From 69404296d0deba28911080b65faa53287797f651 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sun, 24 May 2026 13:19:40 -0500 Subject: [PATCH 01/90] feat!: convert to pnpm monorepo with server app Move all application code into apps/server/ as the @rsscloud/server package (v4.0.0). Root becomes a workspace orchestrator with shared devDependencies. Updates Dockerfile, docker-compose, release-please, and CI configs for the new monorepo structure. BREAKING CHANGE: project restructured as pnpm monorepo Co-Authored-By: Claude Opus 4.6 (1M context) --- .circleci/config.yml | 16 +- .dockerignore | 4 +- .github/workflows/release-please.yml | 20 +- .gitignore | 6 +- .jshintrc | 2 +- .prettierrc | 22 +- .release-please-manifest.json | 2 +- CLAUDE.md | 44 +- Dockerfile | 2 + README.md | 15 +- .mocharc.js => apps/server/.mocharc.js | 6 +- CHANGELOG.md => apps/server/CHANGELOG.md | 58 +- app.js => apps/server/app.js | 104 +- client.js => apps/server/client.js | 113 +- config.js => apps/server/config.js | 0 apps/server/controllers/docs.js | 33 + apps/server/controllers/home.js | 15 + .../server/controllers}/index.js | 15 +- apps/server/controllers/ping-form.js | 15 + .../server/controllers}/ping.js | 26 +- apps/server/controllers/please-notify-form.js | 15 + .../server/controllers}/please-notify.js | 28 +- apps/server/controllers/rpc2.js | 102 + .../server/controllers}/stats.js | 2 +- .../server/controllers}/test.js | 10 +- .../server/controllers}/view-log.js | 2 +- apps/server/package.json | 44 + {public => apps/server/public}/.gitignore | 0 {public => apps/server/public}/css/style.css | 34 +- {public => apps/server/public}/favicon.ico | Bin {public => apps/server/public}/sw.js | 0 apps/server/services/app-messages.js | 32 + .../server/services}/dayjs-wrapper.js | 3 +- .../server/services}/error-response.js | 0 .../server/services}/error-result.js | 4 +- .../server/services}/get-random-password.js | 3 +- .../server/services}/init-resource.js | 0 .../server/services}/init-subscription.js | 15 +- .../server/services}/json-store.js | 4 +- .../server/services}/log-event.js | 0 .../server/services}/notify-one-challenge.js | 16 +- .../server/services}/notify-one.js | 28 +- .../server/services}/notify-subscribers.js | 5 +- .../server/services}/parse-feed.js | 14 +- .../server/services}/parse-notify-params.js | 50 +- .../server/services}/parse-ping-params.js | 4 +- apps/server/services/parse-rpc-request.js | 91 + {services => apps/server/services}/ping.js | 34 +- .../server/services}/please-notify.js | 59 +- .../services}/remove-expired-subscriptions.js | 52 +- .../server/services}/rest-return-success.js | 5 +- apps/server/services/rpc-return-fault.js | 32 + apps/server/services/rpc-return-success.js | 21 + {services => apps/server/services}/stats.js | 21 +- .../server/services}/websocket.js | 12 +- {test => apps/server/test}/fixtures.js | 4 +- {test => apps/server/test}/keys/README.md | 0 {test => apps/server/test}/keys/server.cert | 0 {test => apps/server/test}/keys/server.key | 0 {test => apps/server/test}/mock.js | 109 +- apps/server/test/ping.js | 884 +++ {test => apps/server/test}/please-notify.js | 273 +- .../test}/remove-expired-subscriptions.js | 127 +- apps/server/test/static.js | 38 + {test => apps/server/test}/store-api.js | 57 +- {test => apps/server/test}/xmlrpc-builder.js | 0 apps/server/views/docs.handlebars | 16 + apps/server/views/home.handlebars | 22 + apps/server/views/layouts/main.handlebars | 1 + apps/server/views/ping-form.handlebars | 29 + .../views/please-notify-form.handlebars | 74 + {views => apps/server/views}/stats.handlebars | 0 apps/server/views/view-log.handlebars | 29 + controllers/docs.js | 33 - controllers/home.js | 15 - controllers/ping-form.js | 15 - controllers/please-notify-form.js | 15 - controllers/rpc2.js | 97 - docker-compose.yml | 5 +- eslint.config.js | 12 +- package.json | 82 +- pnpm-lock.yaml | 6846 ++++++++++------- pnpm-workspace.yaml | 2 + release-please-config.json | 10 + services/app-messages.js | 23 - services/parse-rpc-request.js | 86 - services/rpc-return-fault.js | 30 - services/rpc-return-success.js | 19 - test/ping.js | 528 -- test/static.js | 50 - views/docs.handlebars | 17 - views/home.handlebars | 22 - views/layouts/main.handlebars | 1 - views/ping-form.handlebars | 23 - views/please-notify-form.handlebars | 39 - views/view-log.handlebars | 33 - 96 files changed, 6473 insertions(+), 4453 deletions(-) rename .mocharc.js => apps/server/.mocharc.js (52%) rename CHANGELOG.md => apps/server/CHANGELOG.md (64%) rename app.js => apps/server/app.js (50%) rename client.js => apps/server/client.js (86%) rename config.js => apps/server/config.js (100%) create mode 100644 apps/server/controllers/docs.js create mode 100644 apps/server/controllers/home.js rename {controllers => apps/server/controllers}/index.js (87%) create mode 100644 apps/server/controllers/ping-form.js rename {controllers => apps/server/controllers}/ping.js (71%) create mode 100644 apps/server/controllers/please-notify-form.js rename {controllers => apps/server/controllers}/please-notify.js (73%) create mode 100644 apps/server/controllers/rpc2.js rename {controllers => apps/server/controllers}/stats.js (84%) rename {controllers => apps/server/controllers}/test.js (87%) rename {controllers => apps/server/controllers}/view-log.js (88%) create mode 100644 apps/server/package.json rename {public => apps/server/public}/.gitignore (100%) rename {public => apps/server/public}/css/style.css (85%) rename {public => apps/server/public}/favicon.ico (100%) rename {public => apps/server/public}/sw.js (100%) create mode 100644 apps/server/services/app-messages.js rename {services => apps/server/services}/dayjs-wrapper.js (85%) rename {services => apps/server/services}/error-response.js (100%) rename {services => apps/server/services}/error-result.js (64%) rename {services => apps/server/services}/get-random-password.js (83%) rename {services => apps/server/services}/init-resource.js (100%) rename {services => apps/server/services}/init-subscription.js (75%) rename {services => apps/server/services}/json-store.js (95%) rename {services => apps/server/services}/log-event.js (100%) rename {services => apps/server/services}/notify-one-challenge.js (82%) rename {services => apps/server/services}/notify-one.js (83%) rename {services => apps/server/services}/notify-subscribers.js (96%) rename {services => apps/server/services}/parse-feed.js (89%) rename {services => apps/server/services}/parse-notify-params.js (68%) rename {services => apps/server/services}/parse-ping-params.js (87%) create mode 100644 apps/server/services/parse-rpc-request.js rename {services => apps/server/services}/ping.js (80%) rename {services => apps/server/services}/please-notify.js (65%) rename {services => apps/server/services}/remove-expired-subscriptions.js (74%) rename {services => apps/server/services}/rest-return-success.js (76%) create mode 100644 apps/server/services/rpc-return-fault.js create mode 100644 apps/server/services/rpc-return-success.js rename {services => apps/server/services}/stats.js (87%) rename {services => apps/server/services}/websocket.js (78%) rename {test => apps/server/test}/fixtures.js (52%) rename {test => apps/server/test}/keys/README.md (100%) rename {test => apps/server/test}/keys/server.cert (100%) rename {test => apps/server/test}/keys/server.key (100%) rename {test => apps/server/test}/mock.js (54%) create mode 100644 apps/server/test/ping.js rename {test => apps/server/test}/please-notify.js (50%) rename {test => apps/server/test}/remove-expired-subscriptions.js (74%) create mode 100644 apps/server/test/static.js rename {test => apps/server/test}/store-api.js (64%) rename {test => apps/server/test}/xmlrpc-builder.js (100%) create mode 100644 apps/server/views/docs.handlebars create mode 100644 apps/server/views/home.handlebars create mode 100644 apps/server/views/layouts/main.handlebars create mode 100644 apps/server/views/ping-form.handlebars create mode 100644 apps/server/views/please-notify-form.handlebars rename {views => apps/server/views}/stats.handlebars (100%) create mode 100644 apps/server/views/view-log.handlebars delete mode 100644 controllers/docs.js delete mode 100644 controllers/home.js delete mode 100644 controllers/ping-form.js delete mode 100644 controllers/please-notify-form.js delete mode 100644 controllers/rpc2.js create mode 100644 pnpm-workspace.yaml create mode 100644 release-please-config.json delete mode 100644 services/app-messages.js delete mode 100644 services/parse-rpc-request.js delete mode 100644 services/rpc-return-fault.js delete mode 100644 services/rpc-return-success.js delete mode 100644 test/ping.js delete mode 100644 test/static.js delete mode 100644 views/docs.handlebars delete mode 100644 views/home.handlebars delete mode 100644 views/layouts/main.handlebars delete mode 100644 views/ping-form.handlebars delete mode 100644 views/please-notify-form.handlebars delete mode 100644 views/view-log.handlebars diff --git a/.circleci/config.yml b/.circleci/config.yml index 51aff71..3457d5b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,10 +1,10 @@ version: 2 jobs: - build: - machine: true - working_directory: ~/repo - steps: - - checkout - - run: docker-compose up --build --abort-on-container-exit - - store_test_results: - path: xunit + build: + machine: true + working_directory: ~/repo + steps: + - checkout + - run: docker-compose up --build --abort-on-container-exit + - store_test_results: + path: xunit diff --git a/.dockerignore b/.dockerignore index cb09176..9e8e620 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,8 @@ .git .github .circleci -node_modules -data +**/node_modules +**/data xunit .nyc_output .env diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 9843e92..9e977d4 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -1,18 +1,16 @@ name: release-please on: - push: - branches: - - main + push: + branches: + - main permissions: - contents: write - pull-requests: write + contents: write + pull-requests: write jobs: - release-please: - runs-on: ubuntu-latest - steps: - - uses: googleapis/release-please-action@v4 - with: - release-type: node + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 diff --git a/.gitignore b/.gitignore index 41650ce..92f0fdb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ .DS_Store .env .nyc_output/ -/data/ -/node_modules/ -/xunit/ +data/ +node_modules/ +xunit/ Procfile tunnel.sh diff --git a/.jshintrc b/.jshintrc index af8a484..4b301a4 100644 --- a/.jshintrc +++ b/.jshintrc @@ -1 +1 @@ -{ "esversion":8 } +{ "esversion": 8 } diff --git a/.prettierrc b/.prettierrc index 9df0867..176089a 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,13 +1,13 @@ { - "tabWidth": 4, - "useTabs": false, - "semi": true, - "singleQuote": true, - "quoteProps": "as-needed", - "trailingComma": false, - "bracketSpacing": true, - "bracketSameLine": false, - "arrowParens": "avoid", - "printWidth": 80, - "endOfLine": "lf" + "tabWidth": 4, + "useTabs": false, + "semi": true, + "singleQuote": true, + "quoteProps": "as-needed", + "trailingComma": "none", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "printWidth": 80, + "endOfLine": "lf" } diff --git a/.release-please-manifest.json b/.release-please-manifest.json index d4f6f29..eb37449 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.0.0" + "apps/server": "4.0.0" } diff --git a/CLAUDE.md b/CLAUDE.md index 7b4420d..7359094 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,25 +4,45 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is an rssCloud Server v2 implementation in Node.js - a notification protocol server that allows RSS feeds to notify subscribers when they are updated. The server handles subscription management and real-time notifications for RSS/feed updates. +This is an rssCloud Server implementation in Node.js - a notification protocol server that allows RSS feeds to notify subscribers when they are updated. The server handles subscription management and real-time notifications for RSS/feed updates. + +## Monorepo Structure + +This project is a pnpm workspace monorepo. The server application lives in `apps/server/`. + +``` +/ # Workspace root +├── apps/server/ # rssCloud Server application +│ ├── app.js # Express entry point +│ ├── config.js # Configuration from env vars +│ ├── controllers/ # Route handlers +│ ├── services/ # Business logic +│ ├── views/ # Handlebars templates +│ ├── public/ # Static assets +│ └── test/ # Mocha/Chai tests +├── pnpm-workspace.yaml # Workspace definition +├── Dockerfile # Docker build +└── docker-compose.yml # Test environment +``` ## Development Commands This project uses pnpm with corepack. Run `corepack enable` to set up pnpm automatically. -### Start Development +### Start Development (from repo root) - `pnpm start` - Start server with nodemon (auto-reload on changes) - `pnpm run client` - Start client with nodemon -### Testing & Quality +### Testing & Quality (from repo root) - `pnpm test` - Run full API tests using Docker containers (MacOS tested) -- `pnpm run lint` - Run ESLint with auto-fix on controllers/, services/, test/ +- `pnpm run lint` - Run ESLint with auto-fix on server code +- `pnpm run format` - Run Prettier on the entire repo ## Architecture -### Core Application Structure +### Core Application Structure (apps/server/) - **app.js** - Main Express application entry point, sets up middleware, loads jsonStore from disk, and starts server - **config.js** - Configuration management reading from env vars with defaults @@ -47,7 +67,7 @@ This project uses pnpm with corepack. Run `corepack enable` to set up pnpm autom ### Configuration -Environment variables (with defaults in config.js): +Environment variables (with defaults in apps/server/config.js): - `DOMAIN` (default: localhost) - `PORT` (default: 5337) @@ -60,9 +80,9 @@ State is persisted to a JSON file (default `./data/subscriptions.json`) managed ### Testing -- Unit tests in test/ directory using Mocha/Chai +- Tests in apps/server/test/ using Mocha/Chai - Docker-based API testing with mock endpoints -- Test fixtures and SSL certificates in test/keys/ +- Test fixtures and SSL certificates in apps/server/test/keys/ ## Commits and Releases @@ -79,11 +99,13 @@ type: description ### Commit Types **Trigger releases:** -- `fix:` - Bug fixes → patch release (2.2.1 → 2.2.2) -- `feat:` - New features → minor release (2.2.1 → 2.3.0) -- `feat!:` or `BREAKING CHANGE:` → major release (2.2.1 → 3.0.0) + +- `fix:` - Bug fixes → patch release +- `feat:` - New features → minor release +- `feat!:` or `BREAKING CHANGE:` → major release **No release triggered:** + - `chore:` - Maintenance tasks, dependencies - `docs:` - Documentation only - `style:` - Code style/formatting diff --git a/Dockerfile b/Dockerfile index beb147c..bfa2aa3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,9 @@ RUN mkdir -p /app WORKDIR /app COPY package.json . +COPY pnpm-workspace.yaml . COPY pnpm-lock.yaml . +COPY apps/server/package.json apps/server/ FROM base AS dependencies diff --git a/README.md b/README.md index e3f2e8c..d9b5115 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,10 @@ The POST parameters are: 2. port 3. path 4. registerProcedure -- required, but isn't used in this server as it only applies to xml-rpc or soap. -5. protocol -- the spec allows for http-post, xml-rpc or soap but this server only supports http-post and xml-rpc. This server also supports https-post which is identical to http-post except it notifies using https as the scheme instead of http. *Note: if you specify http-post with port 443, the server will automatically use the https scheme for notifications.* For other ports that expect https, use https-post as the protocol. -6. url1, url2, ..., urlN this is the resource you're requesting to be notified about. In the case of an RSS feed you would specify the URL of the RSS feed. +5. protocol -- the spec allows for http-post, xml-rpc or soap but this server only supports http-post and xml-rpc. This server also supports https-post which is identical to http-post except it notifies using https as the scheme instead of http. _Note: if you specify http-post with port 443, the server will automatically use the https scheme for notifications._ For other ports that expect https, use https-post as the protocol. +6. url1, url2, ..., urlN this is the resource you're requesting to be notified about. In the case of an RSS feed you would specify the URL of the RSS feed. -When you POST the server first checks if the urls you specifed are returning an [HTTP 2xx status code](http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2) then it attempts to notify the subscriber of an update to make sure it works. This is done in one of two ways. +When you POST the server first checks if the urls you specifed are returning an [HTTP 2xx status code](http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2) then it attempts to notify the subscriber of an update to make sure it works. This is done in one of two ways. 1. If you did not specify a domain parameter and we're using the requesting IP address we perform a POST request to the URL represented by `http://:` with a single parameter `url`. To accept the subscription that resource just needs to return an HTTP 2xx status code. 2. If you did specify a domain parameter then we perform a GET request to the URL represented by `http://:` with two query string parameters, url and challenge. To accept the subscription that resource needs to return an HTTP 2xx status code and have the challenge value as the response body. @@ -95,7 +95,10 @@ Examples: ``` ```json -{"success":false,"msg":"The subscription was cancelled because the call failed when we tested the handler."} +{ + "success": false, + "msg": "The subscription was cancelled because the call failed when we tested the handler." +} ``` ### POST /ping @@ -106,7 +109,7 @@ The POST parameters are: 1. url -When you POST the server first checks if the url has actually changed since the last time it checked. If it has, it will go through it's list of subscribers and POST to the subscriber with the parameter `url`. +When you POST the server first checks if the url has actually changed since the last time it checked. If it has, it will go through it's list of subscribers and POST to the subscriber with the parameter `url`. The default response type is text/xml but if you POST with an [accept header](http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1) specifying `application/json` we will return a JSON formatted response. @@ -118,7 +121,7 @@ Examples: ``` ```json -{"success":true,"msg":"Thanks for the ping."} +{ "success": true, "msg": "Thanks for the ping." } ``` ### GET /pingForm diff --git a/.mocharc.js b/apps/server/.mocharc.js similarity index 52% rename from .mocharc.js rename to apps/server/.mocharc.js index 4763bbc..bd21175 100644 --- a/.mocharc.js +++ b/apps/server/.mocharc.js @@ -1,8 +1,8 @@ 'use strict'; module.exports = { - 'reporter': 'mocha-multi', + reporter: 'mocha-multi', 'reporter-option': ['spec=-,xunit=xunit/test-results.xml'], - 'require': './test/fixtures.js', - 'timeout': '10000', + require: './test/fixtures.js', + timeout: '10000' }; diff --git a/CHANGELOG.md b/apps/server/CHANGELOG.md similarity index 64% rename from CHANGELOG.md rename to apps/server/CHANGELOG.md index 5d5cfe7..2263848 100644 --- a/CHANGELOG.md +++ b/apps/server/CHANGELOG.md @@ -2,61 +2,55 @@ ## [3.0.0](https://github.com/rsscloud/rsscloud-server/compare/v2.4.0...v3.0.0) (2026-05-15) - ### ⚠ BREAKING CHANGES -* MONGODB_URI is no longer read. Deployments must mount a persistent volume at the data directory (DATA_FILE_PATH, default ./data/subscriptions.json) instead of relying on MongoDB. The bin/import-data.js script is removed. +- MONGODB_URI is no longer read. Deployments must mount a persistent volume at the data directory (DATA_FILE_PATH, default ./data/subscriptions.json) instead of relying on MongoDB. The bin/import-data.js script is removed. ### Features -* add test HTTP CRUD endpoints and store-api helper ([18b6570](https://github.com/rsscloud/rsscloud-server/commit/18b6570ae33729faf96ccf2d066b1bce64b2a013)) -* expand stats feed list with Show All toggle and last-updated timestamps ([525b76e](https://github.com/rsscloud/rsscloud-server/commit/525b76e55aa7c012c46d2095610308d01df97337)) -* extract feed metadata and add OPML subscription list ([94a6ef9](https://github.com/rsscloud/rsscloud-server/commit/94a6ef9ee67eb1f4d48df5429bf8dcf9f0034eb8)) -* remove MongoDB, jsonStore is sole data store ([5fa07b2](https://github.com/rsscloud/rsscloud-server/commit/5fa07b2f2fe2afd50b4ab4d4fd29df32aa58ada3)) -* render stats "Generated at" in browser's local timezone ([adac238](https://github.com/rsscloud/rsscloud-server/commit/adac2388fd00b1d024e3a23d3c698129a7818ce2)) -* retain empty-subscriber entries within feedsChangedWindowDays ([db6c16f](https://github.com/rsscloud/rsscloud-server/commit/db6c16f8973938ecbe28a303dad31120faf94774)) -* retain empty-subscriber entries within feedsChangedWindowDays ([631f234](https://github.com/rsscloud/rsscloud-server/commit/631f234b60822321a18f46c1e7140b203422d4b3)) -* show WebSocket feed URL on viewLog page ([f0a0b0f](https://github.com/rsscloud/rsscloud-server/commit/f0a0b0f115c6cca162d3c8e0c6feeee484613a23)) - +- add test HTTP CRUD endpoints and store-api helper ([18b6570](https://github.com/rsscloud/rsscloud-server/commit/18b6570ae33729faf96ccf2d066b1bce64b2a013)) +- expand stats feed list with Show All toggle and last-updated timestamps ([525b76e](https://github.com/rsscloud/rsscloud-server/commit/525b76e55aa7c012c46d2095610308d01df97337)) +- extract feed metadata and add OPML subscription list ([94a6ef9](https://github.com/rsscloud/rsscloud-server/commit/94a6ef9ee67eb1f4d48df5429bf8dcf9f0034eb8)) +- remove MongoDB, jsonStore is sole data store ([5fa07b2](https://github.com/rsscloud/rsscloud-server/commit/5fa07b2f2fe2afd50b4ab4d4fd29df32aa58ada3)) +- render stats "Generated at" in browser's local timezone ([adac238](https://github.com/rsscloud/rsscloud-server/commit/adac2388fd00b1d024e3a23d3c698129a7818ce2)) +- retain empty-subscriber entries within feedsChangedWindowDays ([db6c16f](https://github.com/rsscloud/rsscloud-server/commit/db6c16f8973938ecbe28a303dad31120faf94774)) +- retain empty-subscriber entries within feedsChangedWindowDays ([631f234](https://github.com/rsscloud/rsscloud-server/commit/631f234b60822321a18f46c1e7140b203422d4b3)) +- show WebSocket feed URL on viewLog page ([f0a0b0f](https://github.com/rsscloud/rsscloud-server/commit/f0a0b0f115c6cca162d3c8e0c6feeee484613a23)) ### Bug Fixes -* harden json-store durability and exclude host files from image ([ff96994](https://github.com/rsscloud/rsscloud-server/commit/ff9699462c45f16c7de92e16c2740aa37dd236d3)) -* serve /LICENSE.md with a header so the docs link works ([085c65f](https://github.com/rsscloud/rsscloud-server/commit/085c65fc5110a81f7802efd8a442eac7ffe22079)) -* update dependencies and patch serialize-javascript ([b7b4bcf](https://github.com/rsscloud/rsscloud-server/commit/b7b4bcfc8536be1e5b96795bdddf99632f1dd698)) +- harden json-store durability and exclude host files from image ([ff96994](https://github.com/rsscloud/rsscloud-server/commit/ff9699462c45f16c7de92e16c2740aa37dd236d3)) +- serve /LICENSE.md with a header so the docs link works ([085c65f](https://github.com/rsscloud/rsscloud-server/commit/085c65fc5110a81f7802efd8a442eac7ffe22079)) +- update dependencies and patch serialize-javascript ([b7b4bcf](https://github.com/rsscloud/rsscloud-server/commit/b7b4bcfc8536be1e5b96795bdddf99632f1dd698)) ## [2.4.0](https://github.com/rsscloud/rsscloud-server/compare/v2.3.1...v2.4.0) (2026-03-20) - ### Features -* add dual-write JSON file store alongside MongoDB ([096f46a](https://github.com/rsscloud/rsscloud-server/commit/096f46ac9408b8af8cce5bbac7dc3111ba54536a)) -* add dual-write JSON file store alongside MongoDB ([b00f6dd](https://github.com/rsscloud/rsscloud-server/commit/b00f6ddff09d88aee8b55f289fed2c4771a87fed)) -* add stats page with cached operational statistics ([5e8731f](https://github.com/rsscloud/rsscloud-server/commit/5e8731f8d379cd403eacfc69e53e06f0579a4172)) - +- add dual-write JSON file store alongside MongoDB ([096f46a](https://github.com/rsscloud/rsscloud-server/commit/096f46ac9408b8af8cce5bbac7dc3111ba54536a)) +- add dual-write JSON file store alongside MongoDB ([b00f6dd](https://github.com/rsscloud/rsscloud-server/commit/b00f6ddff09d88aee8b55f289fed2c4771a87fed)) +- add stats page with cached operational statistics ([5e8731f](https://github.com/rsscloud/rsscloud-server/commit/5e8731f8d379cd403eacfc69e53e06f0579a4172)) ### Bug Fixes -* allow pleaseNotify when ping frequency check triggers ([a31cf02](https://github.com/rsscloud/rsscloud-server/commit/a31cf02ca7c7266dd06c1b95c08e6cc3075cb44a)) -* clean up orphaned resources and subscriptions ([21464c4](https://github.com/rsscloud/rsscloud-server/commit/21464c413fa31543b093e454427bb8f21d6cc8f8)) -* clean up orphaned resources and subscriptions ([d84d3d7](https://github.com/rsscloud/rsscloud-server/commit/d84d3d7adb252360f292e3648515d648bba61dd2)) -* clean up stale and orphaned entries from subscriptions.json ([ff3fa87](https://github.com/rsscloud/rsscloud-server/commit/ff3fa87d1df075ac3a1b3e6107006d4ab77e3029)) -* clean up subscription docs with empty pleaseNotify arrays ([c272b96](https://github.com/rsscloud/rsscloud-server/commit/c272b9605f644c51bc27a3d10fcf8c5544abb224)) -* normalize IPv4-mapped IPv6 addresses in subscription URLs ([c37902f](https://github.com/rsscloud/rsscloud-server/commit/c37902fdfbb70d507f26347c39513582601c835e)) -* remove ping of feeds with missing resource during cleanup ([725daeb](https://github.com/rsscloud/rsscloud-server/commit/725daebf816dbd2eb93d1f9f5ac43437084a2b81)) -* use https scheme for http-post subscriptions on port 443 ([c796df3](https://github.com/rsscloud/rsscloud-server/commit/c796df32fb9916cd47dde21390f06ddb6eee1746)) -* use whenLastUpdate instead of whenLastCheck for stats ([e1ac16c](https://github.com/rsscloud/rsscloud-server/commit/e1ac16c9d9baad78bae2dbbf727738f16913fe46)) +- allow pleaseNotify when ping frequency check triggers ([a31cf02](https://github.com/rsscloud/rsscloud-server/commit/a31cf02ca7c7266dd06c1b95c08e6cc3075cb44a)) +- clean up orphaned resources and subscriptions ([21464c4](https://github.com/rsscloud/rsscloud-server/commit/21464c413fa31543b093e454427bb8f21d6cc8f8)) +- clean up orphaned resources and subscriptions ([d84d3d7](https://github.com/rsscloud/rsscloud-server/commit/d84d3d7adb252360f292e3648515d648bba61dd2)) +- clean up stale and orphaned entries from subscriptions.json ([ff3fa87](https://github.com/rsscloud/rsscloud-server/commit/ff3fa87d1df075ac3a1b3e6107006d4ab77e3029)) +- clean up subscription docs with empty pleaseNotify arrays ([c272b96](https://github.com/rsscloud/rsscloud-server/commit/c272b9605f644c51bc27a3d10fcf8c5544abb224)) +- normalize IPv4-mapped IPv6 addresses in subscription URLs ([c37902f](https://github.com/rsscloud/rsscloud-server/commit/c37902fdfbb70d507f26347c39513582601c835e)) +- remove ping of feeds with missing resource during cleanup ([725daeb](https://github.com/rsscloud/rsscloud-server/commit/725daebf816dbd2eb93d1f9f5ac43437084a2b81)) +- use https scheme for http-post subscriptions on port 443 ([c796df3](https://github.com/rsscloud/rsscloud-server/commit/c796df32fb9916cd47dde21390f06ddb6eee1746)) +- use whenLastUpdate instead of whenLastCheck for stats ([e1ac16c](https://github.com/rsscloud/rsscloud-server/commit/e1ac16c9d9baad78bae2dbbf727738f16913fe46)) ## [2.3.1](https://github.com/rsscloud/rsscloud-server/compare/v2.3.0...v2.3.1) (2026-03-18) - ### Bug Fixes -* use protocol-aware WebSocket URL for viewLog page ([9eba9a6](https://github.com/rsscloud/rsscloud-server/commit/9eba9a64bfe8ce5aa13632389128a775fdb6962c)) +- use protocol-aware WebSocket URL for viewLog page ([9eba9a6](https://github.com/rsscloud/rsscloud-server/commit/9eba9a64bfe8ce5aa13632389128a775fdb6962c)) ## [2.3.0](https://github.com/rsscloud/rsscloud-server/compare/2.2.1...v2.3.0) (2026-03-18) - ### Features -* add realtime log page and improved test infrastructure ([0e5ac48](https://github.com/rsscloud/rsscloud-server/commit/0e5ac485b9004fc20eab6ae1f56430c43b1b50a6)) +- add realtime log page and improved test infrastructure ([0e5ac48](https://github.com/rsscloud/rsscloud-server/commit/0e5ac485b9004fc20eab6ae1f56430c43b1b50a6)) diff --git a/app.js b/apps/server/app.js similarity index 50% rename from app.js rename to apps/server/app.js index 404ef88..d0aa10e 100644 --- a/app.js +++ b/apps/server/app.js @@ -20,21 +20,34 @@ function scheduleCleanupTasks() { // Run cleanup immediately on startup removeExpiredSubscriptions() .then(() => console.log('Startup subscription cleanup completed')) - .catch(err => console.error('Error in startup subscription cleanup:', err)); + .catch(err => + console.error('Error in startup subscription cleanup:', err) + ); // Run subscription cleanup every 24 hours - setInterval(async() => { - try { - console.log('Running scheduled subscription cleanup...'); - await removeExpiredSubscriptions(); - } catch (error) { - console.error('Error in scheduled subscription cleanup:', error); - } - }, 24 * 60 * 60 * 1000); // 24 hours in milliseconds + setInterval( + async () => { + try { + console.log('Running scheduled subscription cleanup...'); + await removeExpiredSubscriptions(); + } catch (error) { + console.error( + 'Error in scheduled subscription cleanup:', + error + ); + } + }, + 24 * 60 * 60 * 1000 + ); // 24 hours in milliseconds } morgan.format('mydate', () => { - return new Date().toLocaleTimeString('en-US', { hour12: false, fractionalSecondDigits: 3 }).replace(/:/g, ':'); + return new Date() + .toLocaleTimeString('en-US', { + hour12: false, + fractionalSecondDigits: 3 + }) + .replace(/:/g, ':'); }); // Initialize dayjs at startup @@ -46,7 +59,11 @@ app = express(); app.set('trust proxy', true); -app.use(morgan('[:mydate] :method :url :status :res[content-length] - :remote-addr - :response-time ms')); +app.use( + morgan( + '[:mydate] :method :url :status :res[content-length] - :remote-addr - :response-time ms' + ) +); app.use(cors()); @@ -64,10 +81,12 @@ app.engine('handlebars', hbs.engine); app.set('view engine', 'handlebars'); // Handle static files in public directory -app.use(express.static('public', { - dotfiles: 'ignore', - maxAge: '1d' -})); +app.use( + express.static('public', { + dotfiles: 'ignore', + maxAge: '1d' + }) +); // Load controllers app.use(require('./controllers')); @@ -81,14 +100,20 @@ process.on('SIGINT', gracefulShutdown); process.on('SIGTERM', gracefulShutdown); // Persist data before dying on an unexpected error -process.on('uncaughtException', (error) => { - console.error('Uncaught exception, flushing data store before exit:', error); +process.on('uncaughtException', error => { + console.error( + 'Uncaught exception, flushing data store before exit:', + error + ); jsonStore.shutdown(); process.exit(1); }); -process.on('unhandledRejection', (reason) => { - console.error('Unhandled promise rejection, flushing data store before exit:', reason); +process.on('unhandledRejection', reason => { + console.error( + 'Unhandled promise rejection, flushing data store before exit:', + reason + ); jsonStore.shutdown(); process.exit(1); }); @@ -102,29 +127,36 @@ async function startServer() { scheduleCleanupTasks(); // Generate stats on startup, then schedule periodic regeneration - stats.generateStats().catch(err => console.error('Error generating initial stats:', err)); + stats + .generateStats() + .catch(err => console.error('Error generating initial stats:', err)); stats.scheduleStatsGeneration(); - server = app.listen(config.port, () => { - app.locals.host = config.domain; - app.locals.port = server.address().port; + server = app + .listen(config.port, () => { + app.locals.host = config.domain; + app.locals.port = server.address().port; - if (app.locals.host.indexOf(':') > -1) { - app.locals.host = '[' + app.locals.host + ']'; - } + if (app.locals.host.indexOf(':') > -1) { + app.locals.host = '[' + app.locals.host + ']'; + } - // Initialize WebSocket server for /wsLog - websocket.initialize(server); + // Initialize WebSocket server for /wsLog + websocket.initialize(server); - console.log(`Listening at http://${app.locals.host}:${app.locals.port}`); - }) - .on('error', (error) => { + console.log( + `Listening at http://${app.locals.host}:${app.locals.port}` + ); + }) + .on('error', error => { switch (error.code) { - case 'EADDRINUSE': - console.log(`Error: Port ${config.port} is already in use.`); - break; - default: - console.log(error.code); + case 'EADDRINUSE': + console.log( + `Error: Port ${config.port} is already in use.` + ); + break; + default: + console.log(error.code); } }); } diff --git a/client.js b/apps/server/client.js similarity index 86% rename from client.js rename to apps/server/client.js index 29a986b..c2d37f7 100644 --- a/client.js +++ b/apps/server/client.js @@ -3,7 +3,7 @@ const bodyParser = require('body-parser'), express = require('express'), morgan = require('morgan'), packageJson = require('./package.json'), - textParser = bodyParser.text({ type: '*/xml'}), + textParser = bodyParser.text({ type: '*/xml' }), urlencodedParser = bodyParser.urlencoded({ extended: false }); // Simple config utility @@ -34,18 +34,29 @@ let app, server; console.log(`${clientConfig.appName} ${clientConfig.appVersion}`); morgan.format('mydate', () => { - return new Date().toLocaleTimeString('en-US', { hour12: false, fractionalSecondDigits: 3 }).replace(/:/g, ':'); + return new Date() + .toLocaleTimeString('en-US', { + hour12: false, + fractionalSecondDigits: 3 + }) + .replace(/:/g, ':'); }); app = express(); -app.use(morgan('[:mydate] :method :url :status :res[content-length] - :remote-addr - :response-time ms')); +app.use( + morgan( + '[:mydate] :method :url :status :res[content-length] - :remote-addr - :response-time ms' + ) +); // Handle static files in public directory -app.use(express.static('public', { - dotfiles: 'ignore', - maxAge: '1d' -})); +app.use( + express.static('public', { + dotfiles: 'ignore', + maxAge: '1d' + }) +); // Request logging middleware - captures all incoming requests app.use((req, res, next) => { @@ -132,9 +143,11 @@ function buildPleaseNotifyCall(port, path, protocol, feedUrl, domain) { methodCall.methodCall.params.param.push({ value: { array: { - data: [{ - value: { string: feedUrl } - }] + data: [ + { + value: { string: feedUrl } + } + ] } } }); @@ -164,7 +177,9 @@ function buildPingCall(feedUrl) { // Helper function to generate RSS feed XML function generateRssFeed(feedName) { - const items = feedItems[feedName] || [{ title: 'initialized', timestamp: new Date() }]; + const items = feedItems[feedName] || [ + { title: 'initialized', timestamp: new Date() } + ]; const feedUrl = `http://${clientConfig.domain}:${clientConfig.port}/${feedName}`; const rss = { @@ -207,15 +222,17 @@ function formatBody(body) { // Helper function to generate HTML page function generateHtmlPage() { - const logHtml = requestLog.map(entry => { - const bodyDisplay = formatBody(entry.body); - return `
+ const logHtml = requestLog + .map(entry => { + const bodyDisplay = formatBody(entry.body); + return `
${entry.method} ${escapeHtml(entry.url)} ${bodyDisplay ? `
${bodyDisplay}
` : ''} ${entry.timestamp}
`; - }).join('\n'); + }) + .join('\n'); return ` @@ -336,7 +353,7 @@ app.get('/', (req, res) => { }); // Route: Subscribe to feed notifications -app.post('/subscribe', urlencodedParser, async(req, res) => { +app.post('/subscribe', urlencodedParser, async (req, res) => { const feedName = req.body.feedName || 'rss-01.xml'; const useXmlRpc = req.body.xmlrpc === 'on'; const feedUrl = `http://${clientConfig.domain}:${clientConfig.port}/${feedName}`; @@ -368,11 +385,16 @@ app.post('/subscribe', urlencodedParser, async(req, res) => { url1: feedUrl }); - response = await fetch(`${clientConfig.rsscloudServer}/pleaseNotify`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: formData.toString() - }); + response = await fetch( + `${clientConfig.rsscloudServer}/pleaseNotify`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: formData.toString() + } + ); } const responseText = await response.text(); @@ -406,7 +428,7 @@ app.post('/subscribe', urlencodedParser, async(req, res) => { }); // Route: Ping feed (add item and notify) -app.post('/ping-feed', urlencodedParser, async(req, res) => { +app.post('/ping-feed', urlencodedParser, async (req, res) => { const feedName = req.body.feedName || 'rss-01.xml'; const useXmlRpc = req.body.xmlrpc === 'on'; const feedUrl = `http://${clientConfig.domain}:${clientConfig.port}/${feedName}`; @@ -441,7 +463,9 @@ app.post('/ping-feed', urlencodedParser, async(req, res) => { response = await fetch(`${clientConfig.rsscloudServer}/ping`, { method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, body: formData.toString() }); } @@ -493,17 +517,19 @@ app.post('/notify', urlencodedParser, (req, res) => { app.post('/RPC2', textParser, (req, res) => { // Body is already logged by middleware // Return XML-RPC success response - const response = builder.create({ - methodResponse: { - params: { - param: { - value: { - boolean: 1 + const response = builder + .create({ + methodResponse: { + params: { + param: { + value: { + boolean: 1 + } } } } - } - }).end({ pretty: true }); + }) + .end({ pretty: true }); res.type('text/xml').send(response); }); @@ -522,18 +548,21 @@ app.get('/:feedName', (req, res) => { res.type('application/rss+xml').send(rssXml); }); -server = app.listen(clientConfig.port, () => { - const host = clientConfig.domain, - port = server.address().port; +server = app + .listen(clientConfig.port, () => { + const host = clientConfig.domain, + port = server.address().port; - console.log(`Listening at http://${host}:${port}`); -}) - .on('error', (error) => { + console.log(`Listening at http://${host}:${port}`); + }) + .on('error', error => { switch (error.code) { - case 'EADDRINUSE': - console.log(`Error: Port ${clientConfig.port} is already in use.`); - break; - default: - console.log(error.code); + case 'EADDRINUSE': + console.log( + `Error: Port ${clientConfig.port} is already in use.` + ); + break; + default: + console.log(error.code); } }); diff --git a/config.js b/apps/server/config.js similarity index 100% rename from config.js rename to apps/server/config.js diff --git a/apps/server/controllers/docs.js b/apps/server/controllers/docs.js new file mode 100644 index 0000000..d40d80d --- /dev/null +++ b/apps/server/controllers/docs.js @@ -0,0 +1,33 @@ +const express = require('express'), + router = new express.Router(), + md = require('markdown-it')(), + fs = require('fs'); + +router.get('/', (req, res) => { + switch (req.accepts('html')) { + case 'html': { + try { + // README keeps its own "# rssCloud Server" heading for GitHub, + // but the docs page uses a consistent "Documentation" header, + // so drop the leading H1 from the rendered output. + const htmltext = md + .render(fs.readFileSync('README.md', { encoding: 'utf8' })) + .replace(/]*>[\s\S]*?<\/h1>\s*/i, ''); + res.render('docs', { + title: 'rssCloud Server: Documentation', + heading: 'rssCloud Server: Documentation', + htmltext + }); + } catch (err) { + console.error('Error reading README.md:', err.message); + res.status(500).send('Internal Server Error'); + } + break; + } + default: + res.status(406).send('Not Acceptable'); + break; + } +}); + +module.exports = router; diff --git a/apps/server/controllers/home.js b/apps/server/controllers/home.js new file mode 100644 index 0000000..2275865 --- /dev/null +++ b/apps/server/controllers/home.js @@ -0,0 +1,15 @@ +const express = require('express'), + router = new express.Router(); + +router.get('/', function (req, res) { + switch (req.accepts('html')) { + case 'html': + res.render('home'); + break; + default: + res.status(406).send('Not Acceptable'); + break; + } +}); + +module.exports = router; diff --git a/controllers/index.js b/apps/server/controllers/index.js similarity index 87% rename from controllers/index.js rename to apps/server/controllers/index.js index c61d847..58d0f2f 100644 --- a/controllers/index.js +++ b/apps/server/controllers/index.js @@ -12,7 +12,9 @@ router.use('/docs', require('./docs')); router.get('/LICENSE.md', (req, res) => { try { - const htmltext = md.render(fs.readFileSync('LICENSE.md', { encoding: 'utf8' })); + const htmltext = md.render( + fs.readFileSync('LICENSE.md', { encoding: 'utf8' }) + ); res.render('docs', { title: 'rssCloud Server: License', heading: 'rssCloud Server: License', @@ -42,7 +44,7 @@ router.get('/subscriptions.json', (req, res) => { res.send(JSON.stringify(jsonStore.getData(), null, 2)); }); -router.get('/feeds.opml', async(req, res, next) => { +router.get('/feeds.opml', async (req, res, next) => { try { const dayjs = await getDayjs(); const nowIso = dayjs().utc().format(); @@ -65,9 +67,14 @@ router.get('/feeds.opml', async(req, res, next) => { outlines.push(outline); } - outlines.sort((a, b) => a.text.toLowerCase().localeCompare(b.text.toLowerCase())); + outlines.sort((a, b) => + a.text.toLowerCase().localeCompare(b.text.toLowerCase()) + ); - const opml = builder.create('opml', { version: '1.0', encoding: 'UTF-8' }); + const opml = builder.create('opml', { + version: '1.0', + encoding: 'UTF-8' + }); opml.att('version', '2.0'); const head = opml.ele('head'); head.ele('title', {}, `rssCloud Server feeds (${config.domain})`); diff --git a/apps/server/controllers/ping-form.js b/apps/server/controllers/ping-form.js new file mode 100644 index 0000000..72286f8 --- /dev/null +++ b/apps/server/controllers/ping-form.js @@ -0,0 +1,15 @@ +const express = require('express'), + router = new express.Router(); + +router.get('/', function (req, res) { + switch (req.accepts('html')) { + case 'html': + res.render('ping-form'); + break; + default: + res.status(406).send('Not Acceptable'); + break; + } +}); + +module.exports = router; diff --git a/controllers/ping.js b/apps/server/controllers/ping.js similarity index 71% rename from controllers/ping.js rename to apps/server/controllers/ping.js index 304d05e..2a242d3 100644 --- a/controllers/ping.js +++ b/apps/server/controllers/ping.js @@ -10,20 +10,16 @@ const bodyParser = require('body-parser'), function processResponse(req, res, result) { switch (req.accepts('xml', 'json')) { - case 'xml': - res.set('Content-Type', 'text/xml'); - res.send(restReturnSuccess( - result.success, - result.msg, - 'result' - )); - break; - case 'json': - res.json(result); - break; - default: - res.status(406).send('Not Acceptable'); - break; + case 'xml': + res.set('Content-Type', 'text/xml'); + res.send(restReturnSuccess(result.success, result.msg, 'result')); + break; + case 'json': + res.json(result); + break; + default: + res.status(406).send('Not Acceptable'); + break; } } @@ -34,7 +30,7 @@ function handleError(req, res, err) { processResponse(req, res, errorResult(err.message)); } -router.post('/', urlencodedParser, async(req, res) => { +router.post('/', urlencodedParser, async (req, res) => { try { const params = parsePingParams.rest(req); const result = await ping(params.url); diff --git a/apps/server/controllers/please-notify-form.js b/apps/server/controllers/please-notify-form.js new file mode 100644 index 0000000..34c706a --- /dev/null +++ b/apps/server/controllers/please-notify-form.js @@ -0,0 +1,15 @@ +const express = require('express'), + router = new express.Router(); + +router.get('/', function (req, res) { + switch (req.accepts('html')) { + case 'html': + res.render('please-notify-form'); + break; + default: + res.status(406).send('Not Acceptable'); + break; + } +}); + +module.exports = router; diff --git a/controllers/please-notify.js b/apps/server/controllers/please-notify.js similarity index 73% rename from controllers/please-notify.js rename to apps/server/controllers/please-notify.js index fb9139f..b664785 100644 --- a/controllers/please-notify.js +++ b/apps/server/controllers/please-notify.js @@ -10,20 +10,18 @@ const bodyParser = require('body-parser'), function processResponse(req, res, result) { switch (req.accepts('xml', 'json')) { - case 'xml': - res.set('Content-Type', 'text/xml'); - res.send(restReturnSuccess( - result.success, - result.msg, - 'notifyResult' - )); - break; - case 'json': - res.json(result); - break; - default: - res.status(406).send('Not Acceptable'); - break; + case 'xml': + res.set('Content-Type', 'text/xml'); + res.send( + restReturnSuccess(result.success, result.msg, 'notifyResult') + ); + break; + case 'json': + res.json(result); + break; + default: + res.status(406).send('Not Acceptable'); + break; } } @@ -34,7 +32,7 @@ function handleError(req, res, err) { processResponse(req, res, errorResult(err.message)); } -router.post('/', urlencodedParser, async function(req, res) { +router.post('/', urlencodedParser, async function (req, res) { try { const params = parseNotifyParams.rest(req); const result = await pleaseNotify( diff --git a/apps/server/controllers/rpc2.js b/apps/server/controllers/rpc2.js new file mode 100644 index 0000000..74681bd --- /dev/null +++ b/apps/server/controllers/rpc2.js @@ -0,0 +1,102 @@ +const bodyParser = require('body-parser'), + ErrorResponse = require('../services/error-response'), + express = require('express'), + getDayjs = require('../services/dayjs-wrapper'), + logEvent = require('../services/log-event'), + parseRpcRequest = require('../services/parse-rpc-request'), + parseNotifyParams = require('../services/parse-notify-params'), + parsePingParams = require('../services/parse-ping-params'), + pleaseNotify = require('../services/please-notify'), + ping = require('../services/ping'), + router = new express.Router(), + rpcReturnSuccess = require('../services/rpc-return-success'), + rpcReturnFault = require('../services/rpc-return-fault'), + textParser = bodyParser.text({ type: '*/xml' }); + +function processResponse(req, res, xmlString) { + switch (req.accepts('xml')) { + case 'xml': + res.set('Content-Type', 'text/xml'); + res.send(xmlString); + break; + default: + res.status(406).send('Not Acceptable'); + break; + } +} + +function handleError(req, res, err) { + if (!(err instanceof ErrorResponse)) { + console.error(err); + } + processResponse(req, res, rpcReturnFault(4, err.message)); +} + +router.post('/', textParser, async function (req, res) { + let params; + const dayjs = await getDayjs(); + + try { + const request = await parseRpcRequest(req); + + logEvent( + 'XmlRpc', + { + methodName: request.methodName, + params: request.params + }, + dayjs().format('x') + ); + + switch (request.methodName) { + case 'rssCloud.hello': + processResponse(req, res, rpcReturnSuccess(true)); + break; + case 'rssCloud.pleaseNotify': + try { + params = parseNotifyParams.rpc(req, request.params); + const result = await pleaseNotify( + params.notifyProcedure, + params.apiurl, + params.protocol, + params.urlList, + params.diffDomain + ); + processResponse(req, res, rpcReturnSuccess(result.success)); + } catch (err) { + handleError(req, res, err); + } + break; + case 'rssCloud.ping': + try { + params = parsePingParams.rpc(req, request.params); + // Dave's rssCloud server always returns true whether it succeeded or not + try { + const result = await ping(params.url); + processResponse( + req, + res, + rpcReturnSuccess(result.success) + ); + } catch { + processResponse(req, res, rpcReturnSuccess(true)); + } + } catch (err) { + handleError(req, res, err); + } + break; + default: + handleError( + req, + res, + new Error( + `Can't make the call because "${request.methodName}" is not defined.` + ) + ); + } + } catch (err) { + handleError(req, res, err); + } +}); + +module.exports = router; diff --git a/controllers/stats.js b/apps/server/controllers/stats.js similarity index 84% rename from controllers/stats.js rename to apps/server/controllers/stats.js index 4a879d9..63dcd73 100644 --- a/controllers/stats.js +++ b/apps/server/controllers/stats.js @@ -2,7 +2,7 @@ const express = require('express'), { getStats } = require('../services/stats'), router = new express.Router(); -router.get('/', function(req, res) { +router.get('/', function (req, res) { const stats = getStats(); res.render('stats', stats); }); diff --git a/controllers/test.js b/apps/server/controllers/test.js similarity index 87% rename from controllers/test.js rename to apps/server/controllers/test.js index c6739db..46b2023 100644 --- a/controllers/test.js +++ b/apps/server/controllers/test.js @@ -3,7 +3,9 @@ const express = require('express'), removeExpiredSubscriptions = require('../services/remove-expired-subscriptions'), router = new express.Router(); -console.warn('[test-api] ENABLE_TEST_API=true — /test/* endpoints are mounted. Never enable in production.'); +console.warn( + '[test-api] ENABLE_TEST_API=true — /test/* endpoints are mounted. Never enable in production.' +); router.use(express.json()); @@ -50,7 +52,9 @@ router.post('/getSubscriptions', (req, res) => { try { const { feedUrl } = req.body; const data = jsonStore.getData(); - const found = Object.prototype.hasOwnProperty.call(data, feedUrl) && Array.isArray(data[feedUrl].subscribers); + const found = + Object.prototype.hasOwnProperty.call(data, feedUrl) && + Array.isArray(data[feedUrl].subscribers); const subscriptions = jsonStore.getSubscriptions(feedUrl); res.json({ success: true, found, subscriptions }); } catch (error) { @@ -66,7 +70,7 @@ router.post('/getData', (req, res) => { } }); -router.post('/removeExpired', async(req, res) => { +router.post('/removeExpired', async (req, res) => { try { const result = await removeExpiredSubscriptions(); res.json({ success: true, result }); diff --git a/controllers/view-log.js b/apps/server/controllers/view-log.js similarity index 88% rename from controllers/view-log.js rename to apps/server/controllers/view-log.js index b948e9b..e601bf0 100644 --- a/controllers/view-log.js +++ b/apps/server/controllers/view-log.js @@ -1,7 +1,7 @@ const express = require('express'), router = new express.Router(); -router.get('/', function(req, res) { +router.get('/', function (req, res) { const wsProtocol = req.protocol === 'https' ? 'wss' : 'ws'; const wsUrl = `${wsProtocol}://${req.get('host')}/wsLog`; res.render('view-log', { diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..17a2be7 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,44 @@ +{ + "name": "@rsscloud/server", + "version": "4.0.0", + "description": "An rssCloud Server", + "main": "app.js", + "scripts": { + "start": "node --use_strict app.js", + "dev": "nodemon --use_strict --ignore data/ ./app.js", + "client": "nodemon --use_strict --ignore data/ ./client.js", + "lint": "eslint --fix controllers/ services/ test/ *.js", + "test": "mocha" + }, + "engines": { + "node": ">=22" + }, + "author": "Andrew Shell ", + "license": "MIT", + "dependencies": { + "body-parser": "^2.2.2", + "cors": "^2.8.6", + "dayjs": "^1.11.20", + "dotenv": "^17.4.2", + "express": "^4.22.2", + "express-handlebars": "^5.3.5", + "markdown-it": "^14.1.1", + "morgan": "^1.10.1", + "ws": "^8.20.1", + "xml2js": "^0.6.2", + "xmlbuilder": "^15.1.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "chai": "^4.5.0", + "chai-http": "^4.4.0", + "chai-json": "^1.0.0", + "chai-xml": "^0.4.1", + "eslint": "^10.4.0", + "https": "^1.0.0", + "mocha": "^11.7.5", + "mocha-multi": "^1.1.7", + "nodemon": "3.1.14", + "supertest": "^7.2.2" + } +} diff --git a/public/.gitignore b/apps/server/public/.gitignore similarity index 100% rename from public/.gitignore rename to apps/server/public/.gitignore diff --git a/public/css/style.css b/apps/server/public/css/style.css similarity index 85% rename from public/css/style.css rename to apps/server/public/css/style.css index abb6991..d9254ac 100644 --- a/public/css/style.css +++ b/apps/server/public/css/style.css @@ -5,7 +5,9 @@ } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', + Arial, sans-serif; line-height: 1.6; color: #333; max-width: 1200px; @@ -63,7 +65,7 @@ label { font-weight: bold; } -input[type="text"] { +input[type='text'] { width: 100%; padding: 10px; border: 1px solid #ddd; @@ -92,10 +94,11 @@ table { border-collapse: collapse; margin: 20px 0; background: white; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } -th, td { +th, +td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; @@ -164,12 +167,16 @@ tr:hover { .log-table .time-column { white-space: nowrap; - font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-family: + 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, + 'Courier New', monospace; } .log-table .secs-column { text-align: right; - font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-family: + 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, + 'Courier New', monospace; } /* Headers display */ @@ -179,7 +186,9 @@ tr:hover { border-radius: 4px; padding: 10px; margin: 10px 0; - font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-family: + 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, + 'Courier New', monospace; font-size: 12px; } @@ -204,15 +213,16 @@ tr:hover { body { padding: 10px; } - + table { font-size: 12px; } - - th, td { + + th, + td { padding: 8px 4px; } - + .log-table .secs-column, .log-table .time-column { display: none; @@ -248,4 +258,4 @@ tr:hover { .feed-url code { font-size: 1em; color: #888; -} \ No newline at end of file +} diff --git a/public/favicon.ico b/apps/server/public/favicon.ico similarity index 100% rename from public/favicon.ico rename to apps/server/public/favicon.ico diff --git a/public/sw.js b/apps/server/public/sw.js similarity index 100% rename from public/sw.js rename to apps/server/public/sw.js diff --git a/apps/server/services/app-messages.js b/apps/server/services/app-messages.js new file mode 100644 index 0000000..21ce56d --- /dev/null +++ b/apps/server/services/app-messages.js @@ -0,0 +1,32 @@ +module.exports = { + error: { + subscription: { + missingParams: params => + `The following parameters were missing from the request body: ${params}.`, + invalidProtocol: protocol => + `Can't accept the subscription because the protocol, ${protocol}, is unsupported.`, + readResource: url => + `The subscription was cancelled because there was an error reading the resource at URL ${url}.`, + noResources: 'No resources specified.', + failedHandler: + 'The subscription was cancelled because the call failed when we tested the handler.' + }, + ping: { + tooRecent: (minSeconds, lastPingSeconds) => + `Can't accept the request because the minimum seconds between pings is ${minSeconds} and you pinged us ${lastPingSeconds} seconds ago.`, + readResource: url => + `The ping was cancelled because there was an error reading the resource at URL ${url}.` + }, + rpc: { + notEnoughParams: method => + `Can't call "${method}" because there aren't enough parameters.`, + tooManyParams: method => + `Can't call "${method}" because there are too many parameters.` + } + }, + success: { + subscription: + "Thanks for the registration. It worked. When the resource updates we'll notify you. Don't forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!", + ping: 'Thanks for the ping.' + } +}; diff --git a/services/dayjs-wrapper.js b/apps/server/services/dayjs-wrapper.js similarity index 85% rename from services/dayjs-wrapper.js rename to apps/server/services/dayjs-wrapper.js index bf2e5ec..1c1be18 100644 --- a/services/dayjs-wrapper.js +++ b/apps/server/services/dayjs-wrapper.js @@ -6,7 +6,8 @@ async function getDayjs() { const utc = await import('dayjs/plugin/utc.js'); const advancedFormat = await import('dayjs/plugin/advancedFormat.js'); const duration = await import('dayjs/plugin/duration.js'); - const customParseFormat = await import('dayjs/plugin/customParseFormat.js'); + const customParseFormat = + await import('dayjs/plugin/customParseFormat.js'); dayjs = dayjsModule.default; dayjs.extend(utc.default); diff --git a/services/error-response.js b/apps/server/services/error-response.js similarity index 100% rename from services/error-response.js rename to apps/server/services/error-response.js diff --git a/services/error-result.js b/apps/server/services/error-result.js similarity index 64% rename from services/error-result.js rename to apps/server/services/error-result.js index 48587fa..016dcc1 100644 --- a/services/error-result.js +++ b/apps/server/services/error-result.js @@ -1,7 +1,7 @@ function errorResult(err) { return { - 'success': false, - 'msg': err + success: false, + msg: err }; } diff --git a/services/get-random-password.js b/apps/server/services/get-random-password.js similarity index 83% rename from services/get-random-password.js rename to apps/server/services/get-random-password.js index 8c4c1c4..e55bd71 100644 --- a/services/get-random-password.js +++ b/apps/server/services/get-random-password.js @@ -1,7 +1,8 @@ const crypto = require('crypto'); function getRandomPassword(len) { - return crypto.randomBytes(Math.ceil(len * 3 / 4)) + return crypto + .randomBytes(Math.ceil((len * 3) / 4)) .toString('base64') // convert to base64 format .slice(0, len) // return required number of characters .replace(/\+/g, '0') // replace '+' with '0' diff --git a/services/init-resource.js b/apps/server/services/init-resource.js similarity index 100% rename from services/init-resource.js rename to apps/server/services/init-resource.js diff --git a/services/init-subscription.js b/apps/server/services/init-subscription.js similarity index 75% rename from services/init-subscription.js rename to apps/server/services/init-subscription.js index 1783e36..c4d5c51 100644 --- a/services/init-subscription.js +++ b/apps/server/services/init-subscription.js @@ -1,7 +1,12 @@ const config = require('../config'), getDayjs = require('./dayjs-wrapper'); -async function initSubscription(subscriptions, notifyProcedure, apiurl, protocol) { +async function initSubscription( + subscriptions, + notifyProcedure, + apiurl, + protocol +) { const dayjs = await getDayjs(); const defaultSubscription = { ctUpdates: 0, @@ -9,12 +14,16 @@ async function initSubscription(subscriptions, notifyProcedure, apiurl, protocol ctErrors: 0, ctConsecutiveErrors: 0, whenLastError: new Date(dayjs.utc('0', 'x').format()), - whenExpires: new Date(dayjs().utc().add(config.ctSecsResourceExpire, 'seconds').format()), + whenExpires: new Date( + dayjs() + .utc() + .add(config.ctSecsResourceExpire, 'seconds') + .format() + ), url: apiurl, notifyProcedure, protocol }, - index = subscriptions.pleaseNotify.findIndex(subscription => { return subscription.url === apiurl; }); diff --git a/services/json-store.js b/apps/server/services/json-store.js similarity index 95% rename from services/json-store.js rename to apps/server/services/json-store.js index a4b9200..b4ff927 100644 --- a/services/json-store.js +++ b/apps/server/services/json-store.js @@ -61,7 +61,9 @@ function getSubscriptions(feedUrl) { } return { _id: feedUrl, - pleaseNotify: data[feedUrl].subscribers.map(sub => Object.assign({}, sub)) + pleaseNotify: data[feedUrl].subscribers.map(sub => + Object.assign({}, sub) + ) }; } diff --git a/services/log-event.js b/apps/server/services/log-event.js similarity index 100% rename from services/log-event.js rename to apps/server/services/log-event.js diff --git a/services/notify-one-challenge.js b/apps/server/services/notify-one-challenge.js similarity index 82% rename from services/notify-one-challenge.js rename to apps/server/services/notify-one-challenge.js index f7bc04a..b2432ba 100644 --- a/services/notify-one-challenge.js +++ b/apps/server/services/notify-one-challenge.js @@ -6,13 +6,19 @@ const config = require('../config'), async function notifyOneChallengeRest(apiurl, resourceUrl) { const challenge = getRandomPassword(20); - const testUrl = apiurl + '?' + querystring.stringify({ - 'url': resourceUrl, - 'challenge': challenge - }); + const testUrl = + apiurl + + '?' + + querystring.stringify({ + url: resourceUrl, + challenge: challenge + }); const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), config.requestTimeout); + const timeoutId = setTimeout( + () => controller.abort(), + config.requestTimeout + ); try { const res = await fetch(testUrl, { diff --git a/services/notify-one.js b/apps/server/services/notify-one.js similarity index 83% rename from services/notify-one.js rename to apps/server/services/notify-one.js index e838daf..a2920aa 100644 --- a/services/notify-one.js +++ b/apps/server/services/notify-one.js @@ -5,7 +5,10 @@ const builder = require('xmlbuilder'), async function notifyOneRest(apiurl, resourceUrl) { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), config.requestTimeout); + const timeoutId = setTimeout( + () => controller.abort(), + config.requestTimeout + ); try { const formData = new URLSearchParams(); @@ -47,19 +50,22 @@ async function notifyOneRest(apiurl, resourceUrl) { } async function notifyOneRpc(notifyProcedure, apiurl, resourceUrl) { - const xmldoc = builder.create({ - methodCall: { - methodName: notifyProcedure, - params: { - param: [ - { value: resourceUrl } - ] + const xmldoc = builder + .create({ + methodCall: { + methodName: notifyProcedure, + params: { + param: [{ value: resourceUrl }] + } } - } - }).end({ pretty: true }); + }) + .end({ pretty: true }); const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), config.requestTimeout); + const timeoutId = setTimeout( + () => controller.abort(), + config.requestTimeout + ); try { const res = await fetch(apiurl, { diff --git a/services/notify-subscribers.js b/apps/server/services/notify-subscribers.js similarity index 96% rename from services/notify-subscribers.js rename to apps/server/services/notify-subscribers.js index ec5354a..d5edb3f 100644 --- a/services/notify-subscribers.js +++ b/apps/server/services/notify-subscribers.js @@ -90,10 +90,11 @@ async function notifySubscribers(resourceUrl) { } } - await Promise.all(validSubscriptions.map(notifyOneSubscriber.bind(null, resourceUrl))); + await Promise.all( + validSubscriptions.map(notifyOneSubscriber.bind(null, resourceUrl)) + ); upsertSubscriptions(subscriptions); - } module.exports = notifySubscribers; diff --git a/services/parse-feed.js b/apps/server/services/parse-feed.js similarity index 89% rename from services/parse-feed.js rename to apps/server/services/parse-feed.js index 5285774..92b67e3 100644 --- a/services/parse-feed.js +++ b/apps/server/services/parse-feed.js @@ -1,7 +1,11 @@ const xml2js = require('xml2js'), config = require('../config'); -const parser = new xml2js.Parser({ explicitArray: false, mergeAttrs: false, trim: true }); +const parser = new xml2js.Parser({ + explicitArray: false, + mergeAttrs: false, + trim: true +}); function extractText(node) { if (node === null || node === undefined) return null; @@ -45,7 +49,10 @@ function pickAtomHtmlLink(linkNode) { const rel = attrs.rel; const type = attrs.type; - const isHtmlish = !type || type.startsWith('text/html') || type === 'application/xhtml+xml'; + const isHtmlish = + !type || + type.startsWith('text/html') || + type === 'application/xhtml+xml'; if ((!rel || rel === 'alternate') && isHtmlish) { return href; @@ -74,7 +81,8 @@ function fromRdf(channel) { title: extractText(channel.title), description: extractText(channel.description), htmlUrl: extractText(channel.link), - language: extractText(channel.language) || extractText(channel['dc:language']) + language: + extractText(channel.language) || extractText(channel['dc:language']) }; } diff --git a/services/parse-notify-params.js b/apps/server/services/parse-notify-params.js similarity index 68% rename from services/parse-notify-params.js rename to apps/server/services/parse-notify-params.js index ab909ae..b2068fe 100644 --- a/services/parse-notify-params.js +++ b/apps/server/services/parse-notify-params.js @@ -3,24 +3,30 @@ const appMessages = require('./app-messages'), function validProtocol(protocol) { switch (protocol) { - case 'http-post': - case 'https-post': - case 'xml-rpc': - return true; - default: - throw new ErrorResponse(appMessages.error.subscription.invalidProtocol(protocol)); + case 'http-post': + case 'https-post': + case 'xml-rpc': + return true; + default: + throw new ErrorResponse( + appMessages.error.subscription.invalidProtocol(protocol) + ); } } function parseUrlList(argv) { - let key, urlList = []; + let key, + urlList = []; if (undefined === argv.hasOwnProperty) { Object.setPrototypeOf(argv, {}); } for (key in argv) { - if (Object.prototype.hasOwnProperty.call(argv, key) && 0 === key.toLowerCase().indexOf('url')) { + if ( + Object.prototype.hasOwnProperty.call(argv, key) && + 0 === key.toLowerCase().indexOf('url') + ) { urlList.push(argv[key]); } } @@ -56,7 +62,8 @@ function rest(req) { params.urlList = parseUrlList(req.body); if (null == req.body.domain || '' === req.body.domain) { - parts.client = req.headers['x-forwarded-for'] || req.connection.remoteAddress; + parts.client = + req.headers['x-forwarded-for'] || req.connection.remoteAddress; params.diffDomain = false; } else { parts.client = req.body.domain; @@ -79,7 +86,10 @@ function rest(req) { } if (0 === s.length) { - parts.scheme = ('https-post' === params.protocol || '443' === String(req.body.port)) ? 'https' : 'http'; + parts.scheme = + 'https-post' === params.protocol || '443' === String(req.body.port) + ? 'https' + : 'http'; parts.port = req.body.port; parts.path = req.body.path; @@ -93,7 +103,9 @@ function rest(req) { return params; } else { s = s.substr(0, s.length - 2); - throw new ErrorResponse(appMessages.error.subscription.missingParams(s)); + throw new ErrorResponse( + appMessages.error.subscription.missingParams(s) + ); } } @@ -102,9 +114,13 @@ function rpc(req, rpcParams) { parts = {}; if (5 > rpcParams.length) { - throw new ErrorResponse(appMessages.error.rpc.notEnoughParams('pleaseNotify')); + throw new ErrorResponse( + appMessages.error.rpc.notEnoughParams('pleaseNotify') + ); } else if (6 < rpcParams.length) { - throw new ErrorResponse(appMessages.error.rpc.tooManyParams('pleaseNotify')); + throw new ErrorResponse( + appMessages.error.rpc.tooManyParams('pleaseNotify') + ); } if (validProtocol(rpcParams[3])) { @@ -114,7 +130,8 @@ function rpc(req, rpcParams) { params.urlList = rpcParams[4]; if (undefined === rpcParams[5]) { - parts.client = req.headers['x-forwarded-for'] || req.connection.remoteAddress; + parts.client = + req.headers['x-forwarded-for'] || req.connection.remoteAddress; params.diffDomain = false; } else { parts.client = rpcParams[5]; @@ -127,7 +144,10 @@ function rpc(req, rpcParams) { params.notifyProcedure = false; } - parts.scheme = ('https-post' === params.protocol || '443' === String(rpcParams[1])) ? 'https' : 'http'; + parts.scheme = + 'https-post' === params.protocol || '443' === String(rpcParams[1]) + ? 'https' + : 'http'; parts.port = rpcParams[1]; parts.path = rpcParams[2]; diff --git a/services/parse-ping-params.js b/apps/server/services/parse-ping-params.js similarity index 87% rename from services/parse-ping-params.js rename to apps/server/services/parse-ping-params.js index 8e975e5..2a0055f 100644 --- a/services/parse-ping-params.js +++ b/apps/server/services/parse-ping-params.js @@ -13,7 +13,9 @@ function rest(req) { return params; } else { s = s.substr(0, s.length - 2); - throw new ErrorResponse(appMessages.error.subscription.missingParams(s)); + throw new ErrorResponse( + appMessages.error.subscription.missingParams(s) + ); } } diff --git a/apps/server/services/parse-rpc-request.js b/apps/server/services/parse-rpc-request.js new file mode 100644 index 0000000..3a6ef40 --- /dev/null +++ b/apps/server/services/parse-rpc-request.js @@ -0,0 +1,91 @@ +const getDayjs = require('./dayjs-wrapper'), + xml2js = require('xml2js'); + +async function parseRpcParam(param, dayjs) { + let returnedValue, tag, member, values; + + const value = param.value || param; + + for (tag in value) { + switch (tag) { + case 'i4': + case 'int': + case 'double': + returnedValue = Number(value[tag]); + break; + case 'string': + returnedValue = value[tag]; + break; + case 'boolean': + returnedValue = 'true' === value[tag] || !!Number(value[tag]); + break; + case 'dateTime.iso8601': + returnedValue = dayjs.utc(value[tag], [ + 'YYYYMMDDTHHmmss', + dayjs.ISO_8601 + ]); + break; + case 'base64': + returnedValue = Buffer.from(value[tag], 'base64').toString( + 'utf8' + ); + break; + case 'struct': + member = value[tag].member || []; + if (!Array.isArray(member)) { + member = [member]; + } + returnedValue = {}; + for (const item of member) { + returnedValue[item.name] = await parseRpcParam(item, dayjs); + } + break; + case 'array': + values = (value[tag].data || {}).value || []; + if (!Array.isArray(values)) { + values = [values]; + } + returnedValue = []; + for (const item of values) { + returnedValue.push(await parseRpcParam(item, dayjs)); + } + break; + } + } + + if (undefined === returnedValue) { + returnedValue = value; + } + + return returnedValue; +} + +async function parseRpcRequest(req) { + const dayjs = await getDayjs(); + const parser = new xml2js.Parser({ explicitArray: false }), + jstruct = await parser.parseStringPromise(req.body), + methodCall = jstruct.methodCall, + methodName = (methodCall || {}).methodName, + params = ((methodCall || {}).params || {}).param || []; + + if (undefined === methodCall) { + throw new Error('Bad XML-RPC call, missing "methodCall" element.'); + } + + if (undefined === methodName) { + throw new Error('Bad XML-RPC call, missing "methodName" element.'); + } + + const parsedParams = []; + const paramArray = Array.isArray(params) ? params : [params]; + for (const param of paramArray) { + parsedParams.push(await parseRpcParam(param, dayjs)); + } + + return { + methodName, + params: parsedParams + }; +} + +module.exports = parseRpcRequest; diff --git a/services/ping.js b/apps/server/services/ping.js similarity index 80% rename from services/ping.js rename to apps/server/services/ping.js index fdd5c32..5248224 100644 --- a/services/ping.js +++ b/apps/server/services/ping.js @@ -10,12 +10,16 @@ const appMessage = require('./app-messages'), parseFeed = require('./parse-feed'); async function checkPingFrequency(resource) { - let ctsecs, minsecs = config.minSecsBetweenPings; + let ctsecs, + minsecs = config.minSecsBetweenPings; if (0 < minsecs) { const dayjs = await getDayjs(); ctsecs = dayjs().diff(resource.whenLastCheck, 'seconds'); if (ctsecs < minsecs) { - throw new ErrorResponse(appMessage.error.ping.tooRecent(minsecs, ctsecs), 'PING_TOO_RECENT'); + throw new ErrorResponse( + appMessage.error.ping.tooRecent(minsecs, ctsecs), + 'PING_TOO_RECENT' + ); } } } @@ -29,7 +33,10 @@ async function checkForResourceChange(resource, resourceUrl, startticks) { let body = ''; const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), config.requestTimeout); + const timeoutId = setTimeout( + () => controller.abort(), + config.requestTimeout + ); try { res = await fetch(resourceUrl, { @@ -52,12 +59,15 @@ async function checkForResourceChange(resource, resourceUrl, startticks) { resource.whenLastCheck = new Date(dayjs().utc().format()); if (res.status < 200 || res.status > 299) { - throw new ErrorResponse(appMessage.error.ping.readResource(resourceUrl)); + throw new ErrorResponse( + appMessage.error.ping.readResource(resourceUrl) + ); } const hash = md5Hash(body); - const changed = (resource.lastHash !== hash) || (resource.lastSize !== body.length); + const changed = + resource.lastHash !== hash || resource.lastSize !== body.length; resource.lastHash = hash; resource.lastSize = body.length; @@ -111,18 +121,20 @@ async function notifySubscribersIfDirty(changed, resource, resourceUrl) { async function ping(resourceUrl) { const dayjs = await getDayjs(); const startticks = dayjs().format('x'), - resource = await initResource( - await fetchResource(resourceUrl) - ); + resource = await initResource(await fetchResource(resourceUrl)); await checkPingFrequency(resource); - const changed = await checkForResourceChange(resource, resourceUrl, startticks); + const changed = await checkForResourceChange( + resource, + resourceUrl, + startticks + ); await notifySubscribersIfDirty(changed, resource, resourceUrl); upsertResource(resource); return { - 'success': true, - 'msg': appMessage.success.ping + success: true, + msg: appMessage.success.ping }; } diff --git a/services/please-notify.js b/apps/server/services/please-notify.js similarity index 65% rename from services/please-notify.js rename to apps/server/services/please-notify.js index e29a2bf..756ef02 100644 --- a/services/please-notify.js +++ b/apps/server/services/please-notify.js @@ -17,7 +17,13 @@ function upsertSubscriptions(subscriptions) { jsonStore.setSubscriptions(subscriptions._id, subscriptions.pleaseNotify); } -async function notifyApiUrl(notifyProcedure, apiurl, protocol, resourceUrl, diffDomain) { +async function notifyApiUrl( + notifyProcedure, + apiurl, + protocol, + resourceUrl, + diffDomain +) { const dayjs = await getDayjs(); const subscriptions = await fetchSubscriptions(resourceUrl), startticks = dayjs().format('x'); @@ -26,7 +32,12 @@ async function notifyApiUrl(notifyProcedure, apiurl, protocol, resourceUrl, diff try { if (diffDomain) { - await notifyOneChallenge(notifyProcedure, apiurl, protocol, resourceUrl); + await notifyOneChallenge( + notifyProcedure, + apiurl, + protocol, + resourceUrl + ); } else { await notifyOne(notifyProcedure, apiurl, protocol, resourceUrl); } @@ -37,8 +48,13 @@ async function notifyApiUrl(notifyProcedure, apiurl, protocol, resourceUrl, diff subscriptions.pleaseNotify[index].ctUpdates += 1; subscriptions.pleaseNotify[index].ctConsecutiveErrors = 0; - subscriptions.pleaseNotify[index].whenLastUpdate = new Date(dayjs().utc().format()); - subscriptions.pleaseNotify[index].whenExpires = dayjs().utc().add(config.ctSecsResourceExpire, 'seconds').format(); + subscriptions.pleaseNotify[index].whenLastUpdate = new Date( + dayjs().utc().format() + ); + subscriptions.pleaseNotify[index].whenExpires = dayjs() + .utc() + .add(config.ctSecsResourceExpire, 'seconds') + .format(); upsertSubscriptions(subscriptions); @@ -57,34 +73,53 @@ async function notifyApiUrl(notifyProcedure, apiurl, protocol, resourceUrl, diff } } -async function pleaseNotify(notifyProcedure, apiurl, protocol, urlList, diffDomain) { +async function pleaseNotify( + notifyProcedure, + apiurl, + protocol, + urlList, + diffDomain +) { if (0 === urlList.length) { throw new ErrorResponse(appMessages.error.subscription.noResources); } const results = await Promise.allSettled( - urlList.map(async(resourceUrl) => { + urlList.map(async resourceUrl => { try { await ping(resourceUrl); } catch (err) { if (err.code !== 'PING_TOO_RECENT') { - throw new ErrorResponse(appMessages.error.subscription.readResource(resourceUrl)); + throw new ErrorResponse( + appMessages.error.subscription.readResource(resourceUrl) + ); } } - await notifyApiUrl(notifyProcedure, apiurl, protocol, resourceUrl, diffDomain); + await notifyApiUrl( + notifyProcedure, + apiurl, + protocol, + resourceUrl, + diffDomain + ); }) ); // Check if all operations failed - const rejectedResults = results.filter(result => result.status === 'rejected'); - if (rejectedResults.length === results.length && rejectedResults.length > 0) { + const rejectedResults = results.filter( + result => result.status === 'rejected' + ); + if ( + rejectedResults.length === results.length && + rejectedResults.length > 0 + ) { // If all operations failed, throw the last error throw rejectedResults[rejectedResults.length - 1].reason; } return { - 'success': true, - 'msg': appMessages.success.subscription + success: true, + msg: appMessages.success.subscription }; } diff --git a/services/remove-expired-subscriptions.js b/apps/server/services/remove-expired-subscriptions.js similarity index 74% rename from services/remove-expired-subscriptions.js rename to apps/server/services/remove-expired-subscriptions.js index 1ec9891..7b8a71e 100644 --- a/services/remove-expired-subscriptions.js +++ b/apps/server/services/remove-expired-subscriptions.js @@ -12,7 +12,9 @@ function shouldRetainEmptyEntry(entry, cutoff, dayjs) { async function removeExpiredSubscriptions() { try { const dayjs = await getDayjs(); - const cutoff = dayjs().utc().subtract(config.feedsChangedWindowDays, 'days'); + const cutoff = dayjs() + .utc() + .subtract(config.feedsChangedWindowDays, 'days'); let totalRemoved = 0; let documentsProcessed = 0; @@ -23,7 +25,11 @@ async function removeExpiredSubscriptions() { for (const [feedUrl, entry] of Object.entries(storeData)) { documentsProcessed++; - if (!entry.subscribers || !Array.isArray(entry.subscribers) || entry.subscribers.length === 0) { + if ( + !entry.subscribers || + !Array.isArray(entry.subscribers) || + entry.subscribers.length === 0 + ) { if (shouldRetainEmptyEntry(entry, cutoff, dayjs)) { continue; } @@ -33,19 +39,24 @@ async function removeExpiredSubscriptions() { } // Filter out expired and errored subscriptions - const validSubscriptions = entry.subscribers.filter(subscription => { - if (dayjs(subscription.whenExpires).isBefore(dayjs())) { - totalRemoved++; - return false; - } + const validSubscriptions = entry.subscribers.filter( + subscription => { + if (dayjs(subscription.whenExpires).isBefore(dayjs())) { + totalRemoved++; + return false; + } - if (subscription.ctConsecutiveErrors > config.maxConsecutiveErrors) { - totalRemoved++; - return false; - } + if ( + subscription.ctConsecutiveErrors > + config.maxConsecutiveErrors + ) { + totalRemoved++; + return false; + } - return true; - }); + return true; + } + ); if (validSubscriptions.length !== entry.subscribers.length) { if (validSubscriptions.length === 0) { @@ -66,7 +77,12 @@ async function removeExpiredSubscriptions() { const currentData = jsonStore.getData(); for (const [feedUrl, entry] of Object.entries(currentData)) { - if (!entry.subscribers || !entry.subscribers.some(sub => sub.url && sub.url.includes('::ffff:'))) { + if ( + !entry.subscribers || + !entry.subscribers.some( + sub => sub.url && sub.url.includes('::ffff:') + ) + ) { continue; } @@ -91,8 +107,11 @@ async function removeExpiredSubscriptions() { const latestData = jsonStore.getData(); for (const [feedUrl, entry] of Object.entries(latestData)) { - if (entry.resource && Object.keys(entry.resource).length > 0 && - (!entry.subscribers || entry.subscribers.length === 0)) { + if ( + entry.resource && + Object.keys(entry.resource).length > 0 && + (!entry.subscribers || entry.subscribers.length === 0) + ) { if (shouldRetainEmptyEntry(entry, cutoff, dayjs)) { continue; } @@ -108,7 +127,6 @@ async function removeExpiredSubscriptions() { urlsFixed, orphanedResourcesRemoved }; - } catch (error) { console.error('Error removing expired subscriptions:', error); throw error; diff --git a/services/rest-return-success.js b/apps/server/services/rest-return-success.js similarity index 76% rename from services/rest-return-success.js rename to apps/server/services/rest-return-success.js index f7b6b52..81580b8 100644 --- a/services/rest-return-success.js +++ b/apps/server/services/rest-return-success.js @@ -3,10 +3,11 @@ const builder = require('xmlbuilder'); function restReturnSuccess(success, message, element) { element = element || 'result'; - return builder.create(element) + return builder + .create(element) .att('success', success ? 'true' : 'false') .att('msg', message) - .end({'pretty': true}); + .end({ pretty: true }); } module.exports = restReturnSuccess; diff --git a/apps/server/services/rpc-return-fault.js b/apps/server/services/rpc-return-fault.js new file mode 100644 index 0000000..368a3a7 --- /dev/null +++ b/apps/server/services/rpc-return-fault.js @@ -0,0 +1,32 @@ +const builder = require('xmlbuilder'); + +function rpcReturnFault(faultCode, faultString) { + return builder + .create({ + methodResponse: { + fault: { + value: { + struct: { + member: [ + { + name: 'faultCode', + value: { + int: faultCode + } + }, + { + name: 'faultString', + value: { + string: faultString + } + } + ] + } + } + } + } + }) + .end({ pretty: true }); +} + +module.exports = rpcReturnFault; diff --git a/apps/server/services/rpc-return-success.js b/apps/server/services/rpc-return-success.js new file mode 100644 index 0000000..95ab3c9 --- /dev/null +++ b/apps/server/services/rpc-return-success.js @@ -0,0 +1,21 @@ +const builder = require('xmlbuilder'); + +function rpcReturnSuccess(success) { + return builder + .create({ + methodResponse: { + params: { + param: [ + { + value: { + boolean: success ? 1 : 0 + } + } + ] + } + } + }) + .end({ pretty: true }); +} + +module.exports = rpcReturnSuccess; diff --git a/services/stats.js b/apps/server/services/stats.js similarity index 87% rename from services/stats.js rename to apps/server/services/stats.js index d02044a..2a74cd4 100644 --- a/services/stats.js +++ b/apps/server/services/stats.js @@ -30,7 +30,10 @@ function getStats() { async function generateStats() { const dayjs = await getDayjs(); const now = dayjs().utc().format(); - const cutoff = dayjs().utc().subtract(config.feedsChangedWindowDays, 'days').toDate(); + const cutoff = dayjs() + .utc() + .subtract(config.feedsChangedWindowDays, 'days') + .toDate(); const data = jsonStore.getData(); @@ -71,11 +74,17 @@ async function generateStats() { } if (activeCount > 0) { - const whenLastUpdate = lastUpdate && lastUpdate.getTime() > 0 - ? lastUpdate.toISOString() - : null; + const whenLastUpdate = + lastUpdate && lastUpdate.getTime() > 0 + ? lastUpdate.toISOString() + : null; const feedTitle = entry.resource?.feedTitle || null; - feedCounts.push({ url: feedUrl, subscriberCount: activeCount, whenLastUpdate, feedTitle }); + feedCounts.push({ + url: feedUrl, + subscriberCount: activeCount, + whenLastUpdate, + feedTitle + }); } } @@ -112,7 +121,7 @@ async function generateStats() { } function scheduleStatsGeneration() { - setInterval(async() => { + setInterval(async () => { try { await generateStats(); } catch (error) { diff --git a/services/websocket.js b/apps/server/services/websocket.js similarity index 78% rename from services/websocket.js rename to apps/server/services/websocket.js index b7fba0e..2e42d24 100644 --- a/services/websocket.js +++ b/apps/server/services/websocket.js @@ -7,10 +7,11 @@ function initialize(server) { wss = new WebSocketServer({ noServer: true }); server.on('upgrade', (request, socket, head) => { - const pathname = new URL(request.url, `http://${request.headers.host}`).pathname; + const pathname = new URL(request.url, `http://${request.headers.host}`) + .pathname; if (pathname === '/wsLog') { - wss.handleUpgrade(request, socket, head, (ws) => { + wss.handleUpgrade(request, socket, head, ws => { wss.emit('connection', ws, request); }); } else { @@ -18,7 +19,7 @@ function initialize(server) { } }); - wss.on('connection', (ws) => { + wss.on('connection', ws => { console.log('WebSocket client connected to /wsLog'); ws.on('close', () => { @@ -34,8 +35,9 @@ function broadcast(data) { const message = JSON.stringify(data); - wss.clients.forEach((client) => { - if (client.readyState === 1) { // WebSocket.OPEN + wss.clients.forEach(client => { + if (client.readyState === 1) { + // WebSocket.OPEN client.send(message); } }); diff --git a/test/fixtures.js b/apps/server/test/fixtures.js similarity index 52% rename from test/fixtures.js rename to apps/server/test/fixtures.js index 8b05fe3..3b15cc6 100644 --- a/test/fixtures.js +++ b/apps/server/test/fixtures.js @@ -1,9 +1,9 @@ const storeApi = require('./store-api'); -exports.mochaGlobalSetup = async function() { +exports.mochaGlobalSetup = async function () { await storeApi.before(); }; -exports.mochaGlobalTeardown = async function() { +exports.mochaGlobalTeardown = async function () { await storeApi.after(); }; diff --git a/test/keys/README.md b/apps/server/test/keys/README.md similarity index 100% rename from test/keys/README.md rename to apps/server/test/keys/README.md diff --git a/test/keys/server.cert b/apps/server/test/keys/server.cert similarity index 100% rename from test/keys/server.cert rename to apps/server/test/keys/server.cert diff --git a/test/keys/server.key b/apps/server/test/keys/server.key similarity index 100% rename from test/keys/server.key rename to apps/server/test/keys/server.key diff --git a/test/mock.js b/apps/server/test/mock.js similarity index 54% rename from test/mock.js rename to apps/server/test/mock.js index 1e1ab83..b58a2a9 100644 --- a/test/mock.js +++ b/apps/server/test/mock.js @@ -2,15 +2,19 @@ const https = require('https'), fs = require('fs'), express = require('express'), bodyParser = require('body-parser'), - textParser = bodyParser.text({ type: '*/xml'}), + textParser = bodyParser.text({ type: '*/xml' }), urlencodedParser = bodyParser.urlencoded({ extended: false }), parseRpcRequest = require('../services/parse-rpc-request'), querystring = require('querystring'), MOCK_SERVER_DOMAIN = process.env.MOCK_SERVER_DOMAIN, MOCK_SERVER_PORT = process.env.MOCK_SERVER_PORT || 8002, - MOCK_SERVER_URL = process.env.MOCK_SERVER_URL || `http://${MOCK_SERVER_DOMAIN}:${MOCK_SERVER_PORT}`, + MOCK_SERVER_URL = + process.env.MOCK_SERVER_URL || + `http://${MOCK_SERVER_DOMAIN}:${MOCK_SERVER_PORT}`, SECURE_MOCK_SERVER_PORT = process.env.SECURE_MOCK_SERVER_PORT || 8003, - SECURE_MOCK_SERVER_URL = process.env.SECURE_MOCK_SERVER_URL || `https://${MOCK_SERVER_DOMAIN}:${SECURE_MOCK_SERVER_PORT}`, + SECURE_MOCK_SERVER_URL = + process.env.SECURE_MOCK_SERVER_URL || + `https://${MOCK_SERVER_DOMAIN}:${SECURE_MOCK_SERVER_PORT}`, rpcReturnFault = require('../services/rpc-return-fault'); async function restController(req, res) { @@ -20,25 +24,27 @@ async function restController(req, res) { if (this.routes[method] && this.routes[method][path]) { this.requests[method][path].push(req); let responseBody = this.routes[method][path].responseBody; - if (300 <= this.routes[method][path].status && 400 > this.routes[method][path].status) { - let location = typeof responseBody === 'function' ? await responseBody(req) : responseBody; + if ( + 300 <= this.routes[method][path].status && + 400 > this.routes[method][path].status + ) { + let location = + typeof responseBody === 'function' + ? await responseBody(req) + : responseBody; if (0 < Object.keys(req.query).length) { location += '?' + querystring.stringify(req.query); } - res - .redirect( - this.routes[method][path].status, - location - ); + res.redirect(this.routes[method][path].status, location); } else { - res - .status(this.routes[method][path].status) - .send(typeof responseBody === 'function' ? await responseBody(req) : responseBody); + res.status(this.routes[method][path].status).send( + typeof responseBody === 'function' + ? await responseBody(req) + : responseBody + ); } } else { - res - .status(501) - .send(`Unknown route ${method} ${path}`); + res.status(501).send(`Unknown route ${method} ${path}`); } } @@ -50,18 +56,18 @@ async function rpcController(req, res) { if (this.routes.RPC2[method]) { this.requests.RPC2[method].push(req); let responseBody = this.routes.RPC2[method].responseBody; - res - .status(200) - .send(typeof responseBody === 'function' ? await responseBody(req) : responseBody); + res.status(200).send( + typeof responseBody === 'function' + ? await responseBody(req) + : responseBody + ); } else { - res - .status(501) - .send(rpcReturnFault(1, `Unknown methodName ${method}`)); + res.status(501).send( + rpcReturnFault(1, `Unknown methodName ${method}`) + ); } } catch (err) { - res - .status(500) - .send(rpcReturnFault(1, err.message)); + res.status(500).send(rpcReturnFault(1, err.message)); } } @@ -75,30 +81,30 @@ module.exports = { secureServerPort: SECURE_MOCK_SERVER_PORT, secureServerUrl: SECURE_MOCK_SERVER_URL, requests: { - 'GET': {}, - 'POST': {}, - 'RPC2': {} + GET: {}, + POST: {}, + RPC2: {} }, routes: { - 'GET': {}, - 'POST': {}, - 'RPC2': {} + GET: {}, + POST: {}, + RPC2: {} }, - route: function(method, path, status, responseBody) { + route: function (method, path, status, responseBody) { this.requests[method][path] = []; this.routes[method][path] = { status, responseBody }; }, - rpc: function(methodName, responseBody) { + rpc: function (methodName, responseBody) { const method = 'RPC2'; this.requests[method][methodName] = []; this.routes[method][methodName] = { responseBody }; }, - before: async function() { + before: async function () { this.app.post('/RPC2', textParser, rpcController.bind(this)); this.app.get('*', restController.bind(this)); this.app.post('*', urlencodedParser, restController.bind(this)); @@ -106,13 +112,20 @@ module.exports = { this.server = await this.app.listen(MOCK_SERVER_PORT); console.log(` → Mock server started on port: ${MOCK_SERVER_PORT}`); - this.secureServer = await https.createServer({ - key: fs.readFileSync('test/keys/server.key'), - cert: fs.readFileSync('test/keys/server.cert') - }, this.app).listen(SECURE_MOCK_SERVER_PORT); - console.log(` → Mock secure server started on port: ${SECURE_MOCK_SERVER_PORT}`); + this.secureServer = await https + .createServer( + { + key: fs.readFileSync('test/keys/server.key'), + cert: fs.readFileSync('test/keys/server.cert') + }, + this.app + ) + .listen(SECURE_MOCK_SERVER_PORT); + console.log( + ` → Mock secure server started on port: ${SECURE_MOCK_SERVER_PORT}` + ); }, - after: async function() { + after: async function () { if (this.server) { this.server.close(); delete this.server; @@ -121,20 +134,20 @@ module.exports = { delete this.secureServer; this.routes = { - 'GET': {}, - 'POST': {}, - 'RPC2': {} + GET: {}, + POST: {}, + RPC2: {} }; } }, - beforeEach: async function() { + beforeEach: async function () { // Nothing }, - afterEach: async function() { + afterEach: async function () { this.requests = { - 'GET': {}, - 'POST': {}, - 'RPC2': {} + GET: {}, + POST: {}, + RPC2: {} }; } }; diff --git a/apps/server/test/ping.js b/apps/server/test/ping.js new file mode 100644 index 0000000..7bc421b --- /dev/null +++ b/apps/server/test/ping.js @@ -0,0 +1,884 @@ +const chai = require('chai'), + chaiHttp = require('chai-http'), + chaiXml = require('chai-xml'), + config = require('../config'), + expect = chai.expect, + getDayjs = require('../services/dayjs-wrapper'), + SERVER_URL = process.env.APP_URL || 'http://localhost:5337', + mock = require('./mock'), + storeApi = require('./store-api'), + xmlrpcBuilder = require('./xmlrpc-builder'), + rpcReturnSuccess = require('../services/rpc-return-success'), + rpcReturnFault = require('../services/rpc-return-fault'); + +chai.use(chaiHttp); +chai.use(chaiXml); + +function ping(pingProtocol, resourceUrl, returnFormat) { + if ('XML-RPC' === pingProtocol) { + const rpctext = xmlrpcBuilder.buildPingCall(resourceUrl); + + return chai + .request(SERVER_URL) + .post('/RPC2') + .set('content-type', 'text/xml') + .send(rpctext); + } else { + let req = chai + .request(SERVER_URL) + .post('/ping') + .set('content-type', 'application/x-www-form-urlencoded'); + + if ('JSON' === returnFormat) { + req.set('accept', 'application/json'); + } + + if (null == resourceUrl) { + return req.send({}); + } else { + return req.send({ url: resourceUrl }); + } + } +} + +for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { + for (const returnFormat of ['XML', 'JSON']) { + for (const pingProtocol of ['XML-RPC', 'REST']) { + if ('XML-RPC' === pingProtocol && 'JSON' === returnFormat) { + // Not Applicable + continue; + } + + describe(`Ping ${pingProtocol} to ${protocol} returning ${returnFormat}`, function () { + before(async function () { + await mock.before(); + }); + + after(async function () { + await mock.after(); + }); + + beforeEach(async function () { + await storeApi.beforeEach(); + await mock.beforeEach(); + }); + + afterEach(async function () { + await storeApi.afterEach(); + await mock.afterEach(); + }); + + it('should accept a ping for new resource', async function () { + const feedPath = '/rss.xml', + pingPath = '/feedupdated', + resourceUrl = mock.serverUrl + feedPath; + + let apiurl = + ('http-post' === protocol + ? mock.serverUrl + : mock.secureServerUrl) + pingPath, + notifyProcedure = false; + + if ('xml-rpc' === protocol) { + apiurl = mock.serverUrl + '/RPC2'; + notifyProcedure = 'river.feedUpdated'; + } + + mock.route('GET', feedPath, 200, ''); + mock.route( + 'POST', + pingPath, + 200, + 'Thanks for the update! :-)' + ); + mock.rpc(notifyProcedure, rpcReturnSuccess(true)); + await storeApi.addSubscription( + resourceUrl, + notifyProcedure, + apiurl, + protocol + ); + + let res = await ping( + pingProtocol, + resourceUrl, + returnFormat + ); + + expect(res).status(200); + + if ('XML-RPC' === pingProtocol) { + expect(res.text).xml.equal(rpcReturnSuccess(true)); + } else { + if ('JSON' === returnFormat) { + expect(res.body).deep.equal({ + success: true, + msg: 'Thanks for the ping.' + }); + } else { + expect(res.text).xml.equal( + '' + ); + } + } + + expect(mock.requests.GET) + .property(feedPath) + .lengthOf(1, `Missing GET ${feedPath}`); + + if ('xml-rpc' === protocol) { + expect(mock.requests.RPC2) + .property(notifyProcedure) + .lengthOf( + 1, + `Missing XML-RPC call ${notifyProcedure}` + ); + expect(mock.requests.RPC2[notifyProcedure][0]).property( + 'rpcBody' + ); + expect( + mock.requests.RPC2[notifyProcedure][0].rpcBody + .params[0] + ).equal(resourceUrl); + } else { + expect(mock.requests.POST) + .property(pingPath) + .lengthOf(1, `Missing POST ${pingPath}`); + expect(mock.requests.POST[pingPath][0]).property( + 'body' + ); + expect(mock.requests.POST[pingPath][0].body).property( + 'url' + ); + expect(mock.requests.POST[pingPath][0].body.url).equal( + resourceUrl + ); + } + }); + + it('should ping multiple subscribers on same domain', async function () { + const feedPath = '/rss.xml', + pingPath1 = '/feedupdated1', + pingPath2 = '/feedupdated2', + resourceUrl = mock.serverUrl + feedPath; + + let apiurl1 = + ('http-post' === protocol + ? mock.serverUrl + : mock.secureServerUrl) + pingPath1, + apiurl2 = + ('http-post' === protocol + ? mock.serverUrl + : mock.secureServerUrl) + pingPath2, + notifyProcedure = false; + + if ('xml-rpc' === protocol) { + apiurl1 = mock.serverUrl + '/RPC2'; + apiurl2 = mock.serverUrl + pingPath2; + notifyProcedure = 'river.feedUpdated'; + } + + mock.route('GET', feedPath, 200, ''); + mock.route( + 'POST', + pingPath1, + 200, + 'Thanks for the update! :-)' + ); + mock.route( + 'POST', + pingPath2, + 200, + 'Thanks for the update! :-)' + ); + mock.rpc(notifyProcedure, rpcReturnSuccess(true)); + await storeApi.addSubscription( + resourceUrl, + notifyProcedure, + apiurl1, + protocol + ); + await storeApi.addSubscription( + resourceUrl, + false, + apiurl2, + 'http-post' + ); + + let res = await ping( + pingProtocol, + resourceUrl, + returnFormat + ); + + expect(res).status(200); + + if ('XML-RPC' === pingProtocol) { + expect(res.text).xml.equal(rpcReturnSuccess(true)); + } else { + if ('JSON' === returnFormat) { + expect(res.body).deep.equal({ + success: true, + msg: 'Thanks for the ping.' + }); + } else { + expect(res.text).xml.equal( + '' + ); + } + } + + expect(mock.requests.GET) + .property(feedPath) + .lengthOf(1, `Missing GET ${feedPath}`); + + if ('xml-rpc' === protocol) { + expect(mock.requests.RPC2) + .property(notifyProcedure) + .lengthOf( + 1, + `Missing XML-RPC call ${notifyProcedure}` + ); + expect(mock.requests.RPC2[notifyProcedure][0]).property( + 'rpcBody' + ); + expect( + mock.requests.RPC2[notifyProcedure][0].rpcBody + .params[0] + ).equal(resourceUrl); + } else { + expect(mock.requests.POST) + .property(pingPath1) + .lengthOf(1, `Missing POST ${pingPath1}`); + expect(mock.requests.POST[pingPath1][0]).property( + 'body' + ); + expect(mock.requests.POST[pingPath1][0].body).property( + 'url' + ); + expect(mock.requests.POST[pingPath1][0].body.url).equal( + resourceUrl + ); + } + + expect(mock.requests.POST) + .property(pingPath2) + .lengthOf(1, `Missing POST ${pingPath2}`); + expect(mock.requests.POST[pingPath2][0]).property('body'); + expect(mock.requests.POST[pingPath2][0].body).property( + 'url' + ); + expect(mock.requests.POST[pingPath2][0].body.url).equal( + resourceUrl + ); + }); + + it('should reject a ping for bad resource', async function () { + const feedPath = '/rss.xml', + pingPath = '/feedupdated', + resourceUrl = mock.serverUrl + feedPath; + + let apiurl = + ('http-post' === protocol + ? mock.serverUrl + : mock.secureServerUrl) + pingPath, + notifyProcedure = false; + + if ('xml-rpc' === protocol) { + apiurl = mock.serverUrl + '/RPC2'; + notifyProcedure = 'river.feedUpdated'; + } + + mock.route('GET', feedPath, 404, 'Not Found'); + mock.route( + 'POST', + pingPath, + 200, + 'Thanks for the update! :-)' + ); + mock.rpc(notifyProcedure, rpcReturnSuccess(true)); + await storeApi.addSubscription( + resourceUrl, + notifyProcedure, + apiurl, + protocol + ); + + let res = await ping( + pingProtocol, + resourceUrl, + returnFormat + ); + + expect(res).status(200); + + if ('XML-RPC' === pingProtocol) { + expect(res.text).xml.equal(rpcReturnSuccess(true)); + } else { + if ('JSON' === returnFormat) { + expect(res.body).deep.equal({ + success: false, + msg: `The ping was cancelled because there was an error reading the resource at URL ${resourceUrl}.` + }); + } else { + expect(res.text).xml.equal( + `` + ); + } + } + + expect(mock.requests.GET) + .property(feedPath) + .lengthOf(1, `Missing GET ${feedPath}`); + + if ('xml-rpc' === protocol) { + expect(mock.requests.RPC2) + .property(notifyProcedure) + .lengthOf( + 0, + `Should not XML-RPC call ${notifyProcedure}` + ); + } else { + expect(mock.requests.POST) + .property(pingPath) + .lengthOf(0, `Should not POST ${pingPath}`); + } + }); + + it('should reject a ping with a missing url', async function () { + const feedPath = '/rss.xml', + pingPath = '/feedupdated', + resourceUrl = null; + + let notifyProcedure = false; + + if ('xml-rpc' === protocol) { + notifyProcedure = 'river.feedUpdated'; + } + + mock.route('GET', feedPath, 404, 'Not Found'); + mock.route( + 'POST', + pingPath, + 200, + 'Thanks for the update! :-)' + ); + mock.rpc(notifyProcedure, rpcReturnSuccess(true)); + + let res = await ping( + pingProtocol, + resourceUrl, + returnFormat + ); + + expect(res).status(200); + + if ('XML-RPC' === pingProtocol) { + expect(res.text).xml.equal( + rpcReturnFault( + 4, + 'Can\'t call "ping" because there aren\'t enough parameters.' + ) + ); + } else { + if ('JSON' === returnFormat) { + expect(res.body).deep.equal({ + success: false, + msg: 'The following parameters were missing from the request body: url.' + }); + } else { + expect(res.text).xml.equal( + '' + ); + } + } + + expect(mock.requests.GET) + .property(feedPath) + .lengthOf(0, `Should not GET ${feedPath}`); + + if ('xml-rpc' === protocol) { + expect(mock.requests.RPC2) + .property(notifyProcedure) + .lengthOf( + 0, + `Should not XML-RPC call ${notifyProcedure}` + ); + } else { + expect(mock.requests.POST) + .property(pingPath) + .lengthOf(0, `Should not POST ${pingPath}`); + } + }); + + it('should accept a ping for unchanged resource', async function () { + const feedPath = '/rss.xml', + pingPath = '/feedupdated', + resourceUrl = mock.serverUrl + feedPath; + + let apiurl = + ('http-post' === protocol + ? mock.serverUrl + : mock.secureServerUrl) + pingPath, + notifyProcedure = false; + + if ('xml-rpc' === protocol) { + apiurl = mock.serverUrl + '/RPC2'; + notifyProcedure = 'river.feedUpdated'; + } + + mock.route('GET', feedPath, 200, ''); + mock.route( + 'POST', + pingPath, + 200, + 'Thanks for the update! :-)' + ); + mock.rpc(notifyProcedure, rpcReturnSuccess(true)); + await storeApi.addSubscription( + resourceUrl, + notifyProcedure, + apiurl, + protocol + ); + + let res = await ping( + pingProtocol, + resourceUrl, + returnFormat + ); + + expect(res).status(200); + + if ('XML-RPC' === pingProtocol) { + expect(res.text).xml.equal(rpcReturnSuccess(true)); + } else { + if ('JSON' === returnFormat) { + expect(res.body).deep.equal({ + success: true, + msg: 'Thanks for the ping.' + }); + } else { + expect(res.text).xml.equal( + '' + ); + } + } + + res = await ping(pingProtocol, resourceUrl, returnFormat); + + expect(res).status(200); + + if ('XML-RPC' === pingProtocol) { + expect(res.text).xml.equal(rpcReturnSuccess(true)); + } else { + if ('JSON' === returnFormat) { + expect(res.body).deep.equal({ + success: true, + msg: 'Thanks for the ping.' + }); + } else { + expect(res.text).xml.equal( + '' + ); + } + } + + expect(mock.requests.GET) + .property(feedPath) + .lengthOf(2, `Missing GET ${feedPath}`); + + if ('xml-rpc' === protocol) { + expect(mock.requests.RPC2) + .property(notifyProcedure) + .lengthOf( + 1, + `Should only XML-RPC call ${notifyProcedure} once` + ); + } else { + expect(mock.requests.POST) + .property(pingPath) + .lengthOf(1, `Should only POST ${pingPath} once`); + } + }); + + it('should accept a ping with slow subscribers', async function () { + this.timeout(5000); + + const feedPath = '/rss.xml', + pingPath = '/feedupdated', + resourceUrl = mock.serverUrl + feedPath; + + let apiurl = + ('http-post' === protocol + ? mock.serverUrl + : mock.secureServerUrl) + pingPath, + notifyProcedure = false; + + if ('xml-rpc' === protocol) { + apiurl = mock.serverUrl + '/RPC2'; + notifyProcedure = 'river.feedUpdated'; + } + + function slowPostResponse(_req) { + return new Promise(function (resolve) { + global.setTimeout(function () { + resolve('Thanks for the update! :-)'); + }, 1000); + }); + } + + mock.route('GET', feedPath, 200, ''); + if ('xml-rpc' === protocol) { + mock.rpc(notifyProcedure, rpcReturnSuccess(true)); + await storeApi.addSubscription( + resourceUrl, + notifyProcedure, + apiurl, + protocol + ); + } else { + for (let i = 0; i < 10; i++) { + mock.route( + 'POST', + pingPath + i, + 200, + slowPostResponse + ); + await storeApi.addSubscription( + resourceUrl, + notifyProcedure, + apiurl + i, + protocol + ); + } + } + + let res = await ping( + pingProtocol, + resourceUrl, + returnFormat + ); + + expect(res).status(200); + + if ('XML-RPC' === pingProtocol) { + expect(res.text).xml.equal(rpcReturnSuccess(true)); + } else { + if ('JSON' === returnFormat) { + expect(res.body).deep.equal({ + success: true, + msg: 'Thanks for the ping.' + }); + } else { + expect(res.text).xml.equal( + '' + ); + } + } + + expect(mock.requests.GET) + .property(feedPath) + .lengthOf(1, `Missing GET ${feedPath}`); + + if ('xml-rpc' === protocol) { + expect(mock.requests.RPC2) + .property(notifyProcedure) + .lengthOf( + 1, + `Missing XML-RPC call ${notifyProcedure}` + ); + expect(mock.requests.RPC2[notifyProcedure][0]).property( + 'rpcBody' + ); + expect( + mock.requests.RPC2[notifyProcedure][0].rpcBody + .params[0] + ).equal(resourceUrl); + } else { + for (let i = 0; i < 10; i++) { + expect(mock.requests.POST) + .property(pingPath + i) + .lengthOf(1, `Missing POST ${pingPath + i}`); + expect( + mock.requests.POST[pingPath + i][0] + ).property('body'); + expect( + mock.requests.POST[pingPath + i][0].body + ).property('url'); + expect( + mock.requests.POST[pingPath + i][0].body.url + ).equal(resourceUrl); + } + } + }); + + it('should not notify expired subscribers', async function () { + const feedPath = '/rss.xml', + pingPath = '/feedupdated', + resourceUrl = mock.serverUrl + feedPath; + + let apiurl = + ('http-post' === protocol + ? mock.serverUrl + : mock.secureServerUrl) + pingPath, + notifyProcedure = false; + + if ('xml-rpc' === protocol) { + apiurl = mock.serverUrl + '/RPC2'; + notifyProcedure = 'river.feedUpdated'; + } + + mock.route('GET', feedPath, 200, ''); + mock.route( + 'POST', + pingPath, + 200, + 'Thanks for the update! :-)' + ); + mock.rpc(notifyProcedure, rpcReturnSuccess(true)); + const dayjs = await getDayjs(); + const subscription = await storeApi.addSubscription( + resourceUrl, + notifyProcedure, + apiurl, + protocol + ); + subscription.whenExpires = dayjs() + .utc() + .subtract(config.ctSecsResourceExpire * 2, 'seconds') + .format(); + await storeApi.updateSubscription( + resourceUrl, + subscription + ); + + let res = await ping( + pingProtocol, + resourceUrl, + returnFormat + ); + + expect(res).status(200); + + if ('XML-RPC' === pingProtocol) { + expect(res.text).xml.equal(rpcReturnSuccess(true)); + } else { + if ('JSON' === returnFormat) { + expect(res.body).deep.equal({ + success: true, + msg: 'Thanks for the ping.' + }); + } else { + expect(res.text).xml.equal( + '' + ); + } + } + + expect(mock.requests.GET) + .property(feedPath) + .lengthOf(1, `Missing GET ${feedPath}`); + + if ('xml-rpc' === protocol) { + expect(mock.requests.RPC2) + .property(notifyProcedure) + .lengthOf( + 0, + `Missing XML-RPC call ${notifyProcedure}` + ); + } else { + expect(mock.requests.POST) + .property(pingPath) + .lengthOf(0, `Missing POST ${pingPath}`); + } + }); + + it('should not notify subscribers with excessive errors', async function () { + const feedPath = '/rss.xml', + pingPath = '/feedupdated', + resourceUrl = mock.serverUrl + feedPath; + + let apiurl = + ('http-post' === protocol + ? mock.serverUrl + : mock.secureServerUrl) + pingPath, + notifyProcedure = false; + + if ('xml-rpc' === protocol) { + apiurl = mock.serverUrl + '/RPC2'; + notifyProcedure = 'river.feedUpdated'; + } + + mock.route('GET', feedPath, 200, ''); + mock.route( + 'POST', + pingPath, + 200, + 'Thanks for the update! :-)' + ); + mock.rpc(notifyProcedure, rpcReturnSuccess(true)); + const subscription = await storeApi.addSubscription( + resourceUrl, + notifyProcedure, + apiurl, + protocol + ); + subscription.ctConsecutiveErrors = + config.maxConsecutiveErrors; + await storeApi.updateSubscription( + resourceUrl, + subscription + ); + + let res = await ping( + pingProtocol, + resourceUrl, + returnFormat + ); + + expect(res).status(200); + + if ('XML-RPC' === pingProtocol) { + expect(res.text).xml.equal(rpcReturnSuccess(true)); + } else { + if ('JSON' === returnFormat) { + expect(res.body).deep.equal({ + success: true, + msg: 'Thanks for the ping.' + }); + } else { + expect(res.text).xml.equal( + '' + ); + } + } + + expect(mock.requests.GET) + .property(feedPath) + .lengthOf(1, `Missing GET ${feedPath}`); + + if ('xml-rpc' === protocol) { + expect(mock.requests.RPC2) + .property(notifyProcedure) + .lengthOf( + 0, + `Missing XML-RPC call ${notifyProcedure}` + ); + } else { + expect(mock.requests.POST) + .property(pingPath) + .lengthOf(0, `Missing POST ${pingPath}`); + } + }); + + it('should consider a very slow subscription an error', async function () { + const feedPath = '/rss.xml', + pingPath = '/feedupdated', + resourceUrl = mock.serverUrl + feedPath; + + let apiurl = + ('http-post' === protocol + ? mock.serverUrl + : mock.secureServerUrl) + pingPath, + notifyProcedure = false; + + if ('xml-rpc' === protocol) { + apiurl = mock.serverUrl + '/RPC2'; + notifyProcedure = 'river.feedUpdated'; + } + + function slowRestResponse(_req) { + return new Promise(resolve => { + global.setTimeout(() => { + resolve('Thanks for the update! :-)'); + }, 8000); + }); + } + + function slowRpcResponse(_req) { + return new Promise(resolve => { + global.setTimeout(() => { + resolve(rpcReturnSuccess(true)); + }, 8000); + }); + } + + mock.route('GET', feedPath, 200, ''); + mock.route('POST', pingPath, 200, slowRestResponse); + mock.rpc(notifyProcedure, slowRpcResponse); + await storeApi.addSubscription( + resourceUrl, + notifyProcedure, + apiurl, + protocol + ); + + let res = await ping( + pingProtocol, + resourceUrl, + returnFormat + ); + + expect(res).status(200); + + const subscription = await storeApi.addSubscription( + resourceUrl, + notifyProcedure, + apiurl, + protocol + ); + expect(subscription.ctConsecutiveErrors).equal(1); + + if ('XML-RPC' === pingProtocol) { + expect(res.text).xml.equal(rpcReturnSuccess(true)); + } else { + if ('JSON' === returnFormat) { + expect(res.body).deep.equal({ + success: true, + msg: 'Thanks for the ping.' + }); + } else { + expect(res.text).xml.equal( + '' + ); + } + } + + expect(mock.requests.GET) + .property(feedPath) + .lengthOf(1, `Missing GET ${feedPath}`); + + if ('xml-rpc' === protocol) { + expect(mock.requests.RPC2) + .property(notifyProcedure) + .lengthOf( + 1, + `Missing XML-RPC call ${notifyProcedure}` + ); + expect(mock.requests.RPC2[notifyProcedure][0]).property( + 'rpcBody' + ); + expect( + mock.requests.RPC2[notifyProcedure][0].rpcBody + .params[0] + ).equal(resourceUrl); + } else { + expect(mock.requests.POST) + .property(pingPath) + .lengthOf(1, `Missing POST ${pingPath}`); + expect(mock.requests.POST[pingPath][0]).property( + 'body' + ); + expect(mock.requests.POST[pingPath][0].body).property( + 'url' + ); + expect(mock.requests.POST[pingPath][0].body.url).equal( + resourceUrl + ); + } + }); + }); + } // end for pingProtocol + } // end for returnFormat +} // end for protocol diff --git a/test/please-notify.js b/apps/server/test/please-notify.js similarity index 50% rename from test/please-notify.js rename to apps/server/test/please-notify.js index 111c2dc..a1f43ad 100644 --- a/test/please-notify.js +++ b/apps/server/test/please-notify.js @@ -38,35 +38,33 @@ function pleaseNotify(pingProtocol, body, returnFormat) { for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { for (const returnFormat of ['XML', 'JSON']) { for (const pingProtocol of ['XML-RPC', 'REST']) { - if ('XML-RPC' === pingProtocol && 'JSON' === returnFormat) { // Not Applicable continue; } - describe(`PleaseNotify ${pingProtocol} to ${protocol} returning ${returnFormat}`, function() { - - before(async function() { + describe(`PleaseNotify ${pingProtocol} to ${protocol} returning ${returnFormat}`, function () { + before(async function () { await storeApi.before(); await mock.before(); }); - after(async function() { + after(async function () { await storeApi.after(); await mock.after(); }); - beforeEach(async function() { + beforeEach(async function () { await storeApi.beforeEach(); await mock.beforeEach(); }); - afterEach(async function() { + afterEach(async function () { await storeApi.afterEach(); await mock.afterEach(); }); - it('should accept a pleaseNotify for new resource', async function() { + it('should accept a pleaseNotify for new resource', async function () { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath; @@ -80,7 +78,10 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { let body = { domain: mock.serverDomain, - port: 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, + port: + 'https-post' === protocol + ? mock.secureServerPort + : mock.serverPort, path: pingPath, notifyProcedure: notifyProcedure, protocol, @@ -90,7 +91,9 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { if ('XML-RPC' === pingProtocol) { body = [ notifyProcedure, - 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, + 'https-post' === protocol + ? mock.secureServerPort + : mock.serverPort, pingPath, protocol, [resourceUrl], @@ -99,12 +102,16 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { } mock.route('GET', feedPath, 200, ''); - mock.route('GET', pingPath, 200, (req) => { + mock.route('GET', pingPath, 200, req => { return req.query.challenge; }); mock.rpc(notifyProcedure, rpcReturnSuccess(true)); - let res = await pleaseNotify(pingProtocol, body, returnFormat); + let res = await pleaseNotify( + pingProtocol, + body, + returnFormat + ); expect(res).status(200); @@ -112,20 +119,39 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { expect(res.text).xml.equal(rpcReturnSuccess(true)); } else { if ('JSON' === returnFormat) { - expect(res.body).deep.equal({ success: true, msg: 'Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!' }); + expect(res.body).deep.equal({ + success: true, + msg: "Thanks for the registration. It worked. When the resource updates we'll notify you. Don't forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!" + }); } else { - expect(res.text).xml.equal(''); + expect(res.text).xml.equal( + '' + ); } } - expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); + expect(mock.requests.GET) + .property(feedPath) + .lengthOf(1, `Missing GET ${feedPath}`); if ('xml-rpc' === protocol) { - expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(1, `Missing XML-RPC call ${notifyProcedure}`); - expect(mock.requests.RPC2[notifyProcedure][0]).property('rpcBody'); - expect(mock.requests.RPC2[notifyProcedure][0].rpcBody.params[0]).equal(resourceUrl); + expect(mock.requests.RPC2) + .property(notifyProcedure) + .lengthOf( + 1, + `Missing XML-RPC call ${notifyProcedure}` + ); + expect(mock.requests.RPC2[notifyProcedure][0]).property( + 'rpcBody' + ); + expect( + mock.requests.RPC2[notifyProcedure][0].rpcBody + .params[0] + ).equal(resourceUrl); } else { - expect(mock.requests.GET).property(pingPath).lengthOf(1, `Missing GET ${pingPath}`); + expect(mock.requests.GET) + .property(pingPath) + .lengthOf(1, `Missing GET ${pingPath}`); } // Verify resource document was created @@ -135,7 +161,7 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { expect(resDoc).to.have.property('lastSize'); }); - it('should accept a pleaseNotify without domain for new resource', async function() { + it('should accept a pleaseNotify without domain for new resource', async function () { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath; @@ -148,7 +174,10 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { } let body = { - port: 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, + port: + 'https-post' === protocol + ? mock.secureServerPort + : mock.serverPort, path: pingPath, notifyProcedure: notifyProcedure, protocol, @@ -158,7 +187,9 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { if ('XML-RPC' === pingProtocol) { body = [ notifyProcedure, - 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, + 'https-post' === protocol + ? mock.secureServerPort + : mock.serverPort, pingPath, protocol, [resourceUrl] @@ -166,10 +197,19 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { } mock.route('GET', feedPath, 200, ''); - mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); + mock.route( + 'POST', + pingPath, + 200, + 'Thanks for the update! :-)' + ); mock.rpc(notifyProcedure, rpcReturnSuccess(true)); - let res = await pleaseNotify(pingProtocol, body, returnFormat); + let res = await pleaseNotify( + pingProtocol, + body, + returnFormat + ); expect(res).status(200); @@ -177,24 +217,43 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { expect(res.text).xml.equal(rpcReturnSuccess(true)); } else { if ('JSON' === returnFormat) { - expect(res.body).deep.equal({ success: true, msg: 'Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!' }); + expect(res.body).deep.equal({ + success: true, + msg: "Thanks for the registration. It worked. When the resource updates we'll notify you. Don't forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!" + }); } else { - expect(res.text).xml.equal(''); + expect(res.text).xml.equal( + '' + ); } } - expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); + expect(mock.requests.GET) + .property(feedPath) + .lengthOf(1, `Missing GET ${feedPath}`); if ('xml-rpc' === protocol) { - expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(1, `Missing XML-RPC call ${notifyProcedure}`); - expect(mock.requests.RPC2[notifyProcedure][0]).property('rpcBody'); - expect(mock.requests.RPC2[notifyProcedure][0].rpcBody.params[0]).equal(resourceUrl); + expect(mock.requests.RPC2) + .property(notifyProcedure) + .lengthOf( + 1, + `Missing XML-RPC call ${notifyProcedure}` + ); + expect(mock.requests.RPC2[notifyProcedure][0]).property( + 'rpcBody' + ); + expect( + mock.requests.RPC2[notifyProcedure][0].rpcBody + .params[0] + ).equal(resourceUrl); } else { - expect(mock.requests.POST).property(pingPath).lengthOf(1, `Missing POST ${pingPath}`); + expect(mock.requests.POST) + .property(pingPath) + .lengthOf(1, `Missing POST ${pingPath}`); } }); - it('should reject a pleaseNotify for bad resource', async function() { + it('should reject a pleaseNotify for bad resource', async function () { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath; @@ -207,7 +266,10 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { } let body = { - port: 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, + port: + 'https-post' === protocol + ? mock.secureServerPort + : mock.serverPort, path: pingPath, notifyProcedure: notifyProcedure, protocol, @@ -217,7 +279,9 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { if ('XML-RPC' === pingProtocol) { body = [ notifyProcedure, - 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, + 'https-post' === protocol + ? mock.secureServerPort + : mock.serverPort, pingPath, protocol, [resourceUrl] @@ -225,32 +289,59 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { } mock.route('GET', feedPath, 404, 'Not Found'); - mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); + mock.route( + 'POST', + pingPath, + 200, + 'Thanks for the update! :-)' + ); mock.rpc(notifyProcedure, rpcReturnSuccess(true)); - let res = await pleaseNotify(pingProtocol, body, returnFormat); + let res = await pleaseNotify( + pingProtocol, + body, + returnFormat + ); expect(res).status(200); if ('XML-RPC' === pingProtocol) { - expect(res.text).xml.equal(rpcReturnFault(4, `The subscription was cancelled because there was an error reading the resource at URL ${resourceUrl}.`)); + expect(res.text).xml.equal( + rpcReturnFault( + 4, + `The subscription was cancelled because there was an error reading the resource at URL ${resourceUrl}.` + ) + ); } else { if ('JSON' === returnFormat) { - expect(res.body).deep.equal({ success: false, msg: `The subscription was cancelled because there was an error reading the resource at URL ${resourceUrl}.` }); + expect(res.body).deep.equal({ + success: false, + msg: `The subscription was cancelled because there was an error reading the resource at URL ${resourceUrl}.` + }); } else { - expect(res.text).xml.equal(``); + expect(res.text).xml.equal( + `` + ); } } - expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); + expect(mock.requests.GET) + .property(feedPath) + .lengthOf(1, `Missing GET ${feedPath}`); if ('xml-rpc' === protocol) { - expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(0, `Should not XML-RPC call ${notifyProcedure}`); + expect(mock.requests.RPC2) + .property(notifyProcedure) + .lengthOf( + 0, + `Should not XML-RPC call ${notifyProcedure}` + ); } else { - expect(mock.requests.POST).property(pingPath).lengthOf(0, `Should not POST ${pingPath}`); + expect(mock.requests.POST) + .property(pingPath) + .lengthOf(0, `Should not POST ${pingPath}`); } }); - }); if ('xml-rpc' === protocol) { @@ -258,39 +349,40 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { continue; } - describe(`PleaseNotify ${pingProtocol} to ${protocol} via redirect returning ${returnFormat}`, function() { - - before(async function() { + describe(`PleaseNotify ${pingProtocol} to ${protocol} via redirect returning ${returnFormat}`, function () { + before(async function () { await storeApi.before(); await mock.before(); }); - after(async function() { + after(async function () { await storeApi.after(); await mock.after(); }); - beforeEach(async function() { + beforeEach(async function () { await storeApi.beforeEach(); await mock.beforeEach(); }); - afterEach(async function() { + afterEach(async function () { await storeApi.afterEach(); await mock.afterEach(); }); - it('should accept a pleaseNotify for a redirected subscriber', async function() { + it('should accept a pleaseNotify for a redirected subscriber', async function () { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath; let pingPath = '/feedupdated', redirPath = '/redirect', notifyProcedure = false, - body = { domain: mock.serverDomain, - port: 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, + port: + 'https-post' === protocol + ? mock.secureServerPort + : mock.serverPort, path: redirPath, notifyProcedure: notifyProcedure, protocol, @@ -300,7 +392,9 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { if ('XML-RPC' === pingProtocol) { body = [ notifyProcedure, - 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, + 'https-post' === protocol + ? mock.secureServerPort + : mock.serverPort, redirPath, protocol, [resourceUrl], @@ -309,15 +403,19 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { } mock.route('GET', feedPath, 200, ''); - mock.route('GET', redirPath, 302, (_req) => { + mock.route('GET', redirPath, 302, _req => { return pingPath; }); - mock.route('GET', pingPath, 200, (req) => { + mock.route('GET', pingPath, 200, req => { return req.query.challenge; }); mock.rpc(notifyProcedure, rpcReturnSuccess(true)); - let res = await pleaseNotify(pingProtocol, body, returnFormat); + let res = await pleaseNotify( + pingProtocol, + body, + returnFormat + ); expect(res).status(200); @@ -325,26 +423,37 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { expect(res.text).xml.equal(rpcReturnSuccess(true)); } else { if ('JSON' === returnFormat) { - expect(res.body).deep.equal({ success: true, msg: 'Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!' }); + expect(res.body).deep.equal({ + success: true, + msg: "Thanks for the registration. It worked. When the resource updates we'll notify you. Don't forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!" + }); } else { - expect(res.text).xml.equal(''); + expect(res.text).xml.equal( + '' + ); } } - expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); - expect(mock.requests.GET).property(pingPath).lengthOf(1, `Missing GET ${pingPath}`); + expect(mock.requests.GET) + .property(feedPath) + .lengthOf(1, `Missing GET ${feedPath}`); + expect(mock.requests.GET) + .property(pingPath) + .lengthOf(1, `Missing GET ${pingPath}`); }); - it('should accept a pleaseNotify without domain for a redirected subscriber', async function() { + it('should accept a pleaseNotify without domain for a redirected subscriber', async function () { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath; let pingPath = '/feedupdated', redirPath = '/redirect', notifyProcedure = false, - body = { - port: 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, + port: + 'https-post' === protocol + ? mock.secureServerPort + : mock.serverPort, path: redirPath, notifyProcedure: notifyProcedure, protocol, @@ -354,7 +463,9 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { if ('XML-RPC' === pingProtocol) { body = [ notifyProcedure, - 'https-post' === protocol ? mock.secureServerPort : mock.serverPort, + 'https-post' === protocol + ? mock.secureServerPort + : mock.serverPort, redirPath, protocol, [resourceUrl] @@ -362,13 +473,22 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { } mock.route('GET', feedPath, 200, ''); - mock.route('POST', redirPath, 302, (_req) => { + mock.route('POST', redirPath, 302, _req => { return pingPath; }); - mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); + mock.route( + 'POST', + pingPath, + 200, + 'Thanks for the update! :-)' + ); mock.rpc(notifyProcedure, rpcReturnSuccess(true)); - let res = await pleaseNotify(pingProtocol, body, returnFormat); + let res = await pleaseNotify( + pingProtocol, + body, + returnFormat + ); expect(res).status(200); @@ -376,18 +496,25 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { expect(res.text).xml.equal(rpcReturnSuccess(true)); } else { if ('JSON' === returnFormat) { - expect(res.body).deep.equal({ success: true, msg: 'Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!' }); + expect(res.body).deep.equal({ + success: true, + msg: "Thanks for the registration. It worked. When the resource updates we'll notify you. Don't forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!" + }); } else { - expect(res.text).xml.equal(''); + expect(res.text).xml.equal( + '' + ); } } - expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); - expect(mock.requests.POST).property(pingPath).lengthOf(1, `Missing POST ${pingPath}`); + expect(mock.requests.GET) + .property(feedPath) + .lengthOf(1, `Missing GET ${feedPath}`); + expect(mock.requests.POST) + .property(pingPath) + .lengthOf(1, `Missing POST ${pingPath}`); }); - }); - } // end for pingProtocol } // end for returnFormat } // end for protocol diff --git a/test/remove-expired-subscriptions.js b/apps/server/test/remove-expired-subscriptions.js similarity index 74% rename from test/remove-expired-subscriptions.js rename to apps/server/test/remove-expired-subscriptions.js index 995ccdf..ab49b7b 100644 --- a/test/remove-expired-subscriptions.js +++ b/apps/server/test/remove-expired-subscriptions.js @@ -5,37 +5,44 @@ const chai = require('chai'), mock = require('./mock'), storeApi = require('./store-api'); -describe('RemoveExpiredSubscriptions', function() { - - before(async function() { +describe('RemoveExpiredSubscriptions', function () { + before(async function () { await storeApi.before(); await mock.before(); }); - after(async function() { + after(async function () { await storeApi.after(); await mock.after(); }); - beforeEach(async function() { + beforeEach(async function () { await storeApi.beforeEach(); await mock.beforeEach(); }); - afterEach(async function() { + afterEach(async function () { await storeApi.afterEach(); await mock.afterEach(); }); - it('should remove expired subscriptions', async function() { + it('should remove expired subscriptions', async function () { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath, pingPath = '/feedupdated', apiurl = mock.serverUrl + pingPath, dayjs = await getDayjs(); - const subscription = await storeApi.addSubscription(resourceUrl, false, apiurl, 'http-post'); - subscription.whenExpires = dayjs().utc().subtract(config.ctSecsResourceExpire * 2, 'seconds').format(); + const subscription = await storeApi.addSubscription( + resourceUrl, + false, + apiurl, + 'http-post' + ); + subscription.whenExpires = dayjs() + .utc() + .subtract(config.ctSecsResourceExpire * 2, 'seconds') + .format(); await storeApi.updateSubscription(resourceUrl, subscription); await storeApi.removeExpired(); @@ -44,7 +51,7 @@ describe('RemoveExpiredSubscriptions', function() { expect(doc).to.be.null; }); - it('should remove resource when all subscriptions are removed', async function() { + it('should remove resource when all subscriptions are removed', async function () { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath, pingPath = '/feedupdated', @@ -52,8 +59,16 @@ describe('RemoveExpiredSubscriptions', function() { dayjs = await getDayjs(); // Add subscription and resource - const subscription = await storeApi.addSubscription(resourceUrl, false, apiurl, 'http-post'); - subscription.whenExpires = dayjs().utc().subtract(config.ctSecsResourceExpire * 2, 'seconds').format(); + const subscription = await storeApi.addSubscription( + resourceUrl, + false, + apiurl, + 'http-post' + ); + subscription.whenExpires = dayjs() + .utc() + .subtract(config.ctSecsResourceExpire * 2, 'seconds') + .format(); await storeApi.updateSubscription(resourceUrl, subscription); await storeApi.addResource(resourceUrl, { @@ -61,7 +76,9 @@ describe('RemoveExpiredSubscriptions', function() { lastSize: 100, ctChecks: 1, ctUpdates: 0, - whenLastCheck: new Date(dayjs().utc().subtract(48, 'hours').format()) + whenLastCheck: new Date( + dayjs().utc().subtract(48, 'hours').format() + ) }); await storeApi.removeExpired(); @@ -79,15 +96,23 @@ describe('RemoveExpiredSubscriptions', function() { expect(storeData).to.not.have.property(resourceUrl); }); - it('should remove resource when all subscriptions are removed and whenLastUpdate is absent', async function() { + it('should remove resource when all subscriptions are removed and whenLastUpdate is absent', async function () { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath, pingPath = '/feedupdated', apiurl = mock.serverUrl + pingPath, dayjs = await getDayjs(); - const subscription = await storeApi.addSubscription(resourceUrl, false, apiurl, 'http-post'); - subscription.whenExpires = dayjs().utc().subtract(config.ctSecsResourceExpire * 2, 'seconds').format(); + const subscription = await storeApi.addSubscription( + resourceUrl, + false, + apiurl, + 'http-post' + ); + subscription.whenExpires = dayjs() + .utc() + .subtract(config.ctSecsResourceExpire * 2, 'seconds') + .format(); await storeApi.updateSubscription(resourceUrl, subscription); await storeApi.addResource(resourceUrl, { @@ -113,12 +138,14 @@ describe('RemoveExpiredSubscriptions', function() { expect(storeData).to.not.have.property(resourceUrl); }); - it('should retain empty-subscribers entry when whenLastUpdate is within retention window', async function() { + it('should retain empty-subscribers entry when whenLastUpdate is within retention window', async function () { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath, dayjs = await getDayjs(); - const recentUpdate = new Date(dayjs().utc().subtract(1, 'day').format()); + const recentUpdate = new Date( + dayjs().utc().subtract(1, 'day').format() + ); await storeApi.addResource(resourceUrl, { lastHash: 'abc', @@ -141,12 +168,17 @@ describe('RemoveExpiredSubscriptions', function() { expect(storeData[resourceUrl].subscribers).to.deep.equal([]); }); - it('should remove empty-subscribers entry when whenLastUpdate is beyond retention window', async function() { + it('should remove empty-subscribers entry when whenLastUpdate is beyond retention window', async function () { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath, dayjs = await getDayjs(); - const staleUpdate = new Date(dayjs().utc().subtract(config.feedsChangedWindowDays + 1, 'days').format()); + const staleUpdate = new Date( + dayjs() + .utc() + .subtract(config.feedsChangedWindowDays + 1, 'days') + .format() + ); await storeApi.addResource(resourceUrl, { lastHash: 'abc', @@ -168,17 +200,27 @@ describe('RemoveExpiredSubscriptions', function() { expect(storeData).to.not.have.property(resourceUrl); }); - it('should retain entry when last subscription expires but whenLastUpdate is recent', async function() { + it('should retain entry when last subscription expires but whenLastUpdate is recent', async function () { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath, pingPath = '/feedupdated', apiurl = mock.serverUrl + pingPath, dayjs = await getDayjs(); - const recentUpdate = new Date(dayjs().utc().subtract(1, 'day').format()); - - const subscription = await storeApi.addSubscription(resourceUrl, false, apiurl, 'http-post'); - subscription.whenExpires = dayjs().utc().subtract(config.ctSecsResourceExpire * 2, 'seconds').format(); + const recentUpdate = new Date( + dayjs().utc().subtract(1, 'day').format() + ); + + const subscription = await storeApi.addSubscription( + resourceUrl, + false, + apiurl, + 'http-post' + ); + subscription.whenExpires = dayjs() + .utc() + .subtract(config.ctSecsResourceExpire * 2, 'seconds') + .format(); await storeApi.updateSubscription(resourceUrl, subscription); await storeApi.addResource(resourceUrl, { @@ -206,7 +248,7 @@ describe('RemoveExpiredSubscriptions', function() { expect(storeData[resourceUrl].subscribers).to.deep.equal([]); }); - it('should not remove resource when valid subscriptions remain', async function() { + it('should not remove resource when valid subscriptions remain', async function () { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath, pingPath1 = '/feedupdated1', @@ -216,15 +258,31 @@ describe('RemoveExpiredSubscriptions', function() { dayjs = await getDayjs(); // Add two subscriptions - one expired, one valid - const subscription1 = await storeApi.addSubscription(resourceUrl, false, apiurl1, 'http-post'); - subscription1.whenExpires = dayjs().utc().subtract(config.ctSecsResourceExpire * 2, 'seconds').format(); + const subscription1 = await storeApi.addSubscription( + resourceUrl, + false, + apiurl1, + 'http-post' + ); + subscription1.whenExpires = dayjs() + .utc() + .subtract(config.ctSecsResourceExpire * 2, 'seconds') + .format(); await storeApi.updateSubscription(resourceUrl, subscription1); - await storeApi.addSubscription(resourceUrl, false, apiurl2, 'http-post'); + await storeApi.addSubscription( + resourceUrl, + false, + apiurl2, + 'http-post' + ); // Add resource await storeApi.addResource(resourceUrl, { - lastHash: 'abc', lastSize: 100, ctChecks: 1, ctUpdates: 0 + lastHash: 'abc', + lastSize: 100, + ctChecks: 1, + ctUpdates: 0 }); await storeApi.removeExpired(); @@ -240,7 +298,7 @@ describe('RemoveExpiredSubscriptions', function() { expect(resDoc).to.not.be.null; }); - it('should remove subscription document with empty pleaseNotify array', async function() { + it('should remove subscription document with empty pleaseNotify array', async function () { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath; @@ -267,7 +325,7 @@ describe('RemoveExpiredSubscriptions', function() { expect(storeData).to.not.have.property(resourceUrl); }); - it('should remove orphaned resource with no subscription document', async function() { + it('should remove orphaned resource with no subscription document', async function () { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath, dayjs = await getDayjs(); @@ -278,7 +336,9 @@ describe('RemoveExpiredSubscriptions', function() { lastSize: 100, ctChecks: 1, ctUpdates: 0, - whenLastCheck: new Date(dayjs().utc().subtract(48, 'hours').format()) + whenLastCheck: new Date( + dayjs().utc().subtract(48, 'hours').format() + ) }); await storeApi.removeExpired(); @@ -291,5 +351,4 @@ describe('RemoveExpiredSubscriptions', function() { const storeData = await storeApi.getData(); expect(storeData).to.not.have.property(resourceUrl); }); - }); diff --git a/apps/server/test/static.js b/apps/server/test/static.js new file mode 100644 index 0000000..83c8477 --- /dev/null +++ b/apps/server/test/static.js @@ -0,0 +1,38 @@ +const chai = require('chai'), + chaiHttp = require('chai-http'), + expect = chai.expect, + SERVER_URL = process.env.APP_URL || 'http://localhost:5337'; + +chai.use(chaiHttp); + +describe('Static Pages', function () { + it('docs should return 200', async function () { + let res = await chai.request(SERVER_URL).get('/docs'); + + expect(res).status(200); + }); + + it('home should return 200', async function () { + let res = await chai.request(SERVER_URL).get('/'); + + expect(res).status(200); + }); + + it('pingForm should return 200', async function () { + let res = await chai.request(SERVER_URL).get('/pingForm'); + + expect(res).status(200); + }); + + it('pleaseNotifyForm should return 200', async function () { + let res = await chai.request(SERVER_URL).get('/pleaseNotifyForm'); + + expect(res).status(200); + }); + + it('viewLog should return 200', async function () { + let res = await chai.request(SERVER_URL).get('/viewLog'); + + expect(res).status(200); + }); +}); diff --git a/test/store-api.js b/apps/server/test/store-api.js similarity index 64% rename from test/store-api.js rename to apps/server/test/store-api.js index ffbce5a..5270439 100644 --- a/test/store-api.js +++ b/apps/server/test/store-api.js @@ -16,30 +16,53 @@ async function postJson(path, body) { } async function fetchSubscriptions(resourceUrl) { - const { subscriptions } = await postJson('/test/getSubscriptions', { feedUrl: resourceUrl }); + const { subscriptions } = await postJson('/test/getSubscriptions', { + feedUrl: resourceUrl + }); return subscriptions; } async function setSubscriptions(resourceUrl, pleaseNotify) { - await postJson('/test/setSubscriptions', { feedUrl: resourceUrl, pleaseNotify }); + await postJson('/test/setSubscriptions', { + feedUrl: resourceUrl, + pleaseNotify + }); } module.exports = { - addResource: async function(resourceUrl, resourceObj) { - await postJson('/test/setResource', { feedUrl: resourceUrl, resource: resourceObj }); + addResource: async function (resourceUrl, resourceObj) { + await postJson('/test/setResource', { + feedUrl: resourceUrl, + resource: resourceObj + }); }, - findResource: async function(resourceUrl) { - const { found, resource } = await postJson('/test/getResource', { feedUrl: resourceUrl }); + findResource: async function (resourceUrl) { + const { found, resource } = await postJson('/test/getResource', { + feedUrl: resourceUrl + }); return found ? resource : null; }, - findSubscription: async function(resourceUrl) { - const { found, subscriptions } = await postJson('/test/getSubscriptions', { feedUrl: resourceUrl }); + findSubscription: async function (resourceUrl) { + const { found, subscriptions } = await postJson( + '/test/getSubscriptions', + { feedUrl: resourceUrl } + ); return found ? subscriptions : null; }, - addSubscription: async function(resourceUrl, notifyProcedure, apiurl, protocol) { + addSubscription: async function ( + resourceUrl, + notifyProcedure, + apiurl, + protocol + ) { const subscriptions = await fetchSubscriptions(resourceUrl); - await initSubscription(subscriptions, notifyProcedure, apiurl, protocol); + await initSubscription( + subscriptions, + notifyProcedure, + apiurl, + protocol + ); await setSubscriptions(resourceUrl, subscriptions.pleaseNotify); const index = subscriptions.pleaseNotify.findIndex(subscription => { @@ -52,7 +75,7 @@ module.exports = { throw Error(`Cannot find ${apiurl} subscription`); }, - updateSubscription: async function(resourceUrl, subscription) { + updateSubscription: async function (resourceUrl, subscription) { const subscriptions = await fetchSubscriptions(resourceUrl), index = subscriptions.pleaseNotify.findIndex(match => { return subscription.url === match.url; @@ -67,18 +90,18 @@ module.exports = { throw Error(`Cannot find ${subscription.url} subscription`); }, setSubscriptions, - getData: async function() { + getData: async function () { const { data } = await postJson('/test/getData', {}); return data; }, - removeExpired: async function() { + removeExpired: async function () { const { result } = await postJson('/test/removeExpired', {}); return result; }, - before: async function() {}, - after: async function() {}, - beforeEach: async function() {}, - afterEach: async function() { + before: async function () {}, + after: async function () {}, + beforeEach: async function () {}, + afterEach: async function () { await postJson('/test/clear', {}); } }; diff --git a/test/xmlrpc-builder.js b/apps/server/test/xmlrpc-builder.js similarity index 100% rename from test/xmlrpc-builder.js rename to apps/server/test/xmlrpc-builder.js diff --git a/apps/server/views/docs.handlebars b/apps/server/views/docs.handlebars new file mode 100644 index 0000000..148d03a --- /dev/null +++ b/apps/server/views/docs.handlebars @@ -0,0 +1,16 @@ + + + + + {{#if title}}{{title}}{{else}}rssCloud Server: Documentation{{/if}} + + + + {{#if heading}}

{{heading}}

{{/if}} + {{{htmltext}}} + +

+ ← Back to Home +

+ + \ No newline at end of file diff --git a/apps/server/views/home.handlebars b/apps/server/views/home.handlebars new file mode 100644 index 0000000..76d74d6 --- /dev/null +++ b/apps/server/views/home.handlebars @@ -0,0 +1,22 @@ + + + + + rssCloud Server + + + +

rssCloud Server

+

A notification protocol server that allows RSS feeds to notify + subscribers when they are updated.

+ +

Available Pages

+ + + \ No newline at end of file diff --git a/apps/server/views/layouts/main.handlebars b/apps/server/views/layouts/main.handlebars new file mode 100644 index 0000000..4839ad4 --- /dev/null +++ b/apps/server/views/layouts/main.handlebars @@ -0,0 +1 @@ +{{{body}}} \ No newline at end of file diff --git a/apps/server/views/ping-form.handlebars b/apps/server/views/ping-form.handlebars new file mode 100644 index 0000000..ea2bf3e --- /dev/null +++ b/apps/server/views/ping-form.handlebars @@ -0,0 +1,29 @@ + + + + + rssCloud Server: Ping + + + +

rssCloud Server: Ping

+

Posting to /ping is your way of alerting the server that a resource + has been updated.

+ +
+ + + +
+ +

+ ← Back to Home +

+ + \ No newline at end of file diff --git a/apps/server/views/please-notify-form.handlebars b/apps/server/views/please-notify-form.handlebars new file mode 100644 index 0000000..3fb6a33 --- /dev/null +++ b/apps/server/views/please-notify-form.handlebars @@ -0,0 +1,74 @@ + + + + + rssCloud Server: Please Notify + + + +

rssCloud Server: Please Notify

+

Posting to /pleaseNotify is your way of alerting the server that you + want to receive notifications when one or more resources are + updated.

+ +
+ + + + + + + + + + + + + + + + + + + +
+ +

+ ← Back to Home +

+ + \ No newline at end of file diff --git a/views/stats.handlebars b/apps/server/views/stats.handlebars similarity index 100% rename from views/stats.handlebars rename to apps/server/views/stats.handlebars diff --git a/apps/server/views/view-log.handlebars b/apps/server/views/view-log.handlebars new file mode 100644 index 0000000..5a9c263 --- /dev/null +++ b/apps/server/views/view-log.handlebars @@ -0,0 +1,29 @@ + + + + + rssCloud Server: Log + + + +

rssCloud Server: Log

+

Real-time events on this rssCloud server.

+ + + +
+ + +
+

Feed from {{wsUrl}}

+ +

+ ← Back to Home +

+ + \ No newline at end of file diff --git a/controllers/docs.js b/controllers/docs.js deleted file mode 100644 index 346d62a..0000000 --- a/controllers/docs.js +++ /dev/null @@ -1,33 +0,0 @@ -const express = require('express'), - router = new express.Router(), - md = require('markdown-it')(), - fs = require('fs'); - -router.get('/', (req, res) => { - switch (req.accepts('html')) { - case 'html': { - try { - // README keeps its own "# rssCloud Server" heading for GitHub, - // but the docs page uses a consistent "Documentation" header, - // so drop the leading H1 from the rendered output. - const htmltext = md - .render(fs.readFileSync('README.md', { encoding: 'utf8' })) - .replace(/]*>[\s\S]*?<\/h1>\s*/i, ''); - res.render('docs', { - title: 'rssCloud Server: Documentation', - heading: 'rssCloud Server: Documentation', - htmltext - }); - } catch (err) { - console.error('Error reading README.md:', err.message); - res.status(500).send('Internal Server Error'); - } - break; - } - default: - res.status(406).send('Not Acceptable'); - break; - } -}); - -module.exports = router; diff --git a/controllers/home.js b/controllers/home.js deleted file mode 100644 index eba138c..0000000 --- a/controllers/home.js +++ /dev/null @@ -1,15 +0,0 @@ -const express = require('express'), - router = new express.Router(); - -router.get('/', function(req, res) { - switch (req.accepts('html')) { - case 'html': - res.render('home'); - break; - default: - res.status(406).send('Not Acceptable'); - break; - } -}); - -module.exports = router; diff --git a/controllers/ping-form.js b/controllers/ping-form.js deleted file mode 100644 index 5e5397c..0000000 --- a/controllers/ping-form.js +++ /dev/null @@ -1,15 +0,0 @@ -const express = require('express'), - router = new express.Router(); - -router.get('/', function(req, res) { - switch (req.accepts('html')) { - case 'html': - res.render('ping-form'); - break; - default: - res.status(406).send('Not Acceptable'); - break; - } -}); - -module.exports = router; diff --git a/controllers/please-notify-form.js b/controllers/please-notify-form.js deleted file mode 100644 index 657ceb3..0000000 --- a/controllers/please-notify-form.js +++ /dev/null @@ -1,15 +0,0 @@ -const express = require('express'), - router = new express.Router(); - -router.get('/', function(req, res) { - switch (req.accepts('html')) { - case 'html': - res.render('please-notify-form'); - break; - default: - res.status(406).send('Not Acceptable'); - break; - } -}); - -module.exports = router; diff --git a/controllers/rpc2.js b/controllers/rpc2.js deleted file mode 100644 index 42c95a4..0000000 --- a/controllers/rpc2.js +++ /dev/null @@ -1,97 +0,0 @@ - -const bodyParser = require('body-parser'), - ErrorResponse = require('../services/error-response'), - express = require('express'), - getDayjs = require('../services/dayjs-wrapper'), - logEvent = require('../services/log-event'), - parseRpcRequest = require('../services/parse-rpc-request'), - parseNotifyParams = require('../services/parse-notify-params'), - parsePingParams = require('../services/parse-ping-params'), - pleaseNotify = require('../services/please-notify'), - ping = require('../services/ping'), - router = new express.Router(), - rpcReturnSuccess = require('../services/rpc-return-success'), - rpcReturnFault = require('../services/rpc-return-fault'), - textParser = bodyParser.text({ type: '*/xml'}); - -function processResponse(req, res, xmlString) { - switch (req.accepts('xml')) { - case 'xml': - res.set('Content-Type', 'text/xml'); - res.send(xmlString); - break; - default: - res.status(406).send('Not Acceptable'); - break; - } -} - -function handleError(req, res, err) { - if (!(err instanceof ErrorResponse)) { - console.error(err); - } - processResponse(req, res, rpcReturnFault(4, err.message)); -} - -router.post('/', textParser, async function(req, res) { - let params; - const dayjs = await getDayjs(); - - try { - const request = await parseRpcRequest(req); - - logEvent( - 'XmlRpc', - { - methodName: request.methodName, - params: request.params - }, - dayjs().format('x') - ); - - switch (request.methodName) { - case 'rssCloud.hello': - processResponse(req, res, rpcReturnSuccess(true)); - break; - case 'rssCloud.pleaseNotify': - try { - params = parseNotifyParams.rpc(req, request.params); - const result = await pleaseNotify( - params.notifyProcedure, - params.apiurl, - params.protocol, - params.urlList, - params.diffDomain - ); - processResponse(req, res, rpcReturnSuccess(result.success)); - } catch (err) { - handleError(req, res, err); - } - break; - case 'rssCloud.ping': - try { - params = parsePingParams.rpc(req, request.params); - // Dave's rssCloud server always returns true whether it succeeded or not - try { - const result = await ping(params.url); - processResponse(req, res, rpcReturnSuccess(result.success)); - } catch { - processResponse(req, res, rpcReturnSuccess(true)); - } - } catch (err) { - handleError(req, res, err); - } - break; - default: - handleError( - req, - res, - new Error(`Can't make the call because "${request.methodName}" is not defined.`) - ); - } - } catch (err) { - handleError(req, res, err); - } -}); - -module.exports = router; diff --git a/docker-compose.yml b/docker-compose.yml index 04cbfc7..8012936 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,7 @@ services: rsscloud: build: . + working_dir: /app/apps/server command: node --use_strict app.js environment: DOMAIN: rsscloud @@ -13,6 +14,7 @@ services: rsscloud-tests: build: . + working_dir: /app/apps/server command: dockerize -wait http://rsscloud:5337 -timeout 10s bash -c "pnpm exec mocha" environment: APP_URL: http://rsscloud:5337 @@ -20,10 +22,9 @@ services: MOCK_SERVER_PORT: 8002 SECURE_MOCK_SERVER_PORT: 8003 volumes: - - ./xunit:/app/xunit + - ./xunit:/app/apps/server/xunit expose: - 8002 - 8003 depends_on: - rsscloud - diff --git a/eslint.config.js b/eslint.config.js index bd9719a..398ecaa 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -35,21 +35,21 @@ module.exports = [ }, rules: { // Crockford-inspired formatting - 'indent': ['error', 4], - 'quotes': ['error', 'single'], - 'semi': ['error', 'always'], + indent: ['error', 4], + quotes: ['error', 'single'], + semi: ['error', 'always'], 'no-trailing-spaces': 'error', 'eol-last': 'error', - 'no-multiple-empty-lines': ['error', { 'max': 1 }], + 'no-multiple-empty-lines': ['error', { max: 1 }], 'comma-dangle': ['error', 'never'], - 'brace-style': ['error', '1tbs', { 'allowSingleLine': true }], + 'brace-style': ['error', '1tbs', { allowSingleLine: true }], 'space-before-function-paren': ['error', 'never'], 'keyword-spacing': 'error', 'space-infix-ops': 'error', 'space-unary-ops': 'error', // Code quality - 'no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }], + 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 'no-console': 'off', 'no-debugger': 'error', 'no-eval': 'error', diff --git a/package.json b/package.json index d26effb..b0f5553 100644 --- a/package.json +++ b/package.json @@ -1,58 +1,30 @@ { - "name": "rsscloud-server", - "version": "3.0.0", - "description": "An rssCloud Server", - "main": "app.js", - "scripts": { - "start": "nodemon --use_strict --ignore data/ ./app.js", - "client": "nodemon --use_strict --ignore data/ ./client.js", - "lint": "eslint --fix controllers/ services/ test/ *.js", - "format": "prettier --write .", - "test": "docker-compose up --build --abort-on-container-exit --attach rsscloud-tests --no-log-prefix", - "prepare": "husky" - }, - "engines": { - "node": ">=22" - }, - "packageManager": "pnpm@10.11.0", - "author": "Andrew Shell ", - "license": "MIT", - "pnpm": { - "overrides": { - "serialize-javascript": ">=7.0.5" + "name": "rsscloud-monorepo", + "version": "0.0.0", + "private": true, + "description": "rssCloud monorepo", + "scripts": { + "start": "pnpm --filter @rsscloud/server run dev", + "client": "pnpm --filter @rsscloud/server run client", + "lint": "pnpm --filter @rsscloud/server run lint", + "format": "prettier --write .", + "test": "docker-compose up --build --abort-on-container-exit --attach rsscloud-tests --no-log-prefix", + "prepare": "husky" + }, + "packageManager": "pnpm@10.11.0", + "pnpm": { + "overrides": { + "serialize-javascript": ">=7.0.5" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/rsscloud/rsscloud-server.git" + }, + "devDependencies": { + "@commitlint/cli": "^20.5.3", + "@commitlint/config-conventional": "^20.5.3", + "husky": "^9.1.7", + "prettier": "^3.8.3" } - }, - "dependencies": { - "body-parser": "^2.2.2", - "cors": "^2.8.6", - "dayjs": "^1.11.20", - "dotenv": "^17.4.2", - "express": "^4.22.2", - "express-handlebars": "^5.3.5", - "markdown-it": "^14.1.1", - "morgan": "^1.10.1", - "ws": "^8.20.1", - "xml2js": "^0.6.2", - "xmlbuilder": "^15.1.1" - }, - "repository": { - "type": "git", - "url": "https://github.com/rsscloud/rsscloud-server.git" - }, - "devDependencies": { - "@commitlint/cli": "^20.5.3", - "@commitlint/config-conventional": "^20.5.3", - "chai": "^4.5.0", - "chai-http": "^4.4.0", - "chai-json": "^1.0.0", - "chai-xml": "^0.4.1", - "eslint": "^10.4.0", - "https": "^1.0.0", - "husky": "^9.1.7", - "mocha": "^11.7.5", - "mocha-multi": "^1.1.7", - "nodemon": "3.1.14", - "prettier": "^3.8.3", - "supertest": "^7.2.2" - } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c80f648..a3a6348 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3085 +1,4223 @@ lockfileVersion: '9.0' settings: - autoInstallPeers: true - excludeLinksFromLockfile: false + autoInstallPeers: true + excludeLinksFromLockfile: false overrides: - serialize-javascript: '>=7.0.5' + serialize-javascript: '>=7.0.5' importers: - - .: - dependencies: - body-parser: - specifier: ^2.2.2 - version: 2.2.2 - cors: - specifier: ^2.8.6 - version: 2.8.6 - dayjs: - specifier: ^1.11.20 - version: 1.11.20 - dotenv: - specifier: ^17.4.2 - version: 17.4.2 - express: - specifier: ^4.22.2 - version: 4.22.2 - express-handlebars: - specifier: ^5.3.5 - version: 5.3.5 - markdown-it: - specifier: ^14.1.1 - version: 14.1.1 - morgan: - specifier: ^1.10.1 - version: 1.10.1 - ws: - specifier: ^8.20.1 - version: 8.20.1 - xml2js: - specifier: ^0.6.2 - version: 0.6.2 - xmlbuilder: - specifier: ^15.1.1 - version: 15.1.1 - devDependencies: - '@commitlint/cli': - specifier: ^20.5.3 - version: 20.5.3(@types/node@25.8.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3) - '@commitlint/config-conventional': - specifier: ^20.5.3 - version: 20.5.3 - chai: - specifier: ^4.5.0 - version: 4.5.0 - chai-http: - specifier: ^4.4.0 - version: 4.4.0 - chai-json: - specifier: ^1.0.0 - version: 1.0.0 - chai-xml: - specifier: ^0.4.1 - version: 0.4.1(chai@4.5.0) - eslint: - specifier: ^10.4.0 - version: 10.4.0(jiti@2.6.1) - https: - specifier: ^1.0.0 - version: 1.0.0 - husky: - specifier: ^9.1.7 - version: 9.1.7 - mocha: - specifier: ^11.7.5 - version: 11.7.5 - mocha-multi: - specifier: ^1.1.7 - version: 1.1.7(mocha@11.7.5) - nodemon: - specifier: 3.1.14 - version: 3.1.14 - prettier: - specifier: ^3.8.3 - version: 3.8.3 - supertest: - specifier: ^7.2.2 - version: 7.2.2 + .: + devDependencies: + '@commitlint/cli': + specifier: ^20.5.3 + version: 20.5.3(@types/node@25.8.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3) + '@commitlint/config-conventional': + specifier: ^20.5.3 + version: 20.5.3 + husky: + specifier: ^9.1.7 + version: 9.1.7 + prettier: + specifier: ^3.8.3 + version: 3.8.3 + + apps/server: + dependencies: + body-parser: + specifier: ^2.2.2 + version: 2.2.2 + cors: + specifier: ^2.8.6 + version: 2.8.6 + dayjs: + specifier: ^1.11.20 + version: 1.11.20 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + express: + specifier: ^4.22.2 + version: 4.22.2 + express-handlebars: + specifier: ^5.3.5 + version: 5.3.5 + markdown-it: + specifier: ^14.1.1 + version: 14.1.1 + morgan: + specifier: ^1.10.1 + version: 1.10.1 + ws: + specifier: ^8.20.1 + version: 8.20.1 + xml2js: + specifier: ^0.6.2 + version: 0.6.2 + xmlbuilder: + specifier: ^15.1.1 + version: 15.1.1 + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.4.0(jiti@2.6.1)) + chai: + specifier: ^4.5.0 + version: 4.5.0 + chai-http: + specifier: ^4.4.0 + version: 4.4.0 + chai-json: + specifier: ^1.0.0 + version: 1.0.0 + chai-xml: + specifier: ^0.4.1 + version: 0.4.1(chai@4.5.0) + eslint: + specifier: ^10.4.0 + version: 10.4.0(jiti@2.6.1) + https: + specifier: ^1.0.0 + version: 1.0.0 + mocha: + specifier: ^11.7.5 + version: 11.7.5 + mocha-multi: + specifier: ^1.1.7 + version: 1.1.7(mocha@11.7.5) + nodemon: + specifier: 3.1.14 + version: 3.1.14 + supertest: + specifier: ^7.2.2 + version: 7.2.2 packages: + '@babel/code-frame@7.29.0': + resolution: + { + integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== + } + engines: { node: '>=6.9.0' } + + '@babel/helper-validator-identifier@7.28.5': + resolution: + { + integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== + } + engines: { node: '>=6.9.0' } + + '@commitlint/cli@20.5.3': + resolution: + { + integrity: sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA== + } + engines: { node: '>=v18' } + hasBin: true + + '@commitlint/config-conventional@20.5.3': + resolution: + { + integrity: sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ== + } + engines: { node: '>=v18' } + + '@commitlint/config-validator@20.5.0': + resolution: + { + integrity: sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw== + } + engines: { node: '>=v18' } + + '@commitlint/ensure@20.5.3': + resolution: + { + integrity: sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw== + } + engines: { node: '>=v18' } + + '@commitlint/execute-rule@20.0.0': + resolution: + { + integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw== + } + engines: { node: '>=v18' } + + '@commitlint/format@20.5.0': + resolution: + { + integrity: sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q== + } + engines: { node: '>=v18' } + + '@commitlint/is-ignored@20.5.0': + resolution: + { + integrity: sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg== + } + engines: { node: '>=v18' } + + '@commitlint/lint@20.5.3': + resolution: + { + integrity: sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw== + } + engines: { node: '>=v18' } + + '@commitlint/load@20.5.3': + resolution: + { + integrity: sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ== + } + engines: { node: '>=v18' } + + '@commitlint/message@20.4.3': + resolution: + { + integrity: sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ== + } + engines: { node: '>=v18' } + + '@commitlint/parse@20.5.0': + resolution: + { + integrity: sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA== + } + engines: { node: '>=v18' } + + '@commitlint/read@20.5.0': + resolution: + { + integrity: sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w== + } + engines: { node: '>=v18' } + + '@commitlint/resolve-extends@20.5.3': + resolution: + { + integrity: sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew== + } + engines: { node: '>=v18' } + + '@commitlint/rules@20.5.3': + resolution: + { + integrity: sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ== + } + engines: { node: '>=v18' } + + '@commitlint/to-lines@20.0.0': + resolution: + { + integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw== + } + engines: { node: '>=v18' } + + '@commitlint/top-level@20.4.3': + resolution: + { + integrity: sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ== + } + engines: { node: '>=v18' } + + '@commitlint/types@20.5.0': + resolution: + { + integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA== + } + engines: { node: '>=v18' } + + '@conventional-changelog/git-client@2.7.0': + resolution: + { + integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw== + } + engines: { node: '>=18' } + peerDependencies: + conventional-commits-filter: ^5.0.0 + conventional-commits-parser: ^6.4.0 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + + '@eslint-community/eslint-utils@4.9.1': + resolution: + { + integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: + { + integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== + } + engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 } + + '@eslint/config-array@0.23.5': + resolution: + { + integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + '@eslint/config-helpers@0.6.0': + resolution: + { + integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + '@eslint/core@1.2.1': + resolution: + { + integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + '@eslint/js@10.0.1': + resolution: + { + integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: + { + integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + '@eslint/plugin-kit@0.7.1': + resolution: + { + integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + '@humanfs/core@0.19.2': + resolution: + { + integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA== + } + engines: { node: '>=18.18.0' } + + '@humanfs/node@0.16.8': + resolution: + { + integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ== + } + engines: { node: '>=18.18.0' } + + '@humanfs/types@0.15.0': + resolution: + { + integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q== + } + engines: { node: '>=18.18.0' } + + '@humanwhocodes/module-importer@1.0.1': + resolution: + { + integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + } + engines: { node: '>=12.22' } + + '@humanwhocodes/retry@0.4.3': + resolution: + { + integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + } + engines: { node: '>=18.18' } + + '@isaacs/cliui@8.0.2': + resolution: + { + integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + } + engines: { node: '>=12' } + + '@noble/hashes@1.8.0': + resolution: + { + integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + } + engines: { node: ^14.21.3 || >=16 } + + '@paralleldrive/cuid2@2.3.1': + resolution: + { + integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw== + } + + '@pkgjs/parseargs@0.11.0': + resolution: + { + integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + } + engines: { node: '>=14' } + + '@simple-libs/child-process-utils@1.0.2': + resolution: + { + integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw== + } + engines: { node: '>=18' } + + '@simple-libs/stream-utils@1.2.0': + resolution: + { + integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA== + } + engines: { node: '>=18' } + + '@types/chai@4.3.20': + resolution: + { + integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ== + } + + '@types/cookiejar@2.1.5': + resolution: + { + integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q== + } + + '@types/esrecurse@4.3.1': + resolution: + { + integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== + } + + '@types/estree@1.0.9': + resolution: + { + integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== + } + + '@types/json-schema@7.0.15': + resolution: + { + integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + } + + '@types/node@25.8.0': + resolution: + { + integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ== + } + + '@types/superagent@4.1.13': + resolution: + { + integrity: sha512-YIGelp3ZyMiH0/A09PMAORO0EBGlF5xIKfDpK74wdYvWUs2o96b5CItJcWPdH409b7SAXIIG6p8NdU/4U2Maww== + } + + accepts@1.3.8: + resolution: + { + integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + } + engines: { node: '>= 0.6' } + + acorn-jsx@5.3.2: + resolution: + { + integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + } + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: + { + integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== + } + engines: { node: '>=0.4.0' } + hasBin: true + + ajv@6.15.0: + resolution: + { + integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw== + } + + ajv@8.20.0: + resolution: + { + integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA== + } + + ansi-regex@5.0.1: + resolution: + { + integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + } + engines: { node: '>=8' } + + ansi-regex@6.2.2: + resolution: + { + integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + } + engines: { node: '>=12' } + + ansi-styles@4.3.0: + resolution: + { + integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + } + engines: { node: '>=8' } + + ansi-styles@6.2.3: + resolution: + { + integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + } + engines: { node: '>=12' } + + anymatch@3.1.3: + resolution: + { + integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + } + engines: { node: '>= 8' } + + argparse@2.0.1: + resolution: + { + integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + } + + array-flatten@1.1.1: + resolution: + { + integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + } + + array-ify@1.0.0: + resolution: + { + integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng== + } + + asap@2.0.6: + resolution: + { + integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + } + + assertion-error@1.1.0: + resolution: + { + integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== + } + + asynckit@0.4.0: + resolution: + { + integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + } + + balanced-match@1.0.2: + resolution: + { + integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + } + + balanced-match@4.0.4: + resolution: + { + integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== + } + engines: { node: 18 || 20 || >=22 } + + basic-auth@2.0.1: + resolution: + { + integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg== + } + engines: { node: '>= 0.8' } + + binary-extensions@2.3.0: + resolution: + { + integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + } + engines: { node: '>=8' } + + body-parser@1.20.5: + resolution: + { + integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA== + } + engines: { node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16 } + + body-parser@2.2.2: + resolution: + { + integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA== + } + engines: { node: '>=18' } + + brace-expansion@1.1.14: + resolution: + { + integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g== + } + + brace-expansion@2.1.0: + resolution: + { + integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w== + } + + brace-expansion@5.0.6: + resolution: + { + integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g== + } + engines: { node: 18 || 20 || >=22 } + + braces@3.0.3: + resolution: + { + integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + } + engines: { node: '>=8' } + + browser-stdout@1.3.1: + resolution: + { + integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + } + + bytes@3.1.2: + resolution: + { + integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + } + engines: { node: '>= 0.8' } + + call-bind-apply-helpers@1.0.2: + resolution: + { + integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + } + engines: { node: '>= 0.4' } + + call-bound@1.0.4: + resolution: + { + integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + } + engines: { node: '>= 0.4' } + + callsites@3.1.0: + resolution: + { + integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + } + engines: { node: '>=6' } + + camelcase@6.3.0: + resolution: + { + integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + } + engines: { node: '>=10' } + + chai-http@4.4.0: + resolution: + { + integrity: sha512-uswN3rZpawlRaa5NiDUHcDZ3v2dw5QgLyAwnQ2tnVNuP7CwIsOFuYJ0xR1WiR7ymD4roBnJIzOUep7w9jQMFJA== + } + engines: { node: '>=10' } + + chai-json@1.0.0: + resolution: + { + integrity: sha512-p9eEYu3H2BkpU8PW8kqt0N6Ni6UG3LCCjlqHtZbrgvGRJYD4UnJuh704EFbGRxylzEPWsaOGhgXfdn0n1W3hhA== + } + + chai-xml@0.4.1: + resolution: + { + integrity: sha512-VUf5Ol4ifOAsgz+lN4tfWENgQtrKxHPWsmpL5wdbqQdkpblZkcDlaT2aFvsPQH219Yvl8vc4064yFErgBIn9bw== + } + engines: { node: '>= 0.8.0' } + peerDependencies: + chai: '>=1.10.0 ' + + chai@3.5.0: + resolution: + { + integrity: sha512-eRYY0vPS2a9zt5w5Z0aCeWbrXTEyvk7u/Xf71EzNObrjSCPgMm1Nku/D/u2tiqHBX5j40wWhj54YJLtgn8g55A== + } + engines: { node: '>= 0.4.0' } + + chai@4.5.0: + resolution: + { + integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw== + } + engines: { node: '>=4' } + + chalk@4.1.2: + resolution: + { + integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + } + engines: { node: '>=10' } + + charset@1.0.1: + resolution: + { + integrity: sha512-6dVyOOYjpfFcL1Y4qChrAoQLRHvj2ziyhcm0QJlhOcAhykL/k1kTUPbeo+87MNRTRdk2OIIsIXbuF3x2wi5EXg== + } + engines: { node: '>=4.0.0' } + + check-error@1.0.3: + resolution: + { + integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg== + } + + chokidar@3.6.0: + resolution: + { + integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + } + engines: { node: '>= 8.10.0' } + + chokidar@4.0.3: + resolution: + { + integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + } + engines: { node: '>= 14.16.0' } + + cliui@8.0.1: + resolution: + { + integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + } + engines: { node: '>=12' } + + color-convert@2.0.1: + resolution: + { + integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + } + engines: { node: '>=7.0.0' } + + color-name@1.1.4: + resolution: + { + integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + } + + combined-stream@1.0.8: + resolution: + { + integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + } + engines: { node: '>= 0.8' } + + compare-func@2.0.0: + resolution: + { + integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA== + } + + component-emitter@1.3.1: + resolution: + { + integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ== + } + + concat-map@0.0.1: + resolution: + { + integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + } + + content-disposition@0.5.4: + resolution: + { + integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + } + engines: { node: '>= 0.6' } + + content-type@1.0.5: + resolution: + { + integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + } + engines: { node: '>= 0.6' } + + content-type@2.0.0: + resolution: + { + integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ== + } + engines: { node: '>=18' } + + conventional-changelog-angular@8.3.1: + resolution: + { + integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg== + } + engines: { node: '>=18' } + + conventional-changelog-conventionalcommits@9.3.1: + resolution: + { + integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw== + } + engines: { node: '>=18' } + + conventional-commits-parser@6.4.0: + resolution: + { + integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw== + } + engines: { node: '>=18' } + hasBin: true + + cookie-signature@1.0.7: + resolution: + { + integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA== + } + + cookie-signature@1.2.2: + resolution: + { + integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== + } + engines: { node: '>=6.6.0' } + + cookie@0.7.2: + resolution: + { + integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + } + engines: { node: '>= 0.6' } + + cookiejar@2.1.4: + resolution: + { + integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== + } + + cors@2.8.6: + resolution: + { + integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw== + } + engines: { node: '>= 0.10' } + + cosmiconfig-typescript-loader@6.3.0: + resolution: + { + integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA== + } + engines: { node: '>=v18' } + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + + cosmiconfig@9.0.1: + resolution: + { + integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ== + } + engines: { node: '>=14' } + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@7.0.6: + resolution: + { + integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + } + engines: { node: '>= 8' } + + dayjs@1.11.20: + resolution: + { + integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ== + } + + debug@2.6.9: + resolution: + { + integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + } + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: + { + integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + } + engines: { node: '>=6.0' } + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@4.0.0: + resolution: + { + integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== + } + engines: { node: '>=10' } + + deep-eql@0.1.3: + resolution: + { + integrity: sha512-6sEotTRGBFiNcqVoeHwnfopbSpi5NbH1VWJmYCVkmxMmaVTT0bUTrNaGyBwhgP4MZL012W/mkzIn3Da+iDYweg== + } + + deep-eql@4.1.4: + resolution: + { + integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg== + } + engines: { node: '>=6' } + + deep-is@0.1.4: + resolution: + { + integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + } + + delayed-stream@1.0.0: + resolution: + { + integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + } + engines: { node: '>=0.4.0' } + + depd@2.0.0: + resolution: + { + integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + } + engines: { node: '>= 0.8' } + + destroy@1.2.0: + resolution: + { + integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + } + engines: { node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16 } + + dezalgo@1.0.4: + resolution: + { + integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig== + } + + diff@7.0.0: + resolution: + { + integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw== + } + engines: { node: '>=0.3.1' } + + dot-prop@5.3.0: + resolution: + { + integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q== + } + engines: { node: '>=8' } + + dotenv@17.4.2: + resolution: + { + integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw== + } + engines: { node: '>=12' } + + dunder-proto@1.0.1: + resolution: + { + integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + } + engines: { node: '>= 0.4' } + + eastasianwidth@0.2.0: + resolution: + { + integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + } + + ee-first@1.1.1: + resolution: + { + integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + } + + emoji-regex@8.0.0: + resolution: + { + integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + } + + emoji-regex@9.2.2: + resolution: + { + integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + } + + encodeurl@2.0.0: + resolution: + { + integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + } + engines: { node: '>= 0.8' } + + entities@4.5.0: + resolution: + { + integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + } + engines: { node: '>=0.12' } + + env-paths@2.2.1: + resolution: + { + integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== + } + engines: { node: '>=6' } + + error-ex@1.3.4: + resolution: + { + integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ== + } + + es-define-property@1.0.1: + resolution: + { + integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + } + engines: { node: '>= 0.4' } + + es-errors@1.3.0: + resolution: + { + integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + } + engines: { node: '>= 0.4' } + + es-object-atoms@1.1.1: + resolution: + { + integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + } + engines: { node: '>= 0.4' } + + es-set-tostringtag@2.1.0: + resolution: + { + integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + } + engines: { node: '>= 0.4' } + + es-toolkit@1.46.1: + resolution: + { + integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ== + } + + escalade@3.2.0: + resolution: + { + integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + } + engines: { node: '>=6' } + + escape-html@1.0.3: + resolution: + { + integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + } + + escape-string-regexp@4.0.0: + resolution: + { + integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + } + engines: { node: '>=10' } + + eslint-scope@9.1.2: + resolution: + { + integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + eslint-visitor-keys@3.4.3: + resolution: + { + integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + + eslint-visitor-keys@5.0.1: + resolution: + { + integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + eslint@10.4.0: + resolution: + { + integrity: sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: + { + integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw== + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + esquery@1.7.0: + resolution: + { + integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== + } + engines: { node: '>=0.10' } + + esrecurse@4.3.0: + resolution: + { + integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + } + engines: { node: '>=4.0' } + + estraverse@5.3.0: + resolution: + { + integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + } + engines: { node: '>=4.0' } + + esutils@2.0.3: + resolution: + { + integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + } + engines: { node: '>=0.10.0' } + + etag@1.8.1: + resolution: + { + integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + } + engines: { node: '>= 0.6' } + + express-handlebars@5.3.5: + resolution: + { + integrity: sha512-r9pzDc94ZNJ7FVvtsxLfPybmN0eFAUnR61oimNPRpD0D7nkLcezrkpZzoXS5TI75wYHRbflPLTU39B62pwB4DA== + } + engines: { node: '>=v10.24.1' } + + express@4.22.2: + resolution: + { + integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q== + } + engines: { node: '>= 0.10.0' } + + fast-deep-equal@3.1.3: + resolution: + { + integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + } + + fast-json-stable-stringify@2.1.0: + resolution: + { + integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + } + + fast-levenshtein@2.0.6: + resolution: + { + integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + } + + fast-safe-stringify@2.1.1: + resolution: + { + integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== + } + + fast-uri@3.1.2: + resolution: + { + integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ== + } + + file-entry-cache@8.0.0: + resolution: + { + integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + } + engines: { node: '>=16.0.0' } + + fill-range@7.1.1: + resolution: + { + integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + } + engines: { node: '>=8' } + + finalhandler@1.3.2: + resolution: + { + integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg== + } + engines: { node: '>= 0.8' } + + find-up@5.0.0: + resolution: + { + integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + } + engines: { node: '>=10' } + + flat-cache@4.0.1: + resolution: + { + integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + } + engines: { node: '>=16' } + + flat@5.0.2: + resolution: + { + integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== + } + hasBin: true + + flatted@3.4.2: + resolution: + { + integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== + } + + foreground-child@3.3.1: + resolution: + { + integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + } + engines: { node: '>=14' } + + form-data@4.0.5: + resolution: + { + integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== + } + engines: { node: '>= 6' } + + formidable@2.1.5: + resolution: + { + integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q== + } + + formidable@3.5.4: + resolution: + { + integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug== + } + engines: { node: '>=14.0.0' } + + forwarded@0.2.0: + resolution: + { + integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + } + engines: { node: '>= 0.6' } + + fresh@0.5.2: + resolution: + { + integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + } + engines: { node: '>= 0.6' } + + fs.realpath@1.0.0: + resolution: + { + integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + } + + fsevents@2.3.3: + resolution: + { + integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + } + engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } + os: [darwin] + + function-bind@1.1.2: + resolution: + { + integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + } + + get-caller-file@2.0.5: + resolution: + { + integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + } + engines: { node: 6.* || 8.* || >= 10.* } + + get-func-name@2.0.2: + resolution: + { + integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ== + } + + get-intrinsic@1.3.0: + resolution: + { + integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + } + engines: { node: '>= 0.4' } + + get-proto@1.0.1: + resolution: + { + integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + } + engines: { node: '>= 0.4' } + + git-raw-commits@5.0.1: + resolution: + { + integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ== + } + engines: { node: '>=18' } + hasBin: true + + glob-parent@5.1.2: + resolution: + { + integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + } + engines: { node: '>= 6' } + + glob-parent@6.0.2: + resolution: + { + integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + } + engines: { node: '>=10.13.0' } + + glob@10.5.0: + resolution: + { + integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== + } + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@7.2.3: + resolution: + { + integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + } + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + global-directory@5.0.0: + resolution: + { + integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w== + } + engines: { node: '>=20' } + + gopd@1.2.0: + resolution: + { + integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + } + engines: { node: '>= 0.4' } + + graceful-fs@4.2.11: + resolution: + { + integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + } + + handlebars@4.7.9: + resolution: + { + integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ== + } + engines: { node: '>=0.4.7' } + hasBin: true + + has-flag@3.0.0: + resolution: + { + integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + } + engines: { node: '>=4' } + + has-flag@4.0.0: + resolution: + { + integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + } + engines: { node: '>=8' } + + has-symbols@1.1.0: + resolution: + { + integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + } + engines: { node: '>= 0.4' } + + has-tostringtag@1.0.2: + resolution: + { + integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + } + engines: { node: '>= 0.4' } + + hasown@2.0.3: + resolution: + { + integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg== + } + engines: { node: '>= 0.4' } + + he@1.2.0: + resolution: + { + integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + } + hasBin: true + + http-errors@2.0.1: + resolution: + { + integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== + } + engines: { node: '>= 0.8' } + + https@1.0.0: + resolution: + { + integrity: sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg== + } + + husky@9.1.7: + resolution: + { + integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA== + } + engines: { node: '>=18' } + hasBin: true + + iconv-lite@0.4.24: + resolution: + { + integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + } + engines: { node: '>=0.10.0' } + + iconv-lite@0.7.2: + resolution: + { + integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw== + } + engines: { node: '>=0.10.0' } + + ignore-by-default@1.0.1: + resolution: + { + integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA== + } + + ignore@5.3.2: + resolution: + { + integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + } + engines: { node: '>= 4' } + + import-fresh@3.3.1: + resolution: + { + integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== + } + engines: { node: '>=6' } + + import-meta-resolve@4.2.0: + resolution: + { + integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg== + } + + imurmurhash@0.1.4: + resolution: + { + integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + } + engines: { node: '>=0.8.19' } + + inflight@1.0.6: + resolution: + { + integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + } + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: + { + integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + } + + ini@6.0.0: + resolution: + { + integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ== + } + engines: { node: ^20.17.0 || >=22.9.0 } + + ip-regex@2.1.0: + resolution: + { + integrity: sha512-58yWmlHpp7VYfcdTwMTvwMmqx/Elfxjd9RXTDyMsbL7lLWmhMylLEqiYVLKuLzOZqVgiWXD9MfR62Vv89VRxkw== + } + engines: { node: '>=4' } + + ipaddr.js@1.9.1: + resolution: + { + integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + } + engines: { node: '>= 0.10' } + + is-arrayish@0.2.1: + resolution: + { + integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== + } + + is-binary-path@2.1.0: + resolution: + { + integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + } + engines: { node: '>=8' } + + is-extglob@2.1.1: + resolution: + { + integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + } + engines: { node: '>=0.10.0' } + + is-fullwidth-code-point@3.0.0: + resolution: + { + integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + } + engines: { node: '>=8' } + + is-glob@4.0.3: + resolution: + { + integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + } + engines: { node: '>=0.10.0' } + + is-ip@2.0.0: + resolution: + { + integrity: sha512-9MTn0dteHETtyUx8pxqMwg5hMBi3pvlyglJ+b79KOCca0po23337LbVV2Hl4xmMvfw++ljnO0/+5G6G+0Szh6g== + } + engines: { node: '>=4' } + + is-number@7.0.0: + resolution: + { + integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + } + engines: { node: '>=0.12.0' } + + is-obj@2.0.0: + resolution: + { + integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== + } + engines: { node: '>=8' } + + is-path-inside@3.0.3: + resolution: + { + integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + } + engines: { node: '>=8' } + + is-plain-obj@2.1.0: + resolution: + { + integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + } + engines: { node: '>=8' } + + is-plain-obj@4.1.0: + resolution: + { + integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== + } + engines: { node: '>=12' } + + is-string@1.1.1: + resolution: + { + integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== + } + engines: { node: '>= 0.4' } + + is-unicode-supported@0.1.0: + resolution: + { + integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + } + engines: { node: '>=10' } + + isexe@2.0.0: + resolution: + { + integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + } + + jackspeak@3.4.3: + resolution: + { + integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + } + + jiti@2.6.1: + resolution: + { + integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== + } + hasBin: true + + js-tokens@4.0.0: + resolution: + { + integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + } + + js-yaml@4.1.1: + resolution: + { + integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== + } + hasBin: true + + json-buffer@3.0.1: + resolution: + { + integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + } + + json-parse-even-better-errors@2.3.1: + resolution: + { + integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + } + + json-schema-traverse@0.4.1: + resolution: + { + integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + } + + json-schema-traverse@1.0.0: + resolution: + { + integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + } + + json-stable-stringify-without-jsonify@1.0.1: + resolution: + { + integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + } + + jsonfile@3.0.1: + resolution: + { + integrity: sha512-oBko6ZHlubVB5mRFkur5vgYR1UyqX+S6Y/oCfLhqNdcc2fYFlDpIoNc7AfKS1KOGcnNAkvsr0grLck9ANM815w== + } + + keyv@4.5.4: + resolution: + { + integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + } + + levn@0.4.1: + resolution: + { + integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + } + engines: { node: '>= 0.8.0' } + + lines-and-columns@1.2.4: + resolution: + { + integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + } + + linkify-it@5.0.0: + resolution: + { + integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ== + } + + locate-path@6.0.0: + resolution: + { + integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + } + engines: { node: '>=10' } + + lodash.once@4.1.1: + resolution: + { + integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + } + + log-symbols@4.1.0: + resolution: + { + integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + } + engines: { node: '>=10' } + + loupe@2.3.7: + resolution: + { + integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA== + } + + lru-cache@10.4.3: + resolution: + { + integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + } + + markdown-it@14.1.1: + resolution: + { + integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA== + } + hasBin: true + + math-intrinsics@1.1.0: + resolution: + { + integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + } + engines: { node: '>= 0.4' } + + mdurl@2.0.0: + resolution: + { + integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w== + } + + media-typer@0.3.0: + resolution: + { + integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + } + engines: { node: '>= 0.6' } + + media-typer@1.1.0: + resolution: + { + integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== + } + engines: { node: '>= 0.8' } + + meow@13.2.0: + resolution: + { + integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA== + } + engines: { node: '>=18' } + + merge-descriptors@1.0.3: + resolution: + { + integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== + } + + methods@1.1.2: + resolution: + { + integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + } + engines: { node: '>= 0.6' } + + mime-db@1.52.0: + resolution: + { + integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + } + engines: { node: '>= 0.6' } + + mime-db@1.54.0: + resolution: + { + integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== + } + engines: { node: '>= 0.6' } + + mime-types@2.1.35: + resolution: + { + integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + } + engines: { node: '>= 0.6' } + + mime-types@3.0.2: + resolution: + { + integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== + } + engines: { node: '>=18' } + + mime@1.6.0: + resolution: + { + integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + } + engines: { node: '>=4' } + hasBin: true + + mime@2.6.0: + resolution: + { + integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + } + engines: { node: '>=4.0.0' } + hasBin: true + + minimatch@10.2.5: + resolution: + { + integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== + } + engines: { node: 18 || 20 || >=22 } + + minimatch@3.1.5: + resolution: + { + integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== + } + + minimatch@9.0.9: + resolution: + { + integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== + } + engines: { node: '>=16 || 14 >=14.17' } + + minimist@1.2.8: + resolution: + { + integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + } + + minipass@7.1.3: + resolution: + { + integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== + } + engines: { node: '>=16 || 14 >=14.17' } + + mkdirp@1.0.4: + resolution: + { + integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + } + engines: { node: '>=10' } + hasBin: true + + mocha-multi@1.1.7: + resolution: + { + integrity: sha512-SXZRgHy0XiRTASyOp0p6fjOkdj+R62L6cqutnYyQOvIjNznJuUwzykxctypeRiOwPd+gfn4yt3NRulMQyI8Tzg== + } + engines: { node: '>=6.0.0' } + peerDependencies: + mocha: '>=2.2.0 <7 || >=9' + + mocha@11.7.5: + resolution: + { + integrity: sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig== + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + hasBin: true + + morgan@1.10.1: + resolution: + { + integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A== + } + engines: { node: '>= 0.8.0' } + + ms@2.0.0: + resolution: + { + integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + } + + ms@2.1.3: + resolution: + { + integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + } + + natural-compare@1.4.0: + resolution: + { + integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + } + + negotiator@0.6.3: + resolution: + { + integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + } + engines: { node: '>= 0.6' } + + neo-async@2.6.2: + resolution: + { + integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + } + + nodemon@3.1.14: + resolution: + { + integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw== + } + engines: { node: '>=10' } + hasBin: true + + normalize-path@3.0.0: + resolution: + { + integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + } + engines: { node: '>=0.10.0' } + + object-assign@4.1.1: + resolution: + { + integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + } + engines: { node: '>=0.10.0' } + + object-inspect@1.13.4: + resolution: + { + integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + } + engines: { node: '>= 0.4' } + + on-finished@2.3.0: + resolution: + { + integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww== + } + engines: { node: '>= 0.8' } + + on-finished@2.4.1: + resolution: + { + integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + } + engines: { node: '>= 0.8' } + + on-headers@1.1.0: + resolution: + { + integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A== + } + engines: { node: '>= 0.8' } + + once@1.4.0: + resolution: + { + integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + } + + optionator@0.9.4: + resolution: + { + integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + } + engines: { node: '>= 0.8.0' } + + p-limit@3.1.0: + resolution: + { + integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + } + engines: { node: '>=10' } + + p-locate@5.0.0: + resolution: + { + integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + } + engines: { node: '>=10' } + + package-json-from-dist@1.0.1: + resolution: + { + integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + } + + parent-module@1.0.1: + resolution: + { + integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + } + engines: { node: '>=6' } + + parse-json@5.2.0: + resolution: + { + integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + } + engines: { node: '>=8' } + + parseurl@1.3.3: + resolution: + { + integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + } + engines: { node: '>= 0.8' } + + path-exists@4.0.0: + resolution: + { + integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + } + engines: { node: '>=8' } + + path-is-absolute@1.0.1: + resolution: + { + integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + } + engines: { node: '>=0.10.0' } + + path-key@3.1.1: + resolution: + { + integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + } + engines: { node: '>=8' } + + path-scurry@1.11.1: + resolution: + { + integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + } + engines: { node: '>=16 || 14 >=14.18' } + + path-to-regexp@0.1.13: + resolution: + { + integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA== + } + + pathval@1.1.1: + resolution: + { + integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== + } + + picocolors@1.1.1: + resolution: + { + integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + } + + picomatch@2.3.2: + resolution: + { + integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== + } + engines: { node: '>=8.6' } + + prelude-ls@1.2.1: + resolution: + { + integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + } + engines: { node: '>= 0.8.0' } + + prettier@3.8.3: + resolution: + { + integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw== + } + engines: { node: '>=14' } + hasBin: true + + proxy-addr@2.0.7: + resolution: + { + integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + } + engines: { node: '>= 0.10' } + + pstree.remy@1.1.8: + resolution: + { + integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== + } + + punycode.js@2.3.1: + resolution: + { + integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA== + } + engines: { node: '>=6' } + + punycode@2.3.1: + resolution: + { + integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + } + engines: { node: '>=6' } + + qs@6.15.1: + resolution: + { + integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg== + } + engines: { node: '>=0.6' } + + range-parser@1.2.1: + resolution: + { + integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + } + engines: { node: '>= 0.6' } + + raw-body@2.5.3: + resolution: + { + integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA== + } + engines: { node: '>= 0.8' } + + raw-body@3.0.2: + resolution: + { + integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== + } + engines: { node: '>= 0.10' } + + readdirp@3.6.0: + resolution: + { + integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + } + engines: { node: '>=8.10.0' } + + readdirp@4.1.2: + resolution: + { + integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== + } + engines: { node: '>= 14.18.0' } + + require-directory@2.1.1: + resolution: + { + integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + } + engines: { node: '>=0.10.0' } + + require-from-string@2.0.2: + resolution: + { + integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + } + engines: { node: '>=0.10.0' } + + resolve-from@4.0.0: + resolution: + { + integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + } + engines: { node: '>=4' } + + resolve-from@5.0.0: + resolution: + { + integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + } + engines: { node: '>=8' } + + safe-buffer@5.1.2: + resolution: + { + integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + } + + safe-buffer@5.2.1: + resolution: + { + integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + } + + safer-buffer@2.1.2: + resolution: + { + integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + } + + sax@1.6.0: + resolution: + { + integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA== + } + engines: { node: '>=11.0.0' } + + semver@7.8.0: + resolution: + { + integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA== + } + engines: { node: '>=10' } + hasBin: true + + send@0.19.2: + resolution: + { + integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg== + } + engines: { node: '>= 0.8.0' } + + serialize-javascript@7.0.5: + resolution: + { + integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw== + } + engines: { node: '>=20.0.0' } + + serve-static@1.16.3: + resolution: + { + integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA== + } + engines: { node: '>= 0.8.0' } + + setprototypeof@1.2.0: + resolution: + { + integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + } + + shebang-command@2.0.0: + resolution: + { + integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + } + engines: { node: '>=8' } + + shebang-regex@3.0.0: + resolution: + { + integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + } + engines: { node: '>=8' } + + side-channel-list@1.0.1: + resolution: + { + integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== + } + engines: { node: '>= 0.4' } + + side-channel-map@1.0.1: + resolution: + { + integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + } + engines: { node: '>= 0.4' } + + side-channel-weakmap@1.0.2: + resolution: + { + integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + } + engines: { node: '>= 0.4' } + + side-channel@1.1.0: + resolution: + { + integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + } + engines: { node: '>= 0.4' } + + signal-exit@4.1.0: + resolution: + { + integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + } + engines: { node: '>=14' } + + simple-update-notifier@2.0.0: + resolution: + { + integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w== + } + engines: { node: '>=10' } + + source-map@0.6.1: + resolution: + { + integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + } + engines: { node: '>=0.10.0' } + + statuses@2.0.2: + resolution: + { + integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== + } + engines: { node: '>= 0.8' } + + string-width@4.2.3: + resolution: + { + integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + } + engines: { node: '>=8' } + + string-width@5.1.2: + resolution: + { + integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + } + engines: { node: '>=12' } + + strip-ansi@6.0.1: + resolution: + { + integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + } + engines: { node: '>=8' } + + strip-ansi@7.2.0: + resolution: + { + integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== + } + engines: { node: '>=12' } + + strip-json-comments@3.1.1: + resolution: + { + integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + } + engines: { node: '>=8' } + + superagent@10.3.0: + resolution: + { + integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ== + } + engines: { node: '>=14.18.0' } + + superagent@8.1.2: + resolution: + { + integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA== + } + engines: { node: '>=6.4.0 <13 || >=14' } + deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net + + supertest@7.2.2: + resolution: + { + integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA== + } + engines: { node: '>=14.18.0' } + + supports-color@5.5.0: + resolution: + { + integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + } + engines: { node: '>=4' } + + supports-color@7.2.0: + resolution: + { + integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + } + engines: { node: '>=8' } + + supports-color@8.1.1: + resolution: + { + integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + } + engines: { node: '>=10' } + + tinyexec@1.1.2: + resolution: + { + integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA== + } + engines: { node: '>=18' } + + to-regex-range@5.0.1: + resolution: + { + integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + } + engines: { node: '>=8.0' } + + toidentifier@1.0.1: + resolution: + { + integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + } + engines: { node: '>=0.6' } + + touch@3.1.1: + resolution: + { + integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA== + } + hasBin: true + + type-check@0.4.0: + resolution: + { + integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + } + engines: { node: '>= 0.8.0' } + + type-detect@0.1.1: + resolution: + { + integrity: sha512-5rqszGVwYgBoDkIm2oUtvkfZMQ0vk29iDMU0W2qCa3rG0vPDNczCMT4hV/bLBgLg8k8ri6+u3Zbt+S/14eMzlA== + } + + type-detect@1.0.0: + resolution: + { + integrity: sha512-f9Uv6ezcpvCQjJU0Zqbg+65qdcszv3qUQsZfjdRbWiZ7AMenrX1u0lNk9EoWWX6e1F+NULyg27mtdeZ5WhpljA== + } + + type-detect@4.1.0: + resolution: + { + integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw== + } + engines: { node: '>=4' } + + type-is@1.6.18: + resolution: + { + integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + } + engines: { node: '>= 0.6' } + + type-is@2.1.0: + resolution: + { + integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA== + } + engines: { node: '>= 18' } + + typescript@5.9.3: + resolution: + { + integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + } + engines: { node: '>=14.17' } + hasBin: true + + uc.micro@2.1.0: + resolution: + { + integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A== + } + + uglify-js@3.19.3: + resolution: + { + integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== + } + engines: { node: '>=0.8.0' } + hasBin: true + + undefsafe@2.0.5: + resolution: + { + integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== + } + + underscore@1.13.8: + resolution: + { + integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ== + } + + undici-types@7.24.6: + resolution: + { + integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg== + } + + unpipe@1.0.0: + resolution: + { + integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + } + engines: { node: '>= 0.8' } + + uri-js@4.4.1: + resolution: + { + integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + } + + utils-merge@1.0.1: + resolution: + { + integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + } + engines: { node: '>= 0.4.0' } + + vary@1.1.2: + resolution: + { + integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + } + engines: { node: '>= 0.8' } + + which@2.0.2: + resolution: + { + integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + } + engines: { node: '>= 8' } + hasBin: true + + word-wrap@1.2.5: + resolution: + { + integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + } + engines: { node: '>=0.10.0' } + + wordwrap@1.0.0: + resolution: + { + integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== + } + + workerpool@9.3.4: + resolution: + { + integrity: sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg== + } + + wrap-ansi@7.0.0: + resolution: + { + integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + } + engines: { node: '>=10' } + + wrap-ansi@8.1.0: + resolution: + { + integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + } + engines: { node: '>=12' } + + wrappy@1.0.2: + resolution: + { + integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + } + + ws@8.20.1: + resolution: + { + integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w== + } + engines: { node: '>=10.0.0' } + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml2js@0.5.0: + resolution: + { + integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA== + } + engines: { node: '>=4.0.0' } + + xml2js@0.6.2: + resolution: + { + integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA== + } + engines: { node: '>=4.0.0' } + + xmlbuilder@11.0.1: + resolution: + { + integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== + } + engines: { node: '>=4.0' } + + xmlbuilder@15.1.1: + resolution: + { + integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg== + } + engines: { node: '>=8.0' } + + y18n@5.0.8: + resolution: + { + integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + } + engines: { node: '>=10' } + + yargs-parser@21.1.1: + resolution: + { + integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + } + engines: { node: '>=12' } + + yargs-unparser@2.0.0: + resolution: + { + integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== + } + engines: { node: '>=10' } + + yargs@17.7.2: + resolution: + { + integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + } + engines: { node: '>=12' } + + yocto-queue@0.1.0: + resolution: + { + integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + } + engines: { node: '>=10' } - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@commitlint/cli@20.5.3': - resolution: {integrity: sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==} - engines: {node: '>=v18'} - hasBin: true - - '@commitlint/config-conventional@20.5.3': - resolution: {integrity: sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ==} - engines: {node: '>=v18'} - - '@commitlint/config-validator@20.5.0': - resolution: {integrity: sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==} - engines: {node: '>=v18'} - - '@commitlint/ensure@20.5.3': - resolution: {integrity: sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw==} - engines: {node: '>=v18'} - - '@commitlint/execute-rule@20.0.0': - resolution: {integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==} - engines: {node: '>=v18'} - - '@commitlint/format@20.5.0': - resolution: {integrity: sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==} - engines: {node: '>=v18'} - - '@commitlint/is-ignored@20.5.0': - resolution: {integrity: sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==} - engines: {node: '>=v18'} - - '@commitlint/lint@20.5.3': - resolution: {integrity: sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw==} - engines: {node: '>=v18'} - - '@commitlint/load@20.5.3': - resolution: {integrity: sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ==} - engines: {node: '>=v18'} - - '@commitlint/message@20.4.3': - resolution: {integrity: sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==} - engines: {node: '>=v18'} - - '@commitlint/parse@20.5.0': - resolution: {integrity: sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==} - engines: {node: '>=v18'} - - '@commitlint/read@20.5.0': - resolution: {integrity: sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==} - engines: {node: '>=v18'} - - '@commitlint/resolve-extends@20.5.3': - resolution: {integrity: sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew==} - engines: {node: '>=v18'} - - '@commitlint/rules@20.5.3': - resolution: {integrity: sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ==} - engines: {node: '>=v18'} - - '@commitlint/to-lines@20.0.0': - resolution: {integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==} - engines: {node: '>=v18'} - - '@commitlint/top-level@20.4.3': - resolution: {integrity: sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==} - engines: {node: '>=v18'} - - '@commitlint/types@20.5.0': - resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} - engines: {node: '>=v18'} - - '@conventional-changelog/git-client@2.7.0': - resolution: {integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==} - engines: {node: '>=18'} - peerDependencies: - conventional-commits-filter: ^5.0.0 - conventional-commits-parser: ^6.4.0 - peerDependenciesMeta: - conventional-commits-filter: - optional: true - conventional-commits-parser: +snapshots: + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.28.5': {} + + '@commitlint/cli@20.5.3(@types/node@25.8.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': + dependencies: + '@commitlint/format': 20.5.0 + '@commitlint/lint': 20.5.3 + '@commitlint/load': 20.5.3(@types/node@25.8.0)(typescript@5.9.3) + '@commitlint/read': 20.5.0(conventional-commits-parser@6.4.0) + '@commitlint/types': 20.5.0 + tinyexec: 1.1.2 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - conventional-commits-filter + - conventional-commits-parser + - typescript + + '@commitlint/config-conventional@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-conventionalcommits: 9.3.1 + + '@commitlint/config-validator@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + ajv: 8.20.0 + + '@commitlint/ensure@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + es-toolkit: 1.46.1 + + '@commitlint/execute-rule@20.0.0': {} + + '@commitlint/format@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + picocolors: 1.1.1 + + '@commitlint/is-ignored@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + semver: 7.8.0 + + '@commitlint/lint@20.5.3': + dependencies: + '@commitlint/is-ignored': 20.5.0 + '@commitlint/parse': 20.5.0 + '@commitlint/rules': 20.5.3 + '@commitlint/types': 20.5.0 + + '@commitlint/load@20.5.3(@types/node@25.8.0)(typescript@5.9.3)': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/execute-rule': 20.0.0 + '@commitlint/resolve-extends': 20.5.3 + '@commitlint/types': 20.5.0 + cosmiconfig: 9.0.1(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@25.8.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) + es-toolkit: 1.46.1 + is-plain-obj: 4.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@20.4.3': {} + + '@commitlint/parse@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-angular: 8.3.1 + conventional-commits-parser: 6.4.0 + + '@commitlint/read@20.5.0(conventional-commits-parser@6.4.0)': + dependencies: + '@commitlint/top-level': 20.4.3 + '@commitlint/types': 20.5.0 + git-raw-commits: 5.0.1(conventional-commits-parser@6.4.0) + minimist: 1.2.8 + tinyexec: 1.1.2 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + '@commitlint/resolve-extends@20.5.3': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/types': 20.5.0 + es-toolkit: 1.46.1 + global-directory: 5.0.0 + import-meta-resolve: 4.2.0 + resolve-from: 5.0.0 + + '@commitlint/rules@20.5.3': + dependencies: + '@commitlint/ensure': 20.5.3 + '@commitlint/message': 20.4.3 + '@commitlint/to-lines': 20.0.0 + '@commitlint/types': 20.5.0 + + '@commitlint/to-lines@20.0.0': {} + + '@commitlint/top-level@20.4.3': + dependencies: + escalade: 3.2.0 + + '@commitlint/types@20.5.0': + dependencies: + conventional-commits-parser: 6.4.0 + picocolors: 1.1.1 + + '@conventional-changelog/git-client@2.7.0(conventional-commits-parser@6.4.0)': + dependencies: + '@simple-libs/child-process-utils': 1.0.2 + '@simple-libs/stream-utils': 1.2.0 + semver: 7.8.0 + optionalDependencies: + conventional-commits-parser: 6.4.0 + + '@eslint-community/eslint-utils@4.9.1(eslint@10.4.0(jiti@2.6.1))': + dependencies: + eslint: 10.4.0(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.4.0(jiti@2.6.1))': + optionalDependencies: + eslint: 10.4.0(jiti@2.6.1) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@noble/hashes@1.8.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@pkgjs/parseargs@0.11.0': optional: true - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.23.5': - resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/config-helpers@0.6.0': - resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/core@1.2.1': - resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/object-schema@3.0.5': - resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/plugin-kit@0.7.1': - resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@humanfs/core@0.19.2': - resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.8': - resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} - engines: {node: '>=18.18.0'} - - '@humanfs/types@0.15.0': - resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - - '@paralleldrive/cuid2@2.3.1': - resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} - - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - - '@simple-libs/child-process-utils@1.0.2': - resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} - engines: {node: '>=18'} - - '@simple-libs/stream-utils@1.2.0': - resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} - engines: {node: '>=18'} - - '@types/chai@4.3.20': - resolution: {integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==} - - '@types/cookiejar@2.1.5': - resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} - - '@types/esrecurse@4.3.1': - resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} - - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/node@25.8.0': - resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==} - - '@types/superagent@4.1.13': - resolution: {integrity: sha512-YIGelp3ZyMiH0/A09PMAORO0EBGlF5xIKfDpK74wdYvWUs2o96b5CItJcWPdH409b7SAXIIG6p8NdU/4U2Maww==} - - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv@6.15.0: - resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - - ajv@8.20.0: - resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - - array-ify@1.0.0: - resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} - - asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - - assertion-error@1.1.0: - resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - basic-auth@2.0.1: - resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} - engines: {node: '>= 0.8'} - - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} + '@simple-libs/child-process-utils@1.0.2': + dependencies: + '@simple-libs/stream-utils': 1.2.0 - body-parser@1.20.5: - resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + '@simple-libs/stream-utils@1.2.0': {} - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} - engines: {node: '>=18'} + '@types/chai@4.3.20': {} - brace-expansion@1.1.14: - resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + '@types/cookiejar@2.1.5': {} - brace-expansion@2.1.0: - resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + '@types/esrecurse@4.3.1': {} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + '@types/estree@1.0.9': {} - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} + '@types/json-schema@7.0.15': {} - browser-stdout@1.3.1: - resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} + '@types/node@25.8.0': + dependencies: + undici-types: 7.24.6 - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} + '@types/superagent@4.1.13': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/node': 25.8.0 - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} + acorn@8.16.0: {} - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 - chai-http@4.4.0: - resolution: {integrity: sha512-uswN3rZpawlRaa5NiDUHcDZ3v2dw5QgLyAwnQ2tnVNuP7CwIsOFuYJ0xR1WiR7ymD4roBnJIzOUep7w9jQMFJA==} - engines: {node: '>=10'} + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 - chai-json@1.0.0: - resolution: {integrity: sha512-p9eEYu3H2BkpU8PW8kqt0N6Ni6UG3LCCjlqHtZbrgvGRJYD4UnJuh704EFbGRxylzEPWsaOGhgXfdn0n1W3hhA==} + ansi-regex@5.0.1: {} - chai-xml@0.4.1: - resolution: {integrity: sha512-VUf5Ol4ifOAsgz+lN4tfWENgQtrKxHPWsmpL5wdbqQdkpblZkcDlaT2aFvsPQH219Yvl8vc4064yFErgBIn9bw==} - engines: {node: '>= 0.8.0'} - peerDependencies: - chai: '>=1.10.0 ' + ansi-regex@6.2.2: {} - chai@3.5.0: - resolution: {integrity: sha512-eRYY0vPS2a9zt5w5Z0aCeWbrXTEyvk7u/Xf71EzNObrjSCPgMm1Nku/D/u2tiqHBX5j40wWhj54YJLtgn8g55A==} - engines: {node: '>= 0.4.0'} + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 - chai@4.5.0: - resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} - engines: {node: '>=4'} + ansi-styles@6.2.3: {} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 - charset@1.0.1: - resolution: {integrity: sha512-6dVyOOYjpfFcL1Y4qChrAoQLRHvj2ziyhcm0QJlhOcAhykL/k1kTUPbeo+87MNRTRdk2OIIsIXbuF3x2wi5EXg==} - engines: {node: '>=4.0.0'} + argparse@2.0.1: {} - check-error@1.0.3: - resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + array-flatten@1.1.1: {} - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} + array-ify@1.0.0: {} - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} + asap@2.0.6: {} - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} + assertion-error@1.1.0: {} - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} + asynckit@0.4.0: {} - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + balanced-match@1.0.2: {} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} + balanced-match@4.0.4: {} - compare-func@2.0.0: - resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 - component-emitter@1.3.1: - resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + binary-extensions@2.3.0: {} + + body-parser@1.20.5: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browser-stdout@1.3.1: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@6.3.0: {} + + chai-http@4.4.0: + dependencies: + '@types/chai': 4.3.20 + '@types/superagent': 4.1.13 + charset: 1.0.1 + cookiejar: 2.1.4 + is-ip: 2.0.0 + methods: 1.1.2 + qs: 6.15.1 + superagent: 8.1.2 + transitivePeerDependencies: + - supports-color + + chai-json@1.0.0: + dependencies: + chai: 3.5.0 + jsonfile: 3.0.1 + underscore: 1.13.8 + + chai-xml@0.4.1(chai@4.5.0): + dependencies: + chai: 4.5.0 + xml2js: 0.5.0 + + chai@3.5.0: + dependencies: + assertion-error: 1.1.0 + deep-eql: 0.1.3 + type-detect: 1.0.0 + + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + charset@1.0.1: {} + + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + + component-emitter@1.3.1: {} + + concat-map@0.0.1: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + conventional-changelog-angular@8.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@9.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-commits-parser@6.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + meow: 13.2.0 - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + cookie-signature@1.0.7: {} - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} + cookie-signature@1.2.2: {} - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} + cookie@0.7.2: {} - content-type@2.0.0: - resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} - engines: {node: '>=18'} + cookiejar@2.1.4: {} - conventional-changelog-angular@8.3.1: - resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} - engines: {node: '>=18'} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 - conventional-changelog-conventionalcommits@9.3.1: - resolution: {integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==} - engines: {node: '>=18'} + cosmiconfig-typescript-loader@6.3.0(@types/node@25.8.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): + dependencies: + '@types/node': 25.8.0 + cosmiconfig: 9.0.1(typescript@5.9.3) + jiti: 2.6.1 + typescript: 5.9.3 - conventional-commits-parser@6.4.0: - resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} - engines: {node: '>=18'} - hasBin: true - - cookie-signature@1.0.7: - resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cosmiconfig@9.0.1(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 - cookie-signature@1.2.2: - resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} - engines: {node: '>=6.6.0'} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} + dayjs@1.11.20: {} - cookiejar@2.1.4: - resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} - - cors@2.8.6: - resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} - engines: {node: '>= 0.10'} - - cosmiconfig-typescript-loader@6.3.0: - resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} - engines: {node: '>=v18'} - peerDependencies: - '@types/node': '*' - cosmiconfig: '>=9' - typescript: '>=5' - - cosmiconfig@9.0.1: - resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} - engines: {node: '>=14'} - peerDependencies: - typescript: '>=4.9.5' - peerDependenciesMeta: - typescript: - optional: true - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - dayjs@1.11.20: - resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} - - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decamelize@4.0.0: - resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} - engines: {node: '>=10'} - - deep-eql@0.1.3: - resolution: {integrity: sha512-6sEotTRGBFiNcqVoeHwnfopbSpi5NbH1VWJmYCVkmxMmaVTT0bUTrNaGyBwhgP4MZL012W/mkzIn3Da+iDYweg==} - - deep-eql@4.1.4: - resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} - engines: {node: '>=6'} - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} + debug@2.6.9: + dependencies: + ms: 2.0.0 - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} + debug@4.4.3: + dependencies: + ms: 2.1.3 - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + debug@4.4.3(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 - dezalgo@1.0.4: - resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 - diff@7.0.0: - resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} - engines: {node: '>=0.3.1'} + decamelize@4.0.0: {} - dot-prop@5.3.0: - resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} - engines: {node: '>=8'} + deep-eql@0.1.3: + dependencies: + type-detect: 0.1.1 - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - engines: {node: '>=12'} + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} + deep-is@0.1.4: {} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + delayed-stream@1.0.0: {} - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + depd@2.0.0: {} - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + destroy@1.2.0: {} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} + diff@7.0.0: {} - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + dotenv@17.4.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ee-first@1.1.1: {} + + emoji-regex@8.0.0: {} - env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} + emoji-regex@9.2.2: {} - error-ex@1.3.4: - resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + encodeurl@2.0.0: {} - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - es-toolkit@1.46.1: - resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-scope@9.1.2: - resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@10.4.0: - resolution: {integrity: sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: + entities@4.5.0: {} + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + es-toolkit@1.46.1: {} + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.4.0(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + express-handlebars@5.3.5: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + handlebars: 4.7.9 + + express@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.5 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.15.1 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.2: {} + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flat@5.0.2: {} + + flatted@3.4.2: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + formidable@2.1.5: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + qs: 6.15.1 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: optional: true - espree@11.2.0: - resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - - express-handlebars@5.3.5: - resolution: {integrity: sha512-r9pzDc94ZNJ7FVvtsxLfPybmN0eFAUnR61oimNPRpD0D7nkLcezrkpZzoXS5TI75wYHRbflPLTU39B62pwB4DA==} - engines: {node: '>=v10.24.1'} - - express@4.22.2: - resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} - engines: {node: '>= 0.10.0'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fast-safe-stringify@2.1.1: - resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - finalhandler@1.3.2: - resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} - engines: {node: '>= 0.8'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flat@5.0.2: - resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} - hasBin: true - - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - - formidable@2.1.5: - resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==} - - formidable@3.5.4: - resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} - engines: {node: '>=14.0.0'} - - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} + function-bind@1.1.2: {} + + get-caller-file@2.0.5: {} + + get-func-name@2.0.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} + git-raw-commits@5.0.1(conventional-commits-parser@6.4.0): + dependencies: + '@conventional-changelog/git-client': 2.7.0(conventional-commits-parser@6.4.0) + meow: 13.2.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 - get-func-name@2.0.2: - resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + global-directory@5.0.0: + dependencies: + ini: 6.0.0 - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} + gopd@1.2.0: {} - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} + graceful-fs@4.2.11: {} - git-raw-commits@5.0.1: - resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} - engines: {node: '>=18'} - hasBin: true + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} + has-flag@3.0.0: {} - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} + has-flag@4.0.0: {} - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true + has-symbols@1.1.0: {} - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 - global-directory@5.0.0: - resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} - engines: {node: '>=20'} + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} + he@1.2.0: {} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 - handlebars@4.7.9: - resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} - engines: {node: '>=0.4.7'} - hasBin: true + https@1.0.0: {} - has-flag@3.0.0: - resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} - engines: {node: '>=4'} + husky@9.1.7: {} - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} + ignore-by-default@1.0.1: {} - hasown@2.0.3: - resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} - engines: {node: '>= 0.4'} + ignore@5.3.2: {} - he@1.2.0: - resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} - hasBin: true + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 - http-errors@2.0.1: - resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} - engines: {node: '>= 0.8'} + import-meta-resolve@4.2.0: {} - https@1.0.0: - resolution: {integrity: sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==} + imurmurhash@0.1.4: {} - husky@9.1.7: - resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} - engines: {node: '>=18'} - hasBin: true + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} + inherits@2.0.4: {} - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} + ini@6.0.0: {} - ignore-by-default@1.0.1: - resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + ip-regex@2.1.0: {} - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} + ipaddr.js@1.9.1: {} - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} + is-arrayish@0.2.1: {} - import-meta-resolve@4.2.0: - resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} + is-extglob@2.1.1: {} - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + is-fullwidth-code-point@3.0.0: {} - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 - ini@6.0.0: - resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} - engines: {node: ^20.17.0 || >=22.9.0} + is-ip@2.0.0: + dependencies: + ip-regex: 2.1.0 - ip-regex@2.1.0: - resolution: {integrity: sha512-58yWmlHpp7VYfcdTwMTvwMmqx/Elfxjd9RXTDyMsbL7lLWmhMylLEqiYVLKuLzOZqVgiWXD9MfR62Vv89VRxkw==} - engines: {node: '>=4'} + is-number@7.0.0: {} - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} + is-obj@2.0.0: {} - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-path-inside@3.0.3: {} - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} + is-plain-obj@2.1.0: {} - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} + is-plain-obj@4.1.0: {} - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} + is-unicode-supported@0.1.0: {} - is-ip@2.0.0: - resolution: {integrity: sha512-9MTn0dteHETtyUx8pxqMwg5hMBi3pvlyglJ+b79KOCca0po23337LbVV2Hl4xmMvfw++ljnO0/+5G6G+0Szh6g==} - engines: {node: '>=4'} + isexe@2.0.0: {} - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 - is-obj@2.0.0: - resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} - engines: {node: '>=8'} + jiti@2.6.1: {} - is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} + js-tokens@4.0.0: {} - is-plain-obj@2.1.0: - resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} - engines: {node: '>=8'} + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 - is-plain-obj@4.1.0: - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} - engines: {node: '>=12'} + json-buffer@3.0.1: {} - is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} - engines: {node: '>= 0.4'} + json-parse-even-better-errors@2.3.1: {} - is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} + json-schema-traverse@0.4.1: {} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + json-schema-traverse@1.0.0: {} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + json-stable-stringify-without-jsonify@1.0.1: {} - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} - hasBin: true + jsonfile@3.0.1: + optionalDependencies: + graceful-fs: 4.2.11 - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + lines-and-columns@1.2.4: {} - json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + lodash.once@4.1.1: {} - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 - jsonfile@3.0.1: - resolution: {integrity: sha512-oBko6ZHlubVB5mRFkur5vgYR1UyqX+S6Y/oCfLhqNdcc2fYFlDpIoNc7AfKS1KOGcnNAkvsr0grLck9ANM815w==} + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + lru-cache@10.4.3: {} - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + math-intrinsics@1.1.0: {} - linkify-it@5.0.0: - resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + mdurl@2.0.0: {} - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} + media-typer@0.3.0: {} - lodash.once@4.1.1: - resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + media-typer@1.1.0: {} - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} + meow@13.2.0: {} - loupe@2.3.7: - resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + merge-descriptors@1.0.3: {} - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + methods@1.1.2: {} - markdown-it@14.1.1: - resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} - hasBin: true + mime-db@1.52.0: {} - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} + mime-db@1.54.0: {} - mdurl@2.0.0: - resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} - engines: {node: '>= 0.8'} + mime@1.6.0: {} - meow@13.2.0: - resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} - engines: {node: '>=18'} + mime@2.6.0: {} - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 - mime-db@1.54.0: - resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} - engines: {node: '>= 0.6'} + minimist@1.2.8: {} - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} + minipass@7.1.3: {} - mime-types@3.0.2: - resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} - engines: {node: '>=18'} + mkdirp@1.0.4: {} - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true + mocha-multi@1.1.7(mocha@11.7.5): + dependencies: + debug: 4.4.3 + is-string: 1.1.1 + lodash.once: 4.1.1 + mkdirp: 1.0.4 + mocha: 11.7.5 + object-assign: 4.1.1 + transitivePeerDependencies: + - supports-color - mime@2.6.0: - resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} - engines: {node: '>=4.0.0'} - hasBin: true + mocha@11.7.5: + dependencies: + browser-stdout: 1.3.1 + chokidar: 4.0.3 + debug: 4.4.3(supports-color@8.1.1) + diff: 7.0.0 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 10.5.0 + he: 1.2.0 + is-path-inside: 3.0.3 + js-yaml: 4.1.1 + log-symbols: 4.1.0 + minimatch: 9.0.9 + ms: 2.1.3 + picocolors: 1.1.1 + serialize-javascript: 7.0.5 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + workerpool: 9.3.4 + yargs: 17.7.2 + yargs-parser: 21.1.1 + yargs-unparser: 2.0.0 - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} + morgan@1.10.1: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 2.0.0 + on-finished: 2.3.0 + on-headers: 1.1.0 + transitivePeerDependencies: + - supports-color - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + ms@2.0.0: {} - minimatch@9.0.9: - resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} - engines: {node: '>=16 || 14 >=14.17'} + ms@2.1.3: {} - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + natural-compare@1.4.0: {} - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} + negotiator@0.6.3: {} - mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} - engines: {node: '>=10'} - hasBin: true + neo-async@2.6.2: {} - mocha-multi@1.1.7: - resolution: {integrity: sha512-SXZRgHy0XiRTASyOp0p6fjOkdj+R62L6cqutnYyQOvIjNznJuUwzykxctypeRiOwPd+gfn4yt3NRulMQyI8Tzg==} - engines: {node: '>=6.0.0'} - peerDependencies: - mocha: '>=2.2.0 <7 || >=9' + nodemon@3.1.14: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 10.2.5 + pstree.remy: 1.1.8 + semver: 7.8.0 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 - mocha@11.7.5: - resolution: {integrity: sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - hasBin: true + normalize-path@3.0.0: {} - morgan@1.10.1: - resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==} - engines: {node: '>= 0.8.0'} + object-assign@4.1.1: {} - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + object-inspect@1.13.4: {} - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} + on-headers@1.1.0: {} - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + once@1.4.0: + dependencies: + wrappy: 1.0.2 - nodemon@3.1.14: - resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==} - engines: {node: '>=10'} - hasBin: true + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} + package-json-from-dist@1.0.1: {} - on-finished@2.3.0: - resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} - engines: {node: '>= 0.8'} + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 - on-headers@1.1.0: - resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} - engines: {node: '>= 0.8'} + parseurl@1.3.3: {} - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + path-exists@4.0.0: {} - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} + path-is-absolute@1.0.1: {} - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} + path-key@3.1.1: {} - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + path-to-regexp@0.1.13: {} - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} + pathval@1.1.1: {} - parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} + picocolors@1.1.1: {} - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} + picomatch@2.3.2: {} - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} + prelude-ls@1.2.1: {} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} + prettier@3.8.3: {} - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} + pstree.remy@1.1.8: {} - path-to-regexp@0.1.13: - resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + punycode.js@2.3.1: {} - pathval@1.1.1: - resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + punycode@2.3.1: {} - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + qs@6.15.1: + dependencies: + side-channel: 1.1.0 - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} + range-parser@1.2.1: {} - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 - prettier@3.8.3: - resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} - engines: {node: '>=14'} - hasBin: true + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 - pstree.remy@1.1.8: - resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + readdirp@4.1.2: {} - punycode.js@2.3.1: - resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} - engines: {node: '>=6'} + require-directory@2.1.1: {} - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} + require-from-string@2.0.2: {} - qs@6.15.1: - resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} - engines: {node: '>=0.6'} + resolve-from@4.0.0: {} - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} + resolve-from@5.0.0: {} - raw-body@2.5.3: - resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} - engines: {node: '>= 0.8'} + safe-buffer@5.1.2: {} - raw-body@3.0.2: - resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} - engines: {node: '>= 0.10'} + safe-buffer@5.2.1: {} - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} + safer-buffer@2.1.2: {} - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} + sax@1.6.0: {} - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} + semver@7.8.0: {} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} + serialize-javascript@7.0.5: {} - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - sax@1.6.0: - resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} - engines: {node: '>=11.0.0'} - - semver@7.8.0: - resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} - engines: {node: '>=10'} - hasBin: true - - send@0.19.2: - resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} - engines: {node: '>= 0.8.0'} - - serialize-javascript@7.0.5: - resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} - engines: {node: '>=20.0.0'} - - serve-static@1.16.3: - resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} - engines: {node: '>= 0.8.0'} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - side-channel-list@1.0.1: - resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - simple-update-notifier@2.0.0: - resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} - engines: {node: '>=10'} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} - engines: {node: '>=12'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - superagent@10.3.0: - resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} - engines: {node: '>=14.18.0'} - - superagent@8.1.2: - resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} - engines: {node: '>=6.4.0 <13 || >=14'} - deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net - - supertest@7.2.2: - resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} - engines: {node: '>=14.18.0'} - - supports-color@5.5.0: - resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} - engines: {node: '>=4'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - - tinyexec@1.1.2: - resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} - engines: {node: '>=18'} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - - touch@3.1.1: - resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} - hasBin: true - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - type-detect@0.1.1: - resolution: {integrity: sha512-5rqszGVwYgBoDkIm2oUtvkfZMQ0vk29iDMU0W2qCa3rG0vPDNczCMT4hV/bLBgLg8k8ri6+u3Zbt+S/14eMzlA==} - - type-detect@1.0.0: - resolution: {integrity: sha512-f9Uv6ezcpvCQjJU0Zqbg+65qdcszv3qUQsZfjdRbWiZ7AMenrX1u0lNk9EoWWX6e1F+NULyg27mtdeZ5WhpljA==} - - type-detect@4.1.0: - resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} - engines: {node: '>=4'} - - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - - type-is@2.1.0: - resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} - engines: {node: '>= 18'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - uc.micro@2.1.0: - resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - - undefsafe@2.0.5: - resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} - - underscore@1.13.8: - resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} - - undici-types@7.24.6: - resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - - workerpool@9.3.4: - resolution: {integrity: sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - ws@8.20.1: - resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@4.1.0: {} + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.8.0 + + source-map@0.6.1: {} + + statuses@2.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-json-comments@3.1.1: {} + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.1 + transitivePeerDependencies: + - supports-color + + superagent@8.1.2: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 2.1.5 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.1 + semver: 7.8.0 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + tinyexec@1.1.2: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + touch@3.1.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@0.1.1: {} + + type-detect@1.0.0: {} + + type-detect@4.1.0: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + uc.micro@2.1.0: {} + + uglify-js@3.19.3: optional: true - xml2js@0.5.0: - resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} - engines: {node: '>=4.0.0'} - - xml2js@0.6.2: - resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} - engines: {node: '>=4.0.0'} - - xmlbuilder@11.0.1: - resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} - engines: {node: '>=4.0'} - - xmlbuilder@15.1.1: - resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} - engines: {node: '>=8.0'} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs-unparser@2.0.0: - resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} - engines: {node: '>=10'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - -snapshots: - - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/helper-validator-identifier@7.28.5': {} - - '@commitlint/cli@20.5.3(@types/node@25.8.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': - dependencies: - '@commitlint/format': 20.5.0 - '@commitlint/lint': 20.5.3 - '@commitlint/load': 20.5.3(@types/node@25.8.0)(typescript@5.9.3) - '@commitlint/read': 20.5.0(conventional-commits-parser@6.4.0) - '@commitlint/types': 20.5.0 - tinyexec: 1.1.2 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - conventional-commits-filter - - conventional-commits-parser - - typescript - - '@commitlint/config-conventional@20.5.3': - dependencies: - '@commitlint/types': 20.5.0 - conventional-changelog-conventionalcommits: 9.3.1 - - '@commitlint/config-validator@20.5.0': - dependencies: - '@commitlint/types': 20.5.0 - ajv: 8.20.0 - - '@commitlint/ensure@20.5.3': - dependencies: - '@commitlint/types': 20.5.0 - es-toolkit: 1.46.1 - - '@commitlint/execute-rule@20.0.0': {} - - '@commitlint/format@20.5.0': - dependencies: - '@commitlint/types': 20.5.0 - picocolors: 1.1.1 - - '@commitlint/is-ignored@20.5.0': - dependencies: - '@commitlint/types': 20.5.0 - semver: 7.8.0 - - '@commitlint/lint@20.5.3': - dependencies: - '@commitlint/is-ignored': 20.5.0 - '@commitlint/parse': 20.5.0 - '@commitlint/rules': 20.5.3 - '@commitlint/types': 20.5.0 - - '@commitlint/load@20.5.3(@types/node@25.8.0)(typescript@5.9.3)': - dependencies: - '@commitlint/config-validator': 20.5.0 - '@commitlint/execute-rule': 20.0.0 - '@commitlint/resolve-extends': 20.5.3 - '@commitlint/types': 20.5.0 - cosmiconfig: 9.0.1(typescript@5.9.3) - cosmiconfig-typescript-loader: 6.3.0(@types/node@25.8.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) - es-toolkit: 1.46.1 - is-plain-obj: 4.1.0 - picocolors: 1.1.1 - transitivePeerDependencies: - - '@types/node' - - typescript - - '@commitlint/message@20.4.3': {} - - '@commitlint/parse@20.5.0': - dependencies: - '@commitlint/types': 20.5.0 - conventional-changelog-angular: 8.3.1 - conventional-commits-parser: 6.4.0 - - '@commitlint/read@20.5.0(conventional-commits-parser@6.4.0)': - dependencies: - '@commitlint/top-level': 20.4.3 - '@commitlint/types': 20.5.0 - git-raw-commits: 5.0.1(conventional-commits-parser@6.4.0) - minimist: 1.2.8 - tinyexec: 1.1.2 - transitivePeerDependencies: - - conventional-commits-filter - - conventional-commits-parser - - '@commitlint/resolve-extends@20.5.3': - dependencies: - '@commitlint/config-validator': 20.5.0 - '@commitlint/types': 20.5.0 - es-toolkit: 1.46.1 - global-directory: 5.0.0 - import-meta-resolve: 4.2.0 - resolve-from: 5.0.0 - - '@commitlint/rules@20.5.3': - dependencies: - '@commitlint/ensure': 20.5.3 - '@commitlint/message': 20.4.3 - '@commitlint/to-lines': 20.0.0 - '@commitlint/types': 20.5.0 - - '@commitlint/to-lines@20.0.0': {} - - '@commitlint/top-level@20.4.3': - dependencies: - escalade: 3.2.0 - - '@commitlint/types@20.5.0': - dependencies: - conventional-commits-parser: 6.4.0 - picocolors: 1.1.1 - - '@conventional-changelog/git-client@2.7.0(conventional-commits-parser@6.4.0)': - dependencies: - '@simple-libs/child-process-utils': 1.0.2 - '@simple-libs/stream-utils': 1.2.0 - semver: 7.8.0 - optionalDependencies: - conventional-commits-parser: 6.4.0 - - '@eslint-community/eslint-utils@4.9.1(eslint@10.4.0(jiti@2.6.1))': - dependencies: - eslint: 10.4.0(jiti@2.6.1) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.23.5': - dependencies: - '@eslint/object-schema': 3.0.5 - debug: 4.4.3 - minimatch: 10.2.5 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.6.0': - dependencies: - '@eslint/core': 1.2.1 - - '@eslint/core@1.2.1': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/object-schema@3.0.5': {} - - '@eslint/plugin-kit@0.7.1': - dependencies: - '@eslint/core': 1.2.1 - levn: 0.4.1 - - '@humanfs/core@0.19.2': - dependencies: - '@humanfs/types': 0.15.0 - - '@humanfs/node@0.16.8': - dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 - '@humanwhocodes/retry': 0.4.3 - - '@humanfs/types@0.15.0': {} - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.2.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - - '@noble/hashes@1.8.0': {} - - '@paralleldrive/cuid2@2.3.1': - dependencies: - '@noble/hashes': 1.8.0 - - '@pkgjs/parseargs@0.11.0': - optional: true - - '@simple-libs/child-process-utils@1.0.2': - dependencies: - '@simple-libs/stream-utils': 1.2.0 - - '@simple-libs/stream-utils@1.2.0': {} - - '@types/chai@4.3.20': {} - - '@types/cookiejar@2.1.5': {} - - '@types/esrecurse@4.3.1': {} - - '@types/estree@1.0.9': {} - - '@types/json-schema@7.0.15': {} - - '@types/node@25.8.0': - dependencies: - undici-types: 7.24.6 - - '@types/superagent@4.1.13': - dependencies: - '@types/cookiejar': 2.1.5 - '@types/node': 25.8.0 - - accepts@1.3.8: - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - - acorn@8.16.0: {} - - ajv@6.15.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ajv@8.20.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@6.2.3: {} - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.2 - - argparse@2.0.1: {} - - array-flatten@1.1.1: {} - - array-ify@1.0.0: {} - - asap@2.0.6: {} - - assertion-error@1.1.0: {} - - asynckit@0.4.0: {} - - balanced-match@1.0.2: {} - - balanced-match@4.0.4: {} - - basic-auth@2.0.1: - dependencies: - safe-buffer: 5.1.2 - - binary-extensions@2.3.0: {} - - body-parser@1.20.5: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.15.1 - raw-body: 2.5.3 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - - body-parser@2.2.2: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 4.4.3 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - on-finished: 2.4.1 - qs: 6.15.1 - raw-body: 3.0.2 - type-is: 2.1.0 - transitivePeerDependencies: - - supports-color - - brace-expansion@1.1.14: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.1.0: - dependencies: - balanced-match: 1.0.2 - - brace-expansion@5.0.6: - dependencies: - balanced-match: 4.0.4 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - browser-stdout@1.3.1: {} - - bytes@3.1.2: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - callsites@3.1.0: {} - - camelcase@6.3.0: {} - - chai-http@4.4.0: - dependencies: - '@types/chai': 4.3.20 - '@types/superagent': 4.1.13 - charset: 1.0.1 - cookiejar: 2.1.4 - is-ip: 2.0.0 - methods: 1.1.2 - qs: 6.15.1 - superagent: 8.1.2 - transitivePeerDependencies: - - supports-color - - chai-json@1.0.0: - dependencies: - chai: 3.5.0 - jsonfile: 3.0.1 - underscore: 1.13.8 - - chai-xml@0.4.1(chai@4.5.0): - dependencies: - chai: 4.5.0 - xml2js: 0.5.0 - - chai@3.5.0: - dependencies: - assertion-error: 1.1.0 - deep-eql: 0.1.3 - type-detect: 1.0.0 - - chai@4.5.0: - dependencies: - assertion-error: 1.1.0 - check-error: 1.0.3 - deep-eql: 4.1.4 - get-func-name: 2.0.2 - loupe: 2.3.7 - pathval: 1.1.1 - type-detect: 4.1.0 - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - charset@1.0.1: {} - - check-error@1.0.3: - dependencies: - get-func-name: 2.0.2 - - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - compare-func@2.0.0: - dependencies: - array-ify: 1.0.0 - dot-prop: 5.3.0 - - component-emitter@1.3.1: {} - - concat-map@0.0.1: {} - - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 - - content-type@1.0.5: {} - - content-type@2.0.0: {} - - conventional-changelog-angular@8.3.1: - dependencies: - compare-func: 2.0.0 - - conventional-changelog-conventionalcommits@9.3.1: - dependencies: - compare-func: 2.0.0 - - conventional-commits-parser@6.4.0: - dependencies: - '@simple-libs/stream-utils': 1.2.0 - meow: 13.2.0 - - cookie-signature@1.0.7: {} - - cookie-signature@1.2.2: {} - - cookie@0.7.2: {} - - cookiejar@2.1.4: {} - - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - - cosmiconfig-typescript-loader@6.3.0(@types/node@25.8.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): - dependencies: - '@types/node': 25.8.0 - cosmiconfig: 9.0.1(typescript@5.9.3) - jiti: 2.6.1 - typescript: 5.9.3 - - cosmiconfig@9.0.1(typescript@5.9.3): - dependencies: - env-paths: 2.2.1 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - parse-json: 5.2.0 - optionalDependencies: - typescript: 5.9.3 - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - dayjs@1.11.20: {} - - debug@2.6.9: - dependencies: - ms: 2.0.0 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - debug@4.4.3(supports-color@5.5.0): - dependencies: - ms: 2.1.3 - optionalDependencies: - supports-color: 5.5.0 - - debug@4.4.3(supports-color@8.1.1): - dependencies: - ms: 2.1.3 - optionalDependencies: - supports-color: 8.1.1 - - decamelize@4.0.0: {} - - deep-eql@0.1.3: - dependencies: - type-detect: 0.1.1 - - deep-eql@4.1.4: - dependencies: - type-detect: 4.1.0 - - deep-is@0.1.4: {} - - delayed-stream@1.0.0: {} - - depd@2.0.0: {} - - destroy@1.2.0: {} - - dezalgo@1.0.4: - dependencies: - asap: 2.0.6 - wrappy: 1.0.2 - - diff@7.0.0: {} - - dot-prop@5.3.0: - dependencies: - is-obj: 2.0.0 - - dotenv@17.4.2: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - eastasianwidth@0.2.0: {} - - ee-first@1.1.1: {} - - emoji-regex@8.0.0: {} - - emoji-regex@9.2.2: {} - - encodeurl@2.0.0: {} - - entities@4.5.0: {} - - env-paths@2.2.1: {} - - error-ex@1.3.4: - dependencies: - is-arrayish: 0.2.1 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.3 - - es-toolkit@1.46.1: {} - - escalade@3.2.0: {} - - escape-html@1.0.3: {} - - escape-string-regexp@4.0.0: {} - - eslint-scope@9.1.2: - dependencies: - '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.9 - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@10.4.0(jiti@2.6.1): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.6.1)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.6.0 - '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.7.1 - '@humanfs/node': 0.16.8 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.9 - ajv: 6.15.0 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 9.1.2 - eslint-visitor-keys: 5.0.1 - espree: 11.2.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.6.1 - transitivePeerDependencies: - - supports-color - - espree@11.2.0: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 5.0.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - esutils@2.0.3: {} - - etag@1.8.1: {} - - express-handlebars@5.3.5: - dependencies: - glob: 7.2.3 - graceful-fs: 4.2.11 - handlebars: 4.7.9 - - express@4.22.2: - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.5 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.0.7 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.2 - fresh: 0.5.2 - http-errors: 2.0.1 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.13 - proxy-addr: 2.0.7 - qs: 6.15.1 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.2 - serve-static: 1.16.3 - setprototypeof: 1.2.0 - statuses: 2.0.2 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - fast-deep-equal@3.1.3: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fast-safe-stringify@2.1.1: {} - - fast-uri@3.1.2: {} - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - finalhandler@1.3.2: - dependencies: - debug: 2.6.9 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.4.2 - keyv: 4.5.4 - - flat@5.0.2: {} - - flatted@3.4.2: {} - - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.3 - mime-types: 2.1.35 - - formidable@2.1.5: - dependencies: - '@paralleldrive/cuid2': 2.3.1 - dezalgo: 1.0.4 - once: 1.4.0 - qs: 6.15.1 - - formidable@3.5.4: - dependencies: - '@paralleldrive/cuid2': 2.3.1 - dezalgo: 1.0.4 - once: 1.4.0 - - forwarded@0.2.0: {} - - fresh@0.5.2: {} - - fs.realpath@1.0.0: {} - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - get-caller-file@2.0.5: {} - - get-func-name@2.0.2: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.3 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - git-raw-commits@5.0.1(conventional-commits-parser@6.4.0): - dependencies: - '@conventional-changelog/git-client': 2.7.0(conventional-commits-parser@6.4.0) - meow: 13.2.0 - transitivePeerDependencies: - - conventional-commits-filter - - conventional-commits-parser - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.9 - minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.5 - once: 1.4.0 - path-is-absolute: 1.0.1 - - global-directory@5.0.0: - dependencies: - ini: 6.0.0 - - gopd@1.2.0: {} - - graceful-fs@4.2.11: {} - - handlebars@4.7.9: - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - - has-flag@3.0.0: {} - - has-flag@4.0.0: {} - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.3: - dependencies: - function-bind: 1.1.2 - - he@1.2.0: {} - - http-errors@2.0.1: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 - - https@1.0.0: {} - - husky@9.1.7: {} - - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 - - ignore-by-default@1.0.1: {} - - ignore@5.3.2: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - import-meta-resolve@4.2.0: {} - - imurmurhash@0.1.4: {} - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - ini@6.0.0: {} - - ip-regex@2.1.0: {} - - ipaddr.js@1.9.1: {} - - is-arrayish@0.2.1: {} - - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - - is-extglob@2.1.1: {} - - is-fullwidth-code-point@3.0.0: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-ip@2.0.0: - dependencies: - ip-regex: 2.1.0 - - is-number@7.0.0: {} - - is-obj@2.0.0: {} - - is-path-inside@3.0.3: {} - - is-plain-obj@2.1.0: {} - - is-plain-obj@4.1.0: {} - - is-string@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-unicode-supported@0.1.0: {} - - isexe@2.0.0: {} - - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - - jiti@2.6.1: {} - - js-tokens@4.0.0: {} - - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - json-buffer@3.0.1: {} - - json-parse-even-better-errors@2.3.1: {} - - json-schema-traverse@0.4.1: {} - - json-schema-traverse@1.0.0: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - jsonfile@3.0.1: - optionalDependencies: - graceful-fs: 4.2.11 - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - lines-and-columns@1.2.4: {} - - linkify-it@5.0.0: - dependencies: - uc.micro: 2.1.0 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash.once@4.1.1: {} - - log-symbols@4.1.0: - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 - - loupe@2.3.7: - dependencies: - get-func-name: 2.0.2 - - lru-cache@10.4.3: {} - - markdown-it@14.1.1: - dependencies: - argparse: 2.0.1 - entities: 4.5.0 - linkify-it: 5.0.0 - mdurl: 2.0.0 - punycode.js: 2.3.1 - uc.micro: 2.1.0 - - math-intrinsics@1.1.0: {} - - mdurl@2.0.0: {} - - media-typer@0.3.0: {} - - media-typer@1.1.0: {} - - meow@13.2.0: {} - - merge-descriptors@1.0.3: {} - - methods@1.1.2: {} - - mime-db@1.52.0: {} - - mime-db@1.54.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - mime-types@3.0.2: - dependencies: - mime-db: 1.54.0 - - mime@1.6.0: {} - - mime@2.6.0: {} - - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.6 - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.14 - - minimatch@9.0.9: - dependencies: - brace-expansion: 2.1.0 - - minimist@1.2.8: {} - - minipass@7.1.3: {} - - mkdirp@1.0.4: {} - - mocha-multi@1.1.7(mocha@11.7.5): - dependencies: - debug: 4.4.3 - is-string: 1.1.1 - lodash.once: 4.1.1 - mkdirp: 1.0.4 - mocha: 11.7.5 - object-assign: 4.1.1 - transitivePeerDependencies: - - supports-color - - mocha@11.7.5: - dependencies: - browser-stdout: 1.3.1 - chokidar: 4.0.3 - debug: 4.4.3(supports-color@8.1.1) - diff: 7.0.0 - escape-string-regexp: 4.0.0 - find-up: 5.0.0 - glob: 10.5.0 - he: 1.2.0 - is-path-inside: 3.0.3 - js-yaml: 4.1.1 - log-symbols: 4.1.0 - minimatch: 9.0.9 - ms: 2.1.3 - picocolors: 1.1.1 - serialize-javascript: 7.0.5 - strip-json-comments: 3.1.1 - supports-color: 8.1.1 - workerpool: 9.3.4 - yargs: 17.7.2 - yargs-parser: 21.1.1 - yargs-unparser: 2.0.0 - - morgan@1.10.1: - dependencies: - basic-auth: 2.0.1 - debug: 2.6.9 - depd: 2.0.0 - on-finished: 2.3.0 - on-headers: 1.1.0 - transitivePeerDependencies: - - supports-color - - ms@2.0.0: {} - - ms@2.1.3: {} - - natural-compare@1.4.0: {} - - negotiator@0.6.3: {} - - neo-async@2.6.2: {} - - nodemon@3.1.14: - dependencies: - chokidar: 3.6.0 - debug: 4.4.3(supports-color@5.5.0) - ignore-by-default: 1.0.1 - minimatch: 10.2.5 - pstree.remy: 1.1.8 - semver: 7.8.0 - simple-update-notifier: 2.0.0 - supports-color: 5.5.0 - touch: 3.1.1 - undefsafe: 2.0.5 - - normalize-path@3.0.0: {} - - object-assign@4.1.1: {} - - object-inspect@1.13.4: {} - - on-finished@2.3.0: - dependencies: - ee-first: 1.1.1 - - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - - on-headers@1.1.0: {} - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - package-json-from-dist@1.0.1: {} - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - parse-json@5.2.0: - dependencies: - '@babel/code-frame': 7.29.0 - error-ex: 1.3.4 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - - parseurl@1.3.3: {} - - path-exists@4.0.0: {} - - path-is-absolute@1.0.1: {} - - path-key@3.1.1: {} - - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.3 - - path-to-regexp@0.1.13: {} - - pathval@1.1.1: {} - - picocolors@1.1.1: {} - - picomatch@2.3.2: {} - - prelude-ls@1.2.1: {} - - prettier@3.8.3: {} - - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - - pstree.remy@1.1.8: {} - - punycode.js@2.3.1: {} - - punycode@2.3.1: {} - - qs@6.15.1: - dependencies: - side-channel: 1.1.0 - - range-parser@1.2.1: {} - - raw-body@2.5.3: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - - raw-body@3.0.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - unpipe: 1.0.0 - - readdirp@3.6.0: - dependencies: - picomatch: 2.3.2 - - readdirp@4.1.2: {} - - require-directory@2.1.1: {} - - require-from-string@2.0.2: {} - - resolve-from@4.0.0: {} - - resolve-from@5.0.0: {} - - safe-buffer@5.1.2: {} - - safe-buffer@5.2.1: {} - - safer-buffer@2.1.2: {} - - sax@1.6.0: {} - - semver@7.8.0: {} - - send@0.19.2: - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.1 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - serialize-javascript@7.0.5: {} - - serve-static@1.16.3: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.2 - transitivePeerDependencies: - - supports-color - - setprototypeof@1.2.0: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - side-channel-list@1.0.1: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.1 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - signal-exit@4.1.0: {} - - simple-update-notifier@2.0.0: - dependencies: - semver: 7.8.0 - - source-map@0.6.1: {} - - statuses@2.0.2: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.2.0 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-ansi@7.2.0: - dependencies: - ansi-regex: 6.2.2 - - strip-json-comments@3.1.1: {} - - superagent@10.3.0: - dependencies: - component-emitter: 1.3.1 - cookiejar: 2.1.4 - debug: 4.4.3 - fast-safe-stringify: 2.1.1 - form-data: 4.0.5 - formidable: 3.5.4 - methods: 1.1.2 - mime: 2.6.0 - qs: 6.15.1 - transitivePeerDependencies: - - supports-color - - superagent@8.1.2: - dependencies: - component-emitter: 1.3.1 - cookiejar: 2.1.4 - debug: 4.4.3 - fast-safe-stringify: 2.1.1 - form-data: 4.0.5 - formidable: 2.1.5 - methods: 1.1.2 - mime: 2.6.0 - qs: 6.15.1 - semver: 7.8.0 - transitivePeerDependencies: - - supports-color - - supertest@7.2.2: - dependencies: - cookie-signature: 1.2.2 - methods: 1.1.2 - superagent: 10.3.0 - transitivePeerDependencies: - - supports-color - - supports-color@5.5.0: - dependencies: - has-flag: 3.0.0 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - - tinyexec@1.1.2: {} - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - toidentifier@1.0.1: {} - - touch@3.1.1: {} - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - type-detect@0.1.1: {} - - type-detect@1.0.0: {} - - type-detect@4.1.0: {} - - type-is@1.6.18: - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - - type-is@2.1.0: - dependencies: - content-type: 2.0.0 - media-typer: 1.1.0 - mime-types: 3.0.2 - - typescript@5.9.3: {} - - uc.micro@2.1.0: {} - - uglify-js@3.19.3: - optional: true - - undefsafe@2.0.5: {} + undefsafe@2.0.5: {} - underscore@1.13.8: {} + underscore@1.13.8: {} - undici-types@7.24.6: {} + undici-types@7.24.6: {} - unpipe@1.0.0: {} + unpipe@1.0.0: {} - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 - utils-merge@1.0.1: {} + utils-merge@1.0.1: {} - vary@1.1.2: {} + vary@1.1.2: {} - which@2.0.2: - dependencies: - isexe: 2.0.0 + which@2.0.2: + dependencies: + isexe: 2.0.0 - word-wrap@1.2.5: {} + word-wrap@1.2.5: {} - wordwrap@1.0.0: {} + wordwrap@1.0.0: {} - workerpool@9.3.4: {} + workerpool@9.3.4: {} - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.2.0 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 - wrappy@1.0.2: {} + wrappy@1.0.2: {} - ws@8.20.1: {} + ws@8.20.1: {} - xml2js@0.5.0: - dependencies: - sax: 1.6.0 - xmlbuilder: 11.0.1 + xml2js@0.5.0: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 - xml2js@0.6.2: - dependencies: - sax: 1.6.0 - xmlbuilder: 11.0.1 + xml2js@0.6.2: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 - xmlbuilder@11.0.1: {} + xmlbuilder@11.0.1: {} - xmlbuilder@15.1.1: {} + xmlbuilder@15.1.1: {} - y18n@5.0.8: {} + y18n@5.0.8: {} - yargs-parser@21.1.1: {} + yargs-parser@21.1.1: {} - yargs-unparser@2.0.0: - dependencies: - camelcase: 6.3.0 - decamelize: 4.0.0 - flat: 5.0.2 - is-plain-obj: 2.1.0 + yargs-unparser@2.0.0: + dependencies: + camelcase: 6.3.0 + decamelize: 4.0.0 + flat: 5.0.2 + is-plain-obj: 2.1.0 - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 - yocto-queue@0.1.0: {} + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..d93aee4 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'apps/*' diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..9ba67df --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + "apps/server": { + "release-type": "node", + "component": "server", + "changelog-path": "CHANGELOG.md" + } + } +} diff --git a/services/app-messages.js b/services/app-messages.js deleted file mode 100644 index e128a5f..0000000 --- a/services/app-messages.js +++ /dev/null @@ -1,23 +0,0 @@ -module.exports = { - error: { - subscription: { - missingParams: (params) => `The following parameters were missing from the request body: ${params}.`, - invalidProtocol: (protocol) => `Can't accept the subscription because the protocol, ${protocol}, is unsupported.`, - readResource: (url) => `The subscription was cancelled because there was an error reading the resource at URL ${url}.`, - noResources: 'No resources specified.', - failedHandler: 'The subscription was cancelled because the call failed when we tested the handler.' - }, - ping: { - tooRecent: (minSeconds, lastPingSeconds) => `Can't accept the request because the minimum seconds between pings is ${minSeconds} and you pinged us ${lastPingSeconds} seconds ago.`, - readResource: (url) => `The ping was cancelled because there was an error reading the resource at URL ${url}.` - }, - rpc: { - notEnoughParams: (method) => `Can't call "${method}" because there aren't enough parameters.`, - tooManyParams: (method) => `Can't call "${method}" because there are too many parameters.` - } - }, - success: { - subscription: 'Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!', - ping: 'Thanks for the ping.' - } -}; diff --git a/services/parse-rpc-request.js b/services/parse-rpc-request.js deleted file mode 100644 index 504de92..0000000 --- a/services/parse-rpc-request.js +++ /dev/null @@ -1,86 +0,0 @@ -const getDayjs = require('./dayjs-wrapper'), - xml2js = require('xml2js'); - -async function parseRpcParam(param, dayjs) { - let returnedValue, tag, member, values; - - const value = param.value || param; - - for (tag in value) { - switch (tag) { - case 'i4': - case 'int': - case 'double': - returnedValue = Number(value[tag]); - break; - case 'string': - returnedValue = value[tag]; - break; - case 'boolean': - returnedValue = 'true' === value[tag] || !!Number(value[tag]); - break; - case 'dateTime.iso8601': - returnedValue = dayjs.utc(value[tag], ['YYYYMMDDTHHmmss', dayjs.ISO_8601]); - break; - case 'base64': - returnedValue = Buffer.from(value[tag], 'base64').toString('utf8'); - break; - case 'struct': - member = value[tag].member || []; - if (!Array.isArray(member)) { - member = [member]; - } - returnedValue = {}; - for (const item of member) { - returnedValue[item.name] = await parseRpcParam(item, dayjs); - } - break; - case 'array': - values = (value[tag].data || {}).value || []; - if (!Array.isArray(values)) { - values = [values]; - } - returnedValue = []; - for (const item of values) { - returnedValue.push(await parseRpcParam(item, dayjs)); - } - break; - } - } - - if (undefined === returnedValue) { - returnedValue = value; - } - - return returnedValue; -} - -async function parseRpcRequest(req) { - const dayjs = await getDayjs(); - const parser = new xml2js.Parser({ explicitArray: false }), - jstruct = await parser.parseStringPromise(req.body), - methodCall = jstruct.methodCall, - methodName = (methodCall || {}).methodName, - params = ((methodCall || {}).params || {}).param || []; - - if (undefined === methodCall) { - throw new Error('Bad XML-RPC call, missing "methodCall" element.'); - } - - if (undefined === methodName) { - throw new Error('Bad XML-RPC call, missing "methodName" element.'); - } - - const parsedParams = []; - const paramArray = Array.isArray(params) ? params : [params]; - for (const param of paramArray) { - parsedParams.push(await parseRpcParam(param, dayjs)); - } - - return { - methodName, - params: parsedParams - }; -} - -module.exports = parseRpcRequest; diff --git a/services/rpc-return-fault.js b/services/rpc-return-fault.js deleted file mode 100644 index 2af605f..0000000 --- a/services/rpc-return-fault.js +++ /dev/null @@ -1,30 +0,0 @@ -const builder = require('xmlbuilder'); - -function rpcReturnFault(faultCode, faultString) { - return builder.create({ - methodResponse: { - fault: { - value: { - struct: { - member: [ - { - name: 'faultCode', - value: { - int: faultCode - } - }, - { - name: 'faultString', - value: { - string: faultString - } - } - ] - } - } - } - } - }).end({'pretty': true}); -} - -module.exports = rpcReturnFault; diff --git a/services/rpc-return-success.js b/services/rpc-return-success.js deleted file mode 100644 index 2951ee3..0000000 --- a/services/rpc-return-success.js +++ /dev/null @@ -1,19 +0,0 @@ -const builder = require('xmlbuilder'); - -function rpcReturnSuccess(success) { - return builder.create({ - methodResponse: { - params: { - param: [ - { - value: { - boolean: success ? 1 : 0 - } - } - ] - } - } - }).end({'pretty': true}); -} - -module.exports = rpcReturnSuccess; diff --git a/test/ping.js b/test/ping.js deleted file mode 100644 index 69cda65..0000000 --- a/test/ping.js +++ /dev/null @@ -1,528 +0,0 @@ -const chai = require('chai'), - chaiHttp = require('chai-http'), - chaiXml = require('chai-xml'), - config = require('../config'), - expect = chai.expect, - getDayjs = require('../services/dayjs-wrapper'), - SERVER_URL = process.env.APP_URL || 'http://localhost:5337', - mock = require('./mock'), - storeApi = require('./store-api'), - xmlrpcBuilder = require('./xmlrpc-builder'), - rpcReturnSuccess = require('../services/rpc-return-success'), - rpcReturnFault = require('../services/rpc-return-fault'); - -chai.use(chaiHttp); -chai.use(chaiXml); - -function ping(pingProtocol, resourceUrl, returnFormat) { - if ('XML-RPC' === pingProtocol) { - const rpctext = xmlrpcBuilder.buildPingCall(resourceUrl); - - return chai - .request(SERVER_URL) - .post('/RPC2') - .set('content-type', 'text/xml') - .send(rpctext); - } else { - let req = chai - .request(SERVER_URL) - .post('/ping') - .set('content-type', 'application/x-www-form-urlencoded'); - - if ('JSON' === returnFormat) { - req.set('accept', 'application/json'); - } - - if (null == resourceUrl) { - return req.send({}); - } else { - return req.send({ url: resourceUrl }); - } - } -} - -for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { - for (const returnFormat of ['XML', 'JSON']) { - for (const pingProtocol of ['XML-RPC', 'REST']) { - - if ('XML-RPC' === pingProtocol && 'JSON' === returnFormat) { - // Not Applicable - continue; - } - - describe(`Ping ${pingProtocol} to ${protocol} returning ${returnFormat}`, function() { - before(async function() { - await mock.before(); - }); - - after(async function() { - await mock.after(); - }); - - beforeEach(async function() { - await storeApi.beforeEach(); - await mock.beforeEach(); - }); - - afterEach(async function() { - await storeApi.afterEach(); - await mock.afterEach(); - }); - - it('should accept a ping for new resource', async function() { - const feedPath = '/rss.xml', - pingPath = '/feedupdated', - resourceUrl = mock.serverUrl + feedPath; - - let apiurl = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath, - notifyProcedure = false; - - if ('xml-rpc' === protocol) { - apiurl = mock.serverUrl + '/RPC2'; - notifyProcedure = 'river.feedUpdated'; - } - - mock.route('GET', feedPath, 200, ''); - mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); - mock.rpc(notifyProcedure, rpcReturnSuccess(true)); - await storeApi.addSubscription(resourceUrl, notifyProcedure, apiurl, protocol); - - let res = await ping(pingProtocol, resourceUrl, returnFormat); - - expect(res).status(200); - - if ('XML-RPC' === pingProtocol) { - expect(res.text).xml.equal(rpcReturnSuccess(true)); - } else { - if ('JSON' === returnFormat) { - expect(res.body).deep.equal({ success: true, msg: 'Thanks for the ping.' }); - } else { - expect(res.text).xml.equal(''); - } - } - - expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); - - if ('xml-rpc' === protocol) { - expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(1, `Missing XML-RPC call ${notifyProcedure}`); - expect(mock.requests.RPC2[notifyProcedure][0]).property('rpcBody'); - expect(mock.requests.RPC2[notifyProcedure][0].rpcBody.params[0]).equal(resourceUrl); - } else { - expect(mock.requests.POST).property(pingPath).lengthOf(1, `Missing POST ${pingPath}`); - expect(mock.requests.POST[pingPath][0]).property('body'); - expect(mock.requests.POST[pingPath][0].body).property('url'); - expect(mock.requests.POST[pingPath][0].body.url).equal(resourceUrl); - } - }); - - it('should ping multiple subscribers on same domain', async function() { - const feedPath = '/rss.xml', - pingPath1 = '/feedupdated1', - pingPath2 = '/feedupdated2', - resourceUrl = mock.serverUrl + feedPath; - - let apiurl1 = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath1, - apiurl2 = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath2, - notifyProcedure = false; - - if ('xml-rpc' === protocol) { - apiurl1 = mock.serverUrl + '/RPC2'; - apiurl2 = mock.serverUrl + pingPath2; - notifyProcedure = 'river.feedUpdated'; - } - - mock.route('GET', feedPath, 200, ''); - mock.route('POST', pingPath1, 200, 'Thanks for the update! :-)'); - mock.route('POST', pingPath2, 200, 'Thanks for the update! :-)'); - mock.rpc(notifyProcedure, rpcReturnSuccess(true)); - await storeApi.addSubscription(resourceUrl, notifyProcedure, apiurl1, protocol); - await storeApi.addSubscription(resourceUrl, false, apiurl2, 'http-post'); - - let res = await ping(pingProtocol, resourceUrl, returnFormat); - - expect(res).status(200); - - if ('XML-RPC' === pingProtocol) { - expect(res.text).xml.equal(rpcReturnSuccess(true)); - } else { - if ('JSON' === returnFormat) { - expect(res.body).deep.equal({ success: true, msg: 'Thanks for the ping.' }); - } else { - expect(res.text).xml.equal(''); - } - } - - expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); - - if ('xml-rpc' === protocol) { - expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(1, `Missing XML-RPC call ${notifyProcedure}`); - expect(mock.requests.RPC2[notifyProcedure][0]).property('rpcBody'); - expect(mock.requests.RPC2[notifyProcedure][0].rpcBody.params[0]).equal(resourceUrl); - } else { - expect(mock.requests.POST).property(pingPath1).lengthOf(1, `Missing POST ${pingPath1}`); - expect(mock.requests.POST[pingPath1][0]).property('body'); - expect(mock.requests.POST[pingPath1][0].body).property('url'); - expect(mock.requests.POST[pingPath1][0].body.url).equal(resourceUrl); - } - - expect(mock.requests.POST).property(pingPath2).lengthOf(1, `Missing POST ${pingPath2}`); - expect(mock.requests.POST[pingPath2][0]).property('body'); - expect(mock.requests.POST[pingPath2][0].body).property('url'); - expect(mock.requests.POST[pingPath2][0].body.url).equal(resourceUrl); - }); - - it('should reject a ping for bad resource', async function() { - const feedPath = '/rss.xml', - pingPath = '/feedupdated', - resourceUrl = mock.serverUrl + feedPath; - - let apiurl = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath, - notifyProcedure = false; - - if ('xml-rpc' === protocol) { - apiurl = mock.serverUrl + '/RPC2'; - notifyProcedure = 'river.feedUpdated'; - } - - mock.route('GET', feedPath, 404, 'Not Found'); - mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); - mock.rpc(notifyProcedure, rpcReturnSuccess(true)); - await storeApi.addSubscription(resourceUrl, notifyProcedure, apiurl, protocol); - - let res = await ping(pingProtocol, resourceUrl, returnFormat); - - expect(res).status(200); - - if ('XML-RPC' === pingProtocol) { - expect(res.text).xml.equal(rpcReturnSuccess(true)); - } else { - if ('JSON' === returnFormat) { - expect(res.body).deep.equal({ success: false, msg: `The ping was cancelled because there was an error reading the resource at URL ${resourceUrl}.` }); - } else { - expect(res.text).xml.equal(``); - } - } - - expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); - - if ('xml-rpc' === protocol) { - expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(0, `Should not XML-RPC call ${notifyProcedure}`); - } else { - expect(mock.requests.POST).property(pingPath).lengthOf(0, `Should not POST ${pingPath}`); - } - }); - - it('should reject a ping with a missing url', async function() { - const feedPath = '/rss.xml', - pingPath = '/feedupdated', - resourceUrl = null; - - let notifyProcedure = false; - - if ('xml-rpc' === protocol) { - notifyProcedure = 'river.feedUpdated'; - } - - mock.route('GET', feedPath, 404, 'Not Found'); - mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); - mock.rpc(notifyProcedure, rpcReturnSuccess(true)); - - let res = await ping(pingProtocol, resourceUrl, returnFormat); - - expect(res).status(200); - - if ('XML-RPC' === pingProtocol) { - expect(res.text).xml.equal(rpcReturnFault(4, 'Can\'t call "ping" because there aren\'t enough parameters.')); - } else { - if ('JSON' === returnFormat) { - expect(res.body).deep.equal({ success: false, msg: 'The following parameters were missing from the request body: url.' }); - } else { - expect(res.text).xml.equal(''); - } - } - - expect(mock.requests.GET).property(feedPath).lengthOf(0, `Should not GET ${feedPath}`); - - if ('xml-rpc' === protocol) { - expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(0, `Should not XML-RPC call ${notifyProcedure}`); - } else { - expect(mock.requests.POST).property(pingPath).lengthOf(0, `Should not POST ${pingPath}`); - } - }); - - it('should accept a ping for unchanged resource', async function() { - const feedPath = '/rss.xml', - pingPath = '/feedupdated', - resourceUrl = mock.serverUrl + feedPath; - - let apiurl = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath, - notifyProcedure = false; - - if ('xml-rpc' === protocol) { - apiurl = mock.serverUrl + '/RPC2'; - notifyProcedure = 'river.feedUpdated'; - } - - mock.route('GET', feedPath, 200, ''); - mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); - mock.rpc(notifyProcedure, rpcReturnSuccess(true)); - await storeApi.addSubscription(resourceUrl, notifyProcedure, apiurl, protocol); - - let res = await ping(pingProtocol, resourceUrl, returnFormat); - - expect(res).status(200); - - if ('XML-RPC' === pingProtocol) { - expect(res.text).xml.equal(rpcReturnSuccess(true)); - } else { - if ('JSON' === returnFormat) { - expect(res.body).deep.equal({ success: true, msg: 'Thanks for the ping.' }); - } else { - expect(res.text).xml.equal(''); - } - } - - res = await ping(pingProtocol, resourceUrl, returnFormat); - - expect(res).status(200); - - if ('XML-RPC' === pingProtocol) { - expect(res.text).xml.equal(rpcReturnSuccess(true)); - } else { - if ('JSON' === returnFormat) { - expect(res.body).deep.equal({ success: true, msg: 'Thanks for the ping.' }); - } else { - expect(res.text).xml.equal(''); - } - } - - expect(mock.requests.GET).property(feedPath).lengthOf(2, `Missing GET ${feedPath}`); - - if ('xml-rpc' === protocol) { - expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(1, `Should only XML-RPC call ${notifyProcedure} once`); - } else { - expect(mock.requests.POST).property(pingPath).lengthOf(1, `Should only POST ${pingPath} once`); - } - }); - - it('should accept a ping with slow subscribers', async function() { - this.timeout(5000); - - const feedPath = '/rss.xml', - pingPath = '/feedupdated', - resourceUrl = mock.serverUrl + feedPath; - - let apiurl = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath, - notifyProcedure = false; - - if ('xml-rpc' === protocol) { - apiurl = mock.serverUrl + '/RPC2'; - notifyProcedure = 'river.feedUpdated'; - } - - function slowPostResponse(_req) { - return new Promise(function(resolve) { - global.setTimeout(function() { - resolve('Thanks for the update! :-)'); - }, 1000); - }); - } - - mock.route('GET', feedPath, 200, ''); - if ('xml-rpc' === protocol) { - mock.rpc(notifyProcedure, rpcReturnSuccess(true)); - await storeApi.addSubscription(resourceUrl, notifyProcedure, apiurl, protocol); - } else { - for (let i = 0; i < 10; i++) { - mock.route('POST', pingPath + i, 200, slowPostResponse); - await storeApi.addSubscription(resourceUrl, notifyProcedure, apiurl + i, protocol); - } - } - - let res = await ping(pingProtocol, resourceUrl, returnFormat); - - expect(res).status(200); - - if ('XML-RPC' === pingProtocol) { - expect(res.text).xml.equal(rpcReturnSuccess(true)); - } else { - if ('JSON' === returnFormat) { - expect(res.body).deep.equal({ success: true, msg: 'Thanks for the ping.' }); - } else { - expect(res.text).xml.equal(''); - } - } - - expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); - - if ('xml-rpc' === protocol) { - expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(1, `Missing XML-RPC call ${notifyProcedure}`); - expect(mock.requests.RPC2[notifyProcedure][0]).property('rpcBody'); - expect(mock.requests.RPC2[notifyProcedure][0].rpcBody.params[0]).equal(resourceUrl); - } else { - for (let i = 0; i < 10; i++) { - expect(mock.requests.POST).property(pingPath + i).lengthOf(1, `Missing POST ${pingPath + i}`); - expect(mock.requests.POST[pingPath + i][0]).property('body'); - expect(mock.requests.POST[pingPath + i][0].body).property('url'); - expect(mock.requests.POST[pingPath + i][0].body.url).equal(resourceUrl); - } - } - }); - - it('should not notify expired subscribers', async function() { - const feedPath = '/rss.xml', - pingPath = '/feedupdated', - resourceUrl = mock.serverUrl + feedPath; - - let apiurl = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath, - notifyProcedure = false; - - if ('xml-rpc' === protocol) { - apiurl = mock.serverUrl + '/RPC2'; - notifyProcedure = 'river.feedUpdated'; - } - - mock.route('GET', feedPath, 200, ''); - mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); - mock.rpc(notifyProcedure, rpcReturnSuccess(true)); - const dayjs = await getDayjs(); - const subscription = await storeApi.addSubscription(resourceUrl, notifyProcedure, apiurl, protocol); - subscription.whenExpires = dayjs().utc().subtract(config.ctSecsResourceExpire * 2, 'seconds').format(); - await storeApi.updateSubscription(resourceUrl, subscription); - - let res = await ping(pingProtocol, resourceUrl, returnFormat); - - expect(res).status(200); - - if ('XML-RPC' === pingProtocol) { - expect(res.text).xml.equal(rpcReturnSuccess(true)); - } else { - if ('JSON' === returnFormat) { - expect(res.body).deep.equal({ success: true, msg: 'Thanks for the ping.' }); - } else { - expect(res.text).xml.equal(''); - } - } - - expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); - - if ('xml-rpc' === protocol) { - expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(0, `Missing XML-RPC call ${notifyProcedure}`); - } else { - expect(mock.requests.POST).property(pingPath).lengthOf(0, `Missing POST ${pingPath}`); - } - }); - - it('should not notify subscribers with excessive errors', async function() { - const feedPath = '/rss.xml', - pingPath = '/feedupdated', - resourceUrl = mock.serverUrl + feedPath; - - let apiurl = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath, - notifyProcedure = false; - - if ('xml-rpc' === protocol) { - apiurl = mock.serverUrl + '/RPC2'; - notifyProcedure = 'river.feedUpdated'; - } - - mock.route('GET', feedPath, 200, ''); - mock.route('POST', pingPath, 200, 'Thanks for the update! :-)'); - mock.rpc(notifyProcedure, rpcReturnSuccess(true)); - const subscription = await storeApi.addSubscription(resourceUrl, notifyProcedure, apiurl, protocol); - subscription.ctConsecutiveErrors = config.maxConsecutiveErrors; - await storeApi.updateSubscription(resourceUrl, subscription); - - let res = await ping(pingProtocol, resourceUrl, returnFormat); - - expect(res).status(200); - - if ('XML-RPC' === pingProtocol) { - expect(res.text).xml.equal(rpcReturnSuccess(true)); - } else { - if ('JSON' === returnFormat) { - expect(res.body).deep.equal({ success: true, msg: 'Thanks for the ping.' }); - } else { - expect(res.text).xml.equal(''); - } - } - - expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); - - if ('xml-rpc' === protocol) { - expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(0, `Missing XML-RPC call ${notifyProcedure}`); - } else { - expect(mock.requests.POST).property(pingPath).lengthOf(0, `Missing POST ${pingPath}`); - } - }); - - it('should consider a very slow subscription an error', async function() { - const feedPath = '/rss.xml', - pingPath = '/feedupdated', - resourceUrl = mock.serverUrl + feedPath; - - let apiurl = ('http-post' === protocol ? mock.serverUrl : mock.secureServerUrl) + pingPath, - notifyProcedure = false; - - if ('xml-rpc' === protocol) { - apiurl = mock.serverUrl + '/RPC2'; - notifyProcedure = 'river.feedUpdated'; - } - - function slowRestResponse(_req) { - return new Promise((resolve) => { - global.setTimeout(() => { - resolve('Thanks for the update! :-)'); - }, 8000); - }); - } - - function slowRpcResponse(_req) { - return new Promise((resolve) => { - global.setTimeout(() => { - resolve(rpcReturnSuccess(true)); - }, 8000); - }); - } - - mock.route('GET', feedPath, 200, ''); - mock.route('POST', pingPath, 200, slowRestResponse); - mock.rpc(notifyProcedure, slowRpcResponse); - await storeApi.addSubscription(resourceUrl, notifyProcedure, apiurl, protocol); - - let res = await ping(pingProtocol, resourceUrl, returnFormat); - - expect(res).status(200); - - const subscription = await storeApi.addSubscription(resourceUrl, notifyProcedure, apiurl, protocol); - expect(subscription.ctConsecutiveErrors).equal(1); - - if ('XML-RPC' === pingProtocol) { - expect(res.text).xml.equal(rpcReturnSuccess(true)); - } else { - if ('JSON' === returnFormat) { - expect(res.body).deep.equal({ success: true, msg: 'Thanks for the ping.' }); - } else { - expect(res.text).xml.equal(''); - } - } - - expect(mock.requests.GET).property(feedPath).lengthOf(1, `Missing GET ${feedPath}`); - - if ('xml-rpc' === protocol) { - expect(mock.requests.RPC2).property(notifyProcedure).lengthOf(1, `Missing XML-RPC call ${notifyProcedure}`); - expect(mock.requests.RPC2[notifyProcedure][0]).property('rpcBody'); - expect(mock.requests.RPC2[notifyProcedure][0].rpcBody.params[0]).equal(resourceUrl); - } else { - expect(mock.requests.POST).property(pingPath).lengthOf(1, `Missing POST ${pingPath}`); - expect(mock.requests.POST[pingPath][0]).property('body'); - expect(mock.requests.POST[pingPath][0].body).property('url'); - expect(mock.requests.POST[pingPath][0].body.url).equal(resourceUrl); - } - }); - - }); - - } // end for pingProtocol - } // end for returnFormat -} // end for protocol diff --git a/test/static.js b/test/static.js deleted file mode 100644 index 2b1fee0..0000000 --- a/test/static.js +++ /dev/null @@ -1,50 +0,0 @@ -const chai = require('chai'), - chaiHttp = require('chai-http'), - expect = chai.expect, - SERVER_URL = process.env.APP_URL || 'http://localhost:5337'; - -chai.use(chaiHttp); - -describe('Static Pages', function() { - - it('docs should return 200', async function() { - let res = await chai - .request(SERVER_URL) - .get('/docs'); - - expect(res).status(200); - }); - - it('home should return 200', async function() { - let res = await chai - .request(SERVER_URL) - .get('/'); - - expect(res).status(200); - }); - - it('pingForm should return 200', async function() { - let res = await chai - .request(SERVER_URL) - .get('/pingForm'); - - expect(res).status(200); - }); - - it('pleaseNotifyForm should return 200', async function() { - let res = await chai - .request(SERVER_URL) - .get('/pleaseNotifyForm'); - - expect(res).status(200); - }); - - it('viewLog should return 200', async function() { - let res = await chai - .request(SERVER_URL) - .get('/viewLog'); - - expect(res).status(200); - }); - -}); diff --git a/views/docs.handlebars b/views/docs.handlebars deleted file mode 100644 index 5b3d99a..0000000 --- a/views/docs.handlebars +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - {{#if title}}{{title}}{{else}}rssCloud Server: Documentation{{/if}} - - - - {{#if heading}}

{{heading}}

{{/if}} - {{{htmltext}}} - -

- ← Back to Home -

- - diff --git a/views/home.handlebars b/views/home.handlebars deleted file mode 100644 index 60e7db7..0000000 --- a/views/home.handlebars +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - rssCloud Server - - - -

rssCloud Server

-

A notification protocol server that allows RSS feeds to notify subscribers when they are updated.

- -

Available Pages

- - - diff --git a/views/layouts/main.handlebars b/views/layouts/main.handlebars deleted file mode 100644 index 38e5499..0000000 --- a/views/layouts/main.handlebars +++ /dev/null @@ -1 +0,0 @@ -{{{body}}} diff --git a/views/ping-form.handlebars b/views/ping-form.handlebars deleted file mode 100644 index 0f58ad0..0000000 --- a/views/ping-form.handlebars +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - rssCloud Server: Ping - - - -

rssCloud Server: Ping

-

Posting to /ping is your way of alerting the server that a resource has been updated.

- -
- - - -
- -

- ← Back to Home -

- - diff --git a/views/please-notify-form.handlebars b/views/please-notify-form.handlebars deleted file mode 100644 index d75ba5d..0000000 --- a/views/please-notify-form.handlebars +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - rssCloud Server: Please Notify - - - -

rssCloud Server: Please Notify

-

Posting to /pleaseNotify is your way of alerting the server that you want to receive notifications when one or more resources are updated.

- -
- - - - - - - - - - - - - - - - - - - -
- -

- ← Back to Home -

- - diff --git a/views/view-log.handlebars b/views/view-log.handlebars deleted file mode 100644 index 6e4ac66..0000000 --- a/views/view-log.handlebars +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - rssCloud Server: Log - - - -

rssCloud Server: Log

-

Real-time events on this rssCloud server.

- - - -
- - -
-

Feed from {{wsUrl}}

- -

- ← Back to Home -

- - From d994a3e06f92f1dc127a6970ad8da3bbcc8aad15 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sun, 24 May 2026 13:46:47 -0500 Subject: [PATCH 02/90] chore(core): scaffold @rsscloud/core package Set up a new TypeScript library package at packages/core/ that will eventually receive logic migrated from apps/server. Configures dual ESM/CJS output via tsup, unit tests via vitest with 100% coverage thresholds, and registers the package with release-please at 0.0.0. Also updates pnpm-workspace.yaml to include packages/*, fans out lint/build/typecheck across the workspace, and ignores dist/coverage in git/prettier/docker contexts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .dockerignore | 2 + .gitignore | 2 + .prettierignore | 1 + .release-please-manifest.json | 3 +- package.json | 10 +- packages/core/LICENSE.md | 20 + packages/core/README.md | 23 + packages/core/eslint.config.mjs | 14 + packages/core/package.json | 69 + packages/core/src/index.test.ts | 8 + packages/core/src/index.ts | 1 + packages/core/tsconfig.build.json | 8 + packages/core/tsconfig.json | 27 + packages/core/tsup.config.ts | 13 + packages/core/vitest.config.ts | 19 + pnpm-lock.yaml | 8757 ++++++++++++++++------------- pnpm-workspace.yaml | 1 + release-please-config.json | 6 + 18 files changed, 4989 insertions(+), 3995 deletions(-) create mode 100644 packages/core/LICENSE.md create mode 100644 packages/core/README.md create mode 100644 packages/core/eslint.config.mjs create mode 100644 packages/core/package.json create mode 100644 packages/core/src/index.test.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/tsconfig.build.json create mode 100644 packages/core/tsconfig.json create mode 100644 packages/core/tsup.config.ts create mode 100644 packages/core/vitest.config.ts diff --git a/.dockerignore b/.dockerignore index 9e8e620..769ecb5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,8 @@ .circleci **/node_modules **/data +packages/*/dist +packages/*/coverage xunit .nyc_output .env diff --git a/.gitignore b/.gitignore index 92f0fdb..68288f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ .DS_Store .env .nyc_output/ +coverage/ data/ +dist/ node_modules/ xunit/ Procfile diff --git a/.prettierignore b/.prettierignore index 1091895..a6e41a4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,6 +5,7 @@ package-lock.json # Build output dist/ build/ +coverage/ # Third-party libraries public/js/ diff --git a/.release-please-manifest.json b/.release-please-manifest.json index eb37449..48f6c97 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,4 @@ { - "apps/server": "4.0.0" + "apps/server": "4.0.0", + "packages/core": "0.0.0" } diff --git a/package.json b/package.json index b0f5553..1f8f683 100644 --- a/package.json +++ b/package.json @@ -6,16 +6,22 @@ "scripts": { "start": "pnpm --filter @rsscloud/server run dev", "client": "pnpm --filter @rsscloud/server run client", - "lint": "pnpm --filter @rsscloud/server run lint", + "build": "pnpm -r --if-present run build", + "lint": "pnpm -r --if-present run lint", + "typecheck": "pnpm -r --if-present run typecheck", "format": "prettier --write .", "test": "docker-compose up --build --abort-on-container-exit --attach rsscloud-tests --no-log-prefix", + "test:core": "pnpm --filter @rsscloud/core run test", "prepare": "husky" }, "packageManager": "pnpm@10.11.0", "pnpm": { "overrides": { "serialize-javascript": ">=7.0.5" - } + }, + "onlyBuiltDependencies": [ + "esbuild" + ] }, "repository": { "type": "git", diff --git a/packages/core/LICENSE.md b/packages/core/LICENSE.md new file mode 100644 index 0000000..d81b273 --- /dev/null +++ b/packages/core/LICENSE.md @@ -0,0 +1,20 @@ +Copyright (c) 2015-2026 Andrew Shell + +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. diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..353bc43 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,23 @@ +# @rsscloud/core + +Core primitives for [rssCloud](https://github.com/rsscloud/rsscloud-server) — subscriptions, notifications, and feed-update processing. + +> **Status:** Early scaffolding. Logic is being migrated from `@rsscloud/server`. + +## Install + +```bash +pnpm add @rsscloud/core +``` + +## Usage + +```ts +import { version } from '@rsscloud/core'; + +console.log(version); +``` + +## License + +MIT — see [LICENSE.md](./LICENSE.md). diff --git a/packages/core/eslint.config.mjs b/packages/core/eslint.config.mjs new file mode 100644 index 0000000..04a8101 --- /dev/null +++ b/packages/core/eslint.config.mjs @@ -0,0 +1,14 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist', 'coverage'] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + languageOptions: { + ecmaVersion: 2023, + sourceType: 'module' + } + } +); diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..5933eaa --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,69 @@ +{ + "name": "@rsscloud/core", + "version": "0.0.0", + "description": "Core primitives for rssCloud — subscriptions, notifications, and feed processing", + "license": "MIT", + "author": "Andrew Shell ", + "repository": { + "type": "git", + "url": "https://github.com/rsscloud/rsscloud-server.git", + "directory": "packages/core" + }, + "homepage": "https://github.com/rsscloud/rsscloud-server/tree/main/packages/core#readme", + "bugs": { + "url": "https://github.com/rsscloud/rsscloud-server/issues" + }, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "LICENSE.md", + "CHANGELOG.md" + ], + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=22" + }, + "sideEffects": false, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "clean": "rm -rf dist coverage", + "lint": "eslint src", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "coverage": "vitest run --coverage", + "check": "pnpm run typecheck && pnpm run lint && pnpm run test", + "prepack": "pnpm run build", + "prepublishOnly": "pnpm run build" + }, + "devDependencies": { + "@eslint/js": "^9.18.0", + "@types/node": "^22.10.0", + "@vitest/coverage-v8": "^2.1.8", + "eslint": "^9.18.0", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "typescript-eslint": "^8.20.0", + "vitest": "^2.1.8" + } +} diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts new file mode 100644 index 0000000..4b85c47 --- /dev/null +++ b/packages/core/src/index.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest'; +import { version } from './index.js'; + +describe('version', () => { + it('exposes a semver string', () => { + expect(version).toBe('0.0.0'); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..5e29e9c --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1 @@ +export const version = '0.0.0'; diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 0000000..9d88b88 --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "coverage", "node_modules", "src/**/*.test.ts"] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..47eb797 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "moduleDetection": "force", + "verbatimModuleSyntax": true, + "isolatedModules": true, + "resolveJsonModule": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts", "*.config.ts"], + "exclude": ["dist", "coverage", "node_modules"] +} diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts new file mode 100644 index 0000000..0f29864 --- /dev/null +++ b/packages/core/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + treeshake: true, + splitting: false, + target: 'node22', + tsconfig: './tsconfig.build.json' +}); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..f571462 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts'], + thresholds: { + lines: 100, + functions: 100, + branches: 100, + statements: 100 + } + } + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3a6348..8db3c63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4223 +1,4996 @@ lockfileVersion: '9.0' settings: - autoInstallPeers: true - excludeLinksFromLockfile: false + autoInstallPeers: true + excludeLinksFromLockfile: false overrides: - serialize-javascript: '>=7.0.5' + serialize-javascript: '>=7.0.5' importers: - .: - devDependencies: - '@commitlint/cli': - specifier: ^20.5.3 - version: 20.5.3(@types/node@25.8.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3) - '@commitlint/config-conventional': - specifier: ^20.5.3 - version: 20.5.3 - husky: - specifier: ^9.1.7 - version: 9.1.7 - prettier: - specifier: ^3.8.3 - version: 3.8.3 - - apps/server: - dependencies: - body-parser: - specifier: ^2.2.2 - version: 2.2.2 - cors: - specifier: ^2.8.6 - version: 2.8.6 - dayjs: - specifier: ^1.11.20 - version: 1.11.20 - dotenv: - specifier: ^17.4.2 - version: 17.4.2 - express: - specifier: ^4.22.2 - version: 4.22.2 - express-handlebars: - specifier: ^5.3.5 - version: 5.3.5 - markdown-it: - specifier: ^14.1.1 - version: 14.1.1 - morgan: - specifier: ^1.10.1 - version: 1.10.1 - ws: - specifier: ^8.20.1 - version: 8.20.1 - xml2js: - specifier: ^0.6.2 - version: 0.6.2 - xmlbuilder: - specifier: ^15.1.1 - version: 15.1.1 - devDependencies: - '@eslint/js': - specifier: ^10.0.1 - version: 10.0.1(eslint@10.4.0(jiti@2.6.1)) - chai: - specifier: ^4.5.0 - version: 4.5.0 - chai-http: - specifier: ^4.4.0 - version: 4.4.0 - chai-json: - specifier: ^1.0.0 - version: 1.0.0 - chai-xml: - specifier: ^0.4.1 - version: 0.4.1(chai@4.5.0) - eslint: - specifier: ^10.4.0 - version: 10.4.0(jiti@2.6.1) - https: - specifier: ^1.0.0 - version: 1.0.0 - mocha: - specifier: ^11.7.5 - version: 11.7.5 - mocha-multi: - specifier: ^1.1.7 - version: 1.1.7(mocha@11.7.5) - nodemon: - specifier: 3.1.14 - version: 3.1.14 - supertest: - specifier: ^7.2.2 - version: 7.2.2 + + .: + devDependencies: + '@commitlint/cli': + specifier: ^20.5.3 + version: 20.5.3(@types/node@25.8.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3) + '@commitlint/config-conventional': + specifier: ^20.5.3 + version: 20.5.3 + husky: + specifier: ^9.1.7 + version: 9.1.7 + prettier: + specifier: ^3.8.3 + version: 3.8.3 + + apps/server: + dependencies: + body-parser: + specifier: ^2.2.2 + version: 2.2.2 + cors: + specifier: ^2.8.6 + version: 2.8.6 + dayjs: + specifier: ^1.11.20 + version: 1.11.20 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + express: + specifier: ^4.22.2 + version: 4.22.2 + express-handlebars: + specifier: ^5.3.5 + version: 5.3.5 + markdown-it: + specifier: ^14.1.1 + version: 14.1.1 + morgan: + specifier: ^1.10.1 + version: 1.10.1 + ws: + specifier: ^8.20.1 + version: 8.20.1 + xml2js: + specifier: ^0.6.2 + version: 0.6.2 + xmlbuilder: + specifier: ^15.1.1 + version: 15.1.1 + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.4.0(jiti@2.6.1)) + chai: + specifier: ^4.5.0 + version: 4.5.0 + chai-http: + specifier: ^4.4.0 + version: 4.4.0 + chai-json: + specifier: ^1.0.0 + version: 1.0.0 + chai-xml: + specifier: ^0.4.1 + version: 0.4.1(chai@4.5.0) + eslint: + specifier: ^10.4.0 + version: 10.4.0(jiti@2.6.1) + https: + specifier: ^1.0.0 + version: 1.0.0 + mocha: + specifier: ^11.7.5 + version: 11.7.5 + mocha-multi: + specifier: ^1.1.7 + version: 1.1.7(mocha@11.7.5) + nodemon: + specifier: 3.1.14 + version: 3.1.14 + supertest: + specifier: ^7.2.2 + version: 7.2.2 + + packages/core: + devDependencies: + '@eslint/js': + specifier: ^9.18.0 + version: 9.39.4 + '@types/node': + specifier: ^22.10.0 + version: 22.19.19 + '@vitest/coverage-v8': + specifier: ^2.1.8 + version: 2.1.9(vitest@2.1.9(@types/node@22.19.19)) + eslint: + specifier: ^9.18.0 + version: 9.39.4(jiti@2.6.1) + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.15)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + typescript-eslint: + specifier: ^8.20.0 + version: 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.19) packages: - '@babel/code-frame@7.29.0': - resolution: - { - integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== - } - engines: { node: '>=6.9.0' } - - '@babel/helper-validator-identifier@7.28.5': - resolution: - { - integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== - } - engines: { node: '>=6.9.0' } - - '@commitlint/cli@20.5.3': - resolution: - { - integrity: sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA== - } - engines: { node: '>=v18' } - hasBin: true - - '@commitlint/config-conventional@20.5.3': - resolution: - { - integrity: sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ== - } - engines: { node: '>=v18' } - - '@commitlint/config-validator@20.5.0': - resolution: - { - integrity: sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw== - } - engines: { node: '>=v18' } - - '@commitlint/ensure@20.5.3': - resolution: - { - integrity: sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw== - } - engines: { node: '>=v18' } - - '@commitlint/execute-rule@20.0.0': - resolution: - { - integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw== - } - engines: { node: '>=v18' } - - '@commitlint/format@20.5.0': - resolution: - { - integrity: sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q== - } - engines: { node: '>=v18' } - - '@commitlint/is-ignored@20.5.0': - resolution: - { - integrity: sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg== - } - engines: { node: '>=v18' } - - '@commitlint/lint@20.5.3': - resolution: - { - integrity: sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw== - } - engines: { node: '>=v18' } - - '@commitlint/load@20.5.3': - resolution: - { - integrity: sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ== - } - engines: { node: '>=v18' } - - '@commitlint/message@20.4.3': - resolution: - { - integrity: sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ== - } - engines: { node: '>=v18' } - - '@commitlint/parse@20.5.0': - resolution: - { - integrity: sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA== - } - engines: { node: '>=v18' } - - '@commitlint/read@20.5.0': - resolution: - { - integrity: sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w== - } - engines: { node: '>=v18' } - - '@commitlint/resolve-extends@20.5.3': - resolution: - { - integrity: sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew== - } - engines: { node: '>=v18' } - - '@commitlint/rules@20.5.3': - resolution: - { - integrity: sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ== - } - engines: { node: '>=v18' } - - '@commitlint/to-lines@20.0.0': - resolution: - { - integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw== - } - engines: { node: '>=v18' } - - '@commitlint/top-level@20.4.3': - resolution: - { - integrity: sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ== - } - engines: { node: '>=v18' } - - '@commitlint/types@20.5.0': - resolution: - { - integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA== - } - engines: { node: '>=v18' } - - '@conventional-changelog/git-client@2.7.0': - resolution: - { - integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw== - } - engines: { node: '>=18' } - peerDependencies: - conventional-commits-filter: ^5.0.0 - conventional-commits-parser: ^6.4.0 - peerDependenciesMeta: - conventional-commits-filter: - optional: true - conventional-commits-parser: - optional: true - - '@eslint-community/eslint-utils@4.9.1': - resolution: - { - integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== - } - engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: - { - integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== - } - engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 } - - '@eslint/config-array@0.23.5': - resolution: - { - integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA== - } - engines: { node: ^20.19.0 || ^22.13.0 || >=24 } - - '@eslint/config-helpers@0.6.0': - resolution: - { - integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA== - } - engines: { node: ^20.19.0 || ^22.13.0 || >=24 } - - '@eslint/core@1.2.1': - resolution: - { - integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ== - } - engines: { node: ^20.19.0 || ^22.13.0 || >=24 } - - '@eslint/js@10.0.1': - resolution: - { - integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA== - } - engines: { node: ^20.19.0 || ^22.13.0 || >=24 } - peerDependencies: - eslint: ^10.0.0 - peerDependenciesMeta: - eslint: - optional: true - - '@eslint/object-schema@3.0.5': - resolution: - { - integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw== - } - engines: { node: ^20.19.0 || ^22.13.0 || >=24 } - - '@eslint/plugin-kit@0.7.1': - resolution: - { - integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ== - } - engines: { node: ^20.19.0 || ^22.13.0 || >=24 } - - '@humanfs/core@0.19.2': - resolution: - { - integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA== - } - engines: { node: '>=18.18.0' } - - '@humanfs/node@0.16.8': - resolution: - { - integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ== - } - engines: { node: '>=18.18.0' } - - '@humanfs/types@0.15.0': - resolution: - { - integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q== - } - engines: { node: '>=18.18.0' } - - '@humanwhocodes/module-importer@1.0.1': - resolution: - { - integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== - } - engines: { node: '>=12.22' } - - '@humanwhocodes/retry@0.4.3': - resolution: - { - integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== - } - engines: { node: '>=18.18' } - - '@isaacs/cliui@8.0.2': - resolution: - { - integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== - } - engines: { node: '>=12' } - - '@noble/hashes@1.8.0': - resolution: - { - integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== - } - engines: { node: ^14.21.3 || >=16 } - - '@paralleldrive/cuid2@2.3.1': - resolution: - { - integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw== - } - - '@pkgjs/parseargs@0.11.0': - resolution: - { - integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== - } - engines: { node: '>=14' } - - '@simple-libs/child-process-utils@1.0.2': - resolution: - { - integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw== - } - engines: { node: '>=18' } - - '@simple-libs/stream-utils@1.2.0': - resolution: - { - integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA== - } - engines: { node: '>=18' } - - '@types/chai@4.3.20': - resolution: - { - integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ== - } - - '@types/cookiejar@2.1.5': - resolution: - { - integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q== - } - - '@types/esrecurse@4.3.1': - resolution: - { - integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== - } - - '@types/estree@1.0.9': - resolution: - { - integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== - } - - '@types/json-schema@7.0.15': - resolution: - { - integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== - } - - '@types/node@25.8.0': - resolution: - { - integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ== - } - - '@types/superagent@4.1.13': - resolution: - { - integrity: sha512-YIGelp3ZyMiH0/A09PMAORO0EBGlF5xIKfDpK74wdYvWUs2o96b5CItJcWPdH409b7SAXIIG6p8NdU/4U2Maww== - } - - accepts@1.3.8: - resolution: - { - integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== - } - engines: { node: '>= 0.6' } - - acorn-jsx@5.3.2: - resolution: - { - integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== - } - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.16.0: - resolution: - { - integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== - } - engines: { node: '>=0.4.0' } - hasBin: true - - ajv@6.15.0: - resolution: - { - integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw== - } - - ajv@8.20.0: - resolution: - { - integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA== - } - - ansi-regex@5.0.1: - resolution: - { - integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - } - engines: { node: '>=8' } - - ansi-regex@6.2.2: - resolution: - { - integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== - } - engines: { node: '>=12' } - - ansi-styles@4.3.0: - resolution: - { - integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== - } - engines: { node: '>=8' } - - ansi-styles@6.2.3: - resolution: - { - integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== - } - engines: { node: '>=12' } - - anymatch@3.1.3: - resolution: - { - integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== - } - engines: { node: '>= 8' } - - argparse@2.0.1: - resolution: - { - integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== - } - - array-flatten@1.1.1: - resolution: - { - integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== - } - - array-ify@1.0.0: - resolution: - { - integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng== - } - - asap@2.0.6: - resolution: - { - integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== - } - - assertion-error@1.1.0: - resolution: - { - integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== - } - - asynckit@0.4.0: - resolution: - { - integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== - } - - balanced-match@1.0.2: - resolution: - { - integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== - } - - balanced-match@4.0.4: - resolution: - { - integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== - } - engines: { node: 18 || 20 || >=22 } - - basic-auth@2.0.1: - resolution: - { - integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg== - } - engines: { node: '>= 0.8' } - - binary-extensions@2.3.0: - resolution: - { - integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== - } - engines: { node: '>=8' } - - body-parser@1.20.5: - resolution: - { - integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA== - } - engines: { node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16 } - - body-parser@2.2.2: - resolution: - { - integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA== - } - engines: { node: '>=18' } - - brace-expansion@1.1.14: - resolution: - { - integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g== - } - - brace-expansion@2.1.0: - resolution: - { - integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w== - } - - brace-expansion@5.0.6: - resolution: - { - integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g== - } - engines: { node: 18 || 20 || >=22 } - - braces@3.0.3: - resolution: - { - integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== - } - engines: { node: '>=8' } - - browser-stdout@1.3.1: - resolution: - { - integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== - } - - bytes@3.1.2: - resolution: - { - integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== - } - engines: { node: '>= 0.8' } - - call-bind-apply-helpers@1.0.2: - resolution: - { - integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== - } - engines: { node: '>= 0.4' } - - call-bound@1.0.4: - resolution: - { - integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== - } - engines: { node: '>= 0.4' } - - callsites@3.1.0: - resolution: - { - integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== - } - engines: { node: '>=6' } - - camelcase@6.3.0: - resolution: - { - integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== - } - engines: { node: '>=10' } - - chai-http@4.4.0: - resolution: - { - integrity: sha512-uswN3rZpawlRaa5NiDUHcDZ3v2dw5QgLyAwnQ2tnVNuP7CwIsOFuYJ0xR1WiR7ymD4roBnJIzOUep7w9jQMFJA== - } - engines: { node: '>=10' } - - chai-json@1.0.0: - resolution: - { - integrity: sha512-p9eEYu3H2BkpU8PW8kqt0N6Ni6UG3LCCjlqHtZbrgvGRJYD4UnJuh704EFbGRxylzEPWsaOGhgXfdn0n1W3hhA== - } - - chai-xml@0.4.1: - resolution: - { - integrity: sha512-VUf5Ol4ifOAsgz+lN4tfWENgQtrKxHPWsmpL5wdbqQdkpblZkcDlaT2aFvsPQH219Yvl8vc4064yFErgBIn9bw== - } - engines: { node: '>= 0.8.0' } - peerDependencies: - chai: '>=1.10.0 ' - - chai@3.5.0: - resolution: - { - integrity: sha512-eRYY0vPS2a9zt5w5Z0aCeWbrXTEyvk7u/Xf71EzNObrjSCPgMm1Nku/D/u2tiqHBX5j40wWhj54YJLtgn8g55A== - } - engines: { node: '>= 0.4.0' } - - chai@4.5.0: - resolution: - { - integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw== - } - engines: { node: '>=4' } - - chalk@4.1.2: - resolution: - { - integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - } - engines: { node: '>=10' } - - charset@1.0.1: - resolution: - { - integrity: sha512-6dVyOOYjpfFcL1Y4qChrAoQLRHvj2ziyhcm0QJlhOcAhykL/k1kTUPbeo+87MNRTRdk2OIIsIXbuF3x2wi5EXg== - } - engines: { node: '>=4.0.0' } - - check-error@1.0.3: - resolution: - { - integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg== - } - - chokidar@3.6.0: - resolution: - { - integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== - } - engines: { node: '>= 8.10.0' } - - chokidar@4.0.3: - resolution: - { - integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== - } - engines: { node: '>= 14.16.0' } - - cliui@8.0.1: - resolution: - { - integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== - } - engines: { node: '>=12' } - - color-convert@2.0.1: - resolution: - { - integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== - } - engines: { node: '>=7.0.0' } - - color-name@1.1.4: - resolution: - { - integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - } - - combined-stream@1.0.8: - resolution: - { - integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== - } - engines: { node: '>= 0.8' } - - compare-func@2.0.0: - resolution: - { - integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA== - } - - component-emitter@1.3.1: - resolution: - { - integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ== - } - - concat-map@0.0.1: - resolution: - { - integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== - } - - content-disposition@0.5.4: - resolution: - { - integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== - } - engines: { node: '>= 0.6' } - - content-type@1.0.5: - resolution: - { - integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== - } - engines: { node: '>= 0.6' } - - content-type@2.0.0: - resolution: - { - integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ== - } - engines: { node: '>=18' } - - conventional-changelog-angular@8.3.1: - resolution: - { - integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg== - } - engines: { node: '>=18' } - - conventional-changelog-conventionalcommits@9.3.1: - resolution: - { - integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw== - } - engines: { node: '>=18' } - - conventional-commits-parser@6.4.0: - resolution: - { - integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw== - } - engines: { node: '>=18' } - hasBin: true - - cookie-signature@1.0.7: - resolution: - { - integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA== - } - - cookie-signature@1.2.2: - resolution: - { - integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== - } - engines: { node: '>=6.6.0' } - - cookie@0.7.2: - resolution: - { - integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== - } - engines: { node: '>= 0.6' } - - cookiejar@2.1.4: - resolution: - { - integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== - } - - cors@2.8.6: - resolution: - { - integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw== - } - engines: { node: '>= 0.10' } - - cosmiconfig-typescript-loader@6.3.0: - resolution: - { - integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA== - } - engines: { node: '>=v18' } - peerDependencies: - '@types/node': '*' - cosmiconfig: '>=9' - typescript: '>=5' - - cosmiconfig@9.0.1: - resolution: - { - integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ== - } - engines: { node: '>=14' } - peerDependencies: - typescript: '>=4.9.5' - peerDependenciesMeta: - typescript: - optional: true - - cross-spawn@7.0.6: - resolution: - { - integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== - } - engines: { node: '>= 8' } - - dayjs@1.11.20: - resolution: - { - integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ== - } - - debug@2.6.9: - resolution: - { - integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - } - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - debug@4.4.3: - resolution: - { - integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== - } - engines: { node: '>=6.0' } - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decamelize@4.0.0: - resolution: - { - integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== - } - engines: { node: '>=10' } - - deep-eql@0.1.3: - resolution: - { - integrity: sha512-6sEotTRGBFiNcqVoeHwnfopbSpi5NbH1VWJmYCVkmxMmaVTT0bUTrNaGyBwhgP4MZL012W/mkzIn3Da+iDYweg== - } - - deep-eql@4.1.4: - resolution: - { - integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg== - } - engines: { node: '>=6' } - - deep-is@0.1.4: - resolution: - { - integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== - } - - delayed-stream@1.0.0: - resolution: - { - integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== - } - engines: { node: '>=0.4.0' } - - depd@2.0.0: - resolution: - { - integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== - } - engines: { node: '>= 0.8' } - - destroy@1.2.0: - resolution: - { - integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== - } - engines: { node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16 } - - dezalgo@1.0.4: - resolution: - { - integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig== - } - - diff@7.0.0: - resolution: - { - integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw== - } - engines: { node: '>=0.3.1' } - - dot-prop@5.3.0: - resolution: - { - integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q== - } - engines: { node: '>=8' } - - dotenv@17.4.2: - resolution: - { - integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw== - } - engines: { node: '>=12' } - - dunder-proto@1.0.1: - resolution: - { - integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== - } - engines: { node: '>= 0.4' } - - eastasianwidth@0.2.0: - resolution: - { - integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== - } - - ee-first@1.1.1: - resolution: - { - integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== - } - - emoji-regex@8.0.0: - resolution: - { - integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - } - - emoji-regex@9.2.2: - resolution: - { - integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== - } - - encodeurl@2.0.0: - resolution: - { - integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== - } - engines: { node: '>= 0.8' } - - entities@4.5.0: - resolution: - { - integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== - } - engines: { node: '>=0.12' } - - env-paths@2.2.1: - resolution: - { - integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== - } - engines: { node: '>=6' } - - error-ex@1.3.4: - resolution: - { - integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ== - } - - es-define-property@1.0.1: - resolution: - { - integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== - } - engines: { node: '>= 0.4' } - - es-errors@1.3.0: - resolution: - { - integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== - } - engines: { node: '>= 0.4' } - - es-object-atoms@1.1.1: - resolution: - { - integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== - } - engines: { node: '>= 0.4' } - - es-set-tostringtag@2.1.0: - resolution: - { - integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== - } - engines: { node: '>= 0.4' } - - es-toolkit@1.46.1: - resolution: - { - integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ== - } - - escalade@3.2.0: - resolution: - { - integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== - } - engines: { node: '>=6' } - - escape-html@1.0.3: - resolution: - { - integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== - } - - escape-string-regexp@4.0.0: - resolution: - { - integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== - } - engines: { node: '>=10' } - - eslint-scope@9.1.2: - resolution: - { - integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ== - } - engines: { node: ^20.19.0 || ^22.13.0 || >=24 } - - eslint-visitor-keys@3.4.3: - resolution: - { - integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== - } - engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } - - eslint-visitor-keys@5.0.1: - resolution: - { - integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== - } - engines: { node: ^20.19.0 || ^22.13.0 || >=24 } - - eslint@10.4.0: - resolution: - { - integrity: sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ== - } - engines: { node: ^20.19.0 || ^22.13.0 || >=24 } - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@11.2.0: - resolution: - { - integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw== - } - engines: { node: ^20.19.0 || ^22.13.0 || >=24 } - - esquery@1.7.0: - resolution: - { - integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== - } - engines: { node: '>=0.10' } - - esrecurse@4.3.0: - resolution: - { - integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== - } - engines: { node: '>=4.0' } - - estraverse@5.3.0: - resolution: - { - integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== - } - engines: { node: '>=4.0' } - - esutils@2.0.3: - resolution: - { - integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== - } - engines: { node: '>=0.10.0' } - - etag@1.8.1: - resolution: - { - integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== - } - engines: { node: '>= 0.6' } - - express-handlebars@5.3.5: - resolution: - { - integrity: sha512-r9pzDc94ZNJ7FVvtsxLfPybmN0eFAUnR61oimNPRpD0D7nkLcezrkpZzoXS5TI75wYHRbflPLTU39B62pwB4DA== - } - engines: { node: '>=v10.24.1' } - - express@4.22.2: - resolution: - { - integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q== - } - engines: { node: '>= 0.10.0' } - - fast-deep-equal@3.1.3: - resolution: - { - integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== - } - - fast-json-stable-stringify@2.1.0: - resolution: - { - integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== - } - - fast-levenshtein@2.0.6: - resolution: - { - integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== - } - - fast-safe-stringify@2.1.1: - resolution: - { - integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== - } - - fast-uri@3.1.2: - resolution: - { - integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ== - } - - file-entry-cache@8.0.0: - resolution: - { - integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== - } - engines: { node: '>=16.0.0' } - - fill-range@7.1.1: - resolution: - { - integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== - } - engines: { node: '>=8' } - - finalhandler@1.3.2: - resolution: - { - integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg== - } - engines: { node: '>= 0.8' } - - find-up@5.0.0: - resolution: - { - integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== - } - engines: { node: '>=10' } - - flat-cache@4.0.1: - resolution: - { - integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== - } - engines: { node: '>=16' } - - flat@5.0.2: - resolution: - { - integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== - } - hasBin: true - - flatted@3.4.2: - resolution: - { - integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== - } - - foreground-child@3.3.1: - resolution: - { - integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== - } - engines: { node: '>=14' } - - form-data@4.0.5: - resolution: - { - integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== - } - engines: { node: '>= 6' } - - formidable@2.1.5: - resolution: - { - integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q== - } - - formidable@3.5.4: - resolution: - { - integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug== - } - engines: { node: '>=14.0.0' } - - forwarded@0.2.0: - resolution: - { - integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== - } - engines: { node: '>= 0.6' } - - fresh@0.5.2: - resolution: - { - integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== - } - engines: { node: '>= 0.6' } - - fs.realpath@1.0.0: - resolution: - { - integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== - } - - fsevents@2.3.3: - resolution: - { - integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== - } - engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } - os: [darwin] - - function-bind@1.1.2: - resolution: - { - integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== - } - - get-caller-file@2.0.5: - resolution: - { - integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== - } - engines: { node: 6.* || 8.* || >= 10.* } - - get-func-name@2.0.2: - resolution: - { - integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ== - } - - get-intrinsic@1.3.0: - resolution: - { - integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== - } - engines: { node: '>= 0.4' } - - get-proto@1.0.1: - resolution: - { - integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== - } - engines: { node: '>= 0.4' } - - git-raw-commits@5.0.1: - resolution: - { - integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ== - } - engines: { node: '>=18' } - hasBin: true - - glob-parent@5.1.2: - resolution: - { - integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== - } - engines: { node: '>= 6' } - - glob-parent@6.0.2: - resolution: - { - integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== - } - engines: { node: '>=10.13.0' } - - glob@10.5.0: - resolution: - { - integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== - } - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - - glob@7.2.3: - resolution: - { - integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== - } - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - - global-directory@5.0.0: - resolution: - { - integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w== - } - engines: { node: '>=20' } - - gopd@1.2.0: - resolution: - { - integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== - } - engines: { node: '>= 0.4' } - - graceful-fs@4.2.11: - resolution: - { - integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== - } - - handlebars@4.7.9: - resolution: - { - integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ== - } - engines: { node: '>=0.4.7' } - hasBin: true - - has-flag@3.0.0: - resolution: - { - integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== - } - engines: { node: '>=4' } - - has-flag@4.0.0: - resolution: - { - integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== - } - engines: { node: '>=8' } - - has-symbols@1.1.0: - resolution: - { - integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== - } - engines: { node: '>= 0.4' } - - has-tostringtag@1.0.2: - resolution: - { - integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== - } - engines: { node: '>= 0.4' } - - hasown@2.0.3: - resolution: - { - integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg== - } - engines: { node: '>= 0.4' } - - he@1.2.0: - resolution: - { - integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== - } - hasBin: true - - http-errors@2.0.1: - resolution: - { - integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== - } - engines: { node: '>= 0.8' } - - https@1.0.0: - resolution: - { - integrity: sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg== - } - - husky@9.1.7: - resolution: - { - integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA== - } - engines: { node: '>=18' } - hasBin: true - - iconv-lite@0.4.24: - resolution: - { - integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - } - engines: { node: '>=0.10.0' } - - iconv-lite@0.7.2: - resolution: - { - integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw== - } - engines: { node: '>=0.10.0' } - - ignore-by-default@1.0.1: - resolution: - { - integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA== - } - - ignore@5.3.2: - resolution: - { - integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== - } - engines: { node: '>= 4' } - - import-fresh@3.3.1: - resolution: - { - integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== - } - engines: { node: '>=6' } - - import-meta-resolve@4.2.0: - resolution: - { - integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg== - } - - imurmurhash@0.1.4: - resolution: - { - integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== - } - engines: { node: '>=0.8.19' } - - inflight@1.0.6: - resolution: - { - integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== - } - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: - { - integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - } - - ini@6.0.0: - resolution: - { - integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ== - } - engines: { node: ^20.17.0 || >=22.9.0 } - - ip-regex@2.1.0: - resolution: - { - integrity: sha512-58yWmlHpp7VYfcdTwMTvwMmqx/Elfxjd9RXTDyMsbL7lLWmhMylLEqiYVLKuLzOZqVgiWXD9MfR62Vv89VRxkw== - } - engines: { node: '>=4' } - - ipaddr.js@1.9.1: - resolution: - { - integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== - } - engines: { node: '>= 0.10' } - - is-arrayish@0.2.1: - resolution: - { - integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== - } - - is-binary-path@2.1.0: - resolution: - { - integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== - } - engines: { node: '>=8' } - - is-extglob@2.1.1: - resolution: - { - integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== - } - engines: { node: '>=0.10.0' } - - is-fullwidth-code-point@3.0.0: - resolution: - { - integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - } - engines: { node: '>=8' } - - is-glob@4.0.3: - resolution: - { - integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== - } - engines: { node: '>=0.10.0' } - - is-ip@2.0.0: - resolution: - { - integrity: sha512-9MTn0dteHETtyUx8pxqMwg5hMBi3pvlyglJ+b79KOCca0po23337LbVV2Hl4xmMvfw++ljnO0/+5G6G+0Szh6g== - } - engines: { node: '>=4' } - - is-number@7.0.0: - resolution: - { - integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== - } - engines: { node: '>=0.12.0' } - - is-obj@2.0.0: - resolution: - { - integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== - } - engines: { node: '>=8' } - - is-path-inside@3.0.3: - resolution: - { - integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - } - engines: { node: '>=8' } - - is-plain-obj@2.1.0: - resolution: - { - integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== - } - engines: { node: '>=8' } - - is-plain-obj@4.1.0: - resolution: - { - integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== - } - engines: { node: '>=12' } - - is-string@1.1.1: - resolution: - { - integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== - } - engines: { node: '>= 0.4' } - - is-unicode-supported@0.1.0: - resolution: - { - integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== - } - engines: { node: '>=10' } - - isexe@2.0.0: - resolution: - { - integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== - } - - jackspeak@3.4.3: - resolution: - { - integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== - } - - jiti@2.6.1: - resolution: - { - integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== - } - hasBin: true - - js-tokens@4.0.0: - resolution: - { - integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== - } - - js-yaml@4.1.1: - resolution: - { - integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== - } - hasBin: true - - json-buffer@3.0.1: - resolution: - { - integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== - } - - json-parse-even-better-errors@2.3.1: - resolution: - { - integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== - } - - json-schema-traverse@0.4.1: - resolution: - { - integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== - } - - json-schema-traverse@1.0.0: - resolution: - { - integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== - } - - json-stable-stringify-without-jsonify@1.0.1: - resolution: - { - integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== - } - - jsonfile@3.0.1: - resolution: - { - integrity: sha512-oBko6ZHlubVB5mRFkur5vgYR1UyqX+S6Y/oCfLhqNdcc2fYFlDpIoNc7AfKS1KOGcnNAkvsr0grLck9ANM815w== - } - - keyv@4.5.4: - resolution: - { - integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== - } - - levn@0.4.1: - resolution: - { - integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== - } - engines: { node: '>= 0.8.0' } - - lines-and-columns@1.2.4: - resolution: - { - integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== - } - - linkify-it@5.0.0: - resolution: - { - integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ== - } - - locate-path@6.0.0: - resolution: - { - integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== - } - engines: { node: '>=10' } - - lodash.once@4.1.1: - resolution: - { - integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== - } - - log-symbols@4.1.0: - resolution: - { - integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== - } - engines: { node: '>=10' } - - loupe@2.3.7: - resolution: - { - integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA== - } - - lru-cache@10.4.3: - resolution: - { - integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== - } - - markdown-it@14.1.1: - resolution: - { - integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA== - } - hasBin: true - - math-intrinsics@1.1.0: - resolution: - { - integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== - } - engines: { node: '>= 0.4' } - - mdurl@2.0.0: - resolution: - { - integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w== - } - - media-typer@0.3.0: - resolution: - { - integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== - } - engines: { node: '>= 0.6' } - - media-typer@1.1.0: - resolution: - { - integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== - } - engines: { node: '>= 0.8' } - - meow@13.2.0: - resolution: - { - integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA== - } - engines: { node: '>=18' } - - merge-descriptors@1.0.3: - resolution: - { - integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== - } - - methods@1.1.2: - resolution: - { - integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== - } - engines: { node: '>= 0.6' } - - mime-db@1.52.0: - resolution: - { - integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - } - engines: { node: '>= 0.6' } - - mime-db@1.54.0: - resolution: - { - integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== - } - engines: { node: '>= 0.6' } - - mime-types@2.1.35: - resolution: - { - integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - } - engines: { node: '>= 0.6' } - - mime-types@3.0.2: - resolution: - { - integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== - } - engines: { node: '>=18' } - - mime@1.6.0: - resolution: - { - integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== - } - engines: { node: '>=4' } - hasBin: true - - mime@2.6.0: - resolution: - { - integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== - } - engines: { node: '>=4.0.0' } - hasBin: true - - minimatch@10.2.5: - resolution: - { - integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== - } - engines: { node: 18 || 20 || >=22 } - - minimatch@3.1.5: - resolution: - { - integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== - } - - minimatch@9.0.9: - resolution: - { - integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== - } - engines: { node: '>=16 || 14 >=14.17' } - - minimist@1.2.8: - resolution: - { - integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== - } - - minipass@7.1.3: - resolution: - { - integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== - } - engines: { node: '>=16 || 14 >=14.17' } - - mkdirp@1.0.4: - resolution: - { - integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== - } - engines: { node: '>=10' } - hasBin: true - - mocha-multi@1.1.7: - resolution: - { - integrity: sha512-SXZRgHy0XiRTASyOp0p6fjOkdj+R62L6cqutnYyQOvIjNznJuUwzykxctypeRiOwPd+gfn4yt3NRulMQyI8Tzg== - } - engines: { node: '>=6.0.0' } - peerDependencies: - mocha: '>=2.2.0 <7 || >=9' - - mocha@11.7.5: - resolution: - { - integrity: sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig== - } - engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - hasBin: true - - morgan@1.10.1: - resolution: - { - integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A== - } - engines: { node: '>= 0.8.0' } - - ms@2.0.0: - resolution: - { - integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== - } - - ms@2.1.3: - resolution: - { - integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - } - - natural-compare@1.4.0: - resolution: - { - integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== - } - - negotiator@0.6.3: - resolution: - { - integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== - } - engines: { node: '>= 0.6' } - - neo-async@2.6.2: - resolution: - { - integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== - } - - nodemon@3.1.14: - resolution: - { - integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw== - } - engines: { node: '>=10' } - hasBin: true - - normalize-path@3.0.0: - resolution: - { - integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== - } - engines: { node: '>=0.10.0' } - - object-assign@4.1.1: - resolution: - { - integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== - } - engines: { node: '>=0.10.0' } - - object-inspect@1.13.4: - resolution: - { - integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== - } - engines: { node: '>= 0.4' } - - on-finished@2.3.0: - resolution: - { - integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww== - } - engines: { node: '>= 0.8' } - - on-finished@2.4.1: - resolution: - { - integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== - } - engines: { node: '>= 0.8' } - - on-headers@1.1.0: - resolution: - { - integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A== - } - engines: { node: '>= 0.8' } - - once@1.4.0: - resolution: - { - integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - } - - optionator@0.9.4: - resolution: - { - integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== - } - engines: { node: '>= 0.8.0' } - - p-limit@3.1.0: - resolution: - { - integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== - } - engines: { node: '>=10' } - - p-locate@5.0.0: - resolution: - { - integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== - } - engines: { node: '>=10' } - - package-json-from-dist@1.0.1: - resolution: - { - integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== - } - - parent-module@1.0.1: - resolution: - { - integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== - } - engines: { node: '>=6' } - - parse-json@5.2.0: - resolution: - { - integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== - } - engines: { node: '>=8' } - - parseurl@1.3.3: - resolution: - { - integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== - } - engines: { node: '>= 0.8' } - - path-exists@4.0.0: - resolution: - { - integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== - } - engines: { node: '>=8' } - - path-is-absolute@1.0.1: - resolution: - { - integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== - } - engines: { node: '>=0.10.0' } - - path-key@3.1.1: - resolution: - { - integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== - } - engines: { node: '>=8' } - - path-scurry@1.11.1: - resolution: - { - integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== - } - engines: { node: '>=16 || 14 >=14.18' } - - path-to-regexp@0.1.13: - resolution: - { - integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA== - } - - pathval@1.1.1: - resolution: - { - integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== - } - - picocolors@1.1.1: - resolution: - { - integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== - } - - picomatch@2.3.2: - resolution: - { - integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== - } - engines: { node: '>=8.6' } - - prelude-ls@1.2.1: - resolution: - { - integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== - } - engines: { node: '>= 0.8.0' } - - prettier@3.8.3: - resolution: - { - integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw== - } - engines: { node: '>=14' } - hasBin: true - - proxy-addr@2.0.7: - resolution: - { - integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== - } - engines: { node: '>= 0.10' } - - pstree.remy@1.1.8: - resolution: - { - integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== - } - - punycode.js@2.3.1: - resolution: - { - integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA== - } - engines: { node: '>=6' } - - punycode@2.3.1: - resolution: - { - integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== - } - engines: { node: '>=6' } - - qs@6.15.1: - resolution: - { - integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg== - } - engines: { node: '>=0.6' } - - range-parser@1.2.1: - resolution: - { - integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== - } - engines: { node: '>= 0.6' } - - raw-body@2.5.3: - resolution: - { - integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA== - } - engines: { node: '>= 0.8' } - - raw-body@3.0.2: - resolution: - { - integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== - } - engines: { node: '>= 0.10' } - - readdirp@3.6.0: - resolution: - { - integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== - } - engines: { node: '>=8.10.0' } - - readdirp@4.1.2: - resolution: - { - integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== - } - engines: { node: '>= 14.18.0' } - - require-directory@2.1.1: - resolution: - { - integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== - } - engines: { node: '>=0.10.0' } - - require-from-string@2.0.2: - resolution: - { - integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== - } - engines: { node: '>=0.10.0' } - - resolve-from@4.0.0: - resolution: - { - integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== - } - engines: { node: '>=4' } - - resolve-from@5.0.0: - resolution: - { - integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== - } - engines: { node: '>=8' } - - safe-buffer@5.1.2: - resolution: - { - integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== - } - - safe-buffer@5.2.1: - resolution: - { - integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - } - - safer-buffer@2.1.2: - resolution: - { - integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - } - - sax@1.6.0: - resolution: - { - integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA== - } - engines: { node: '>=11.0.0' } - - semver@7.8.0: - resolution: - { - integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA== - } - engines: { node: '>=10' } - hasBin: true - - send@0.19.2: - resolution: - { - integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg== - } - engines: { node: '>= 0.8.0' } - - serialize-javascript@7.0.5: - resolution: - { - integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw== - } - engines: { node: '>=20.0.0' } - - serve-static@1.16.3: - resolution: - { - integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA== - } - engines: { node: '>= 0.8.0' } - - setprototypeof@1.2.0: - resolution: - { - integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== - } - - shebang-command@2.0.0: - resolution: - { - integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== - } - engines: { node: '>=8' } - - shebang-regex@3.0.0: - resolution: - { - integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== - } - engines: { node: '>=8' } - - side-channel-list@1.0.1: - resolution: - { - integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== - } - engines: { node: '>= 0.4' } - - side-channel-map@1.0.1: - resolution: - { - integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== - } - engines: { node: '>= 0.4' } - - side-channel-weakmap@1.0.2: - resolution: - { - integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== - } - engines: { node: '>= 0.4' } - - side-channel@1.1.0: - resolution: - { - integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== - } - engines: { node: '>= 0.4' } - - signal-exit@4.1.0: - resolution: - { - integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== - } - engines: { node: '>=14' } - - simple-update-notifier@2.0.0: - resolution: - { - integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w== - } - engines: { node: '>=10' } - - source-map@0.6.1: - resolution: - { - integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - } - engines: { node: '>=0.10.0' } - - statuses@2.0.2: - resolution: - { - integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== - } - engines: { node: '>= 0.8' } - - string-width@4.2.3: - resolution: - { - integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - } - engines: { node: '>=8' } - - string-width@5.1.2: - resolution: - { - integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== - } - engines: { node: '>=12' } - - strip-ansi@6.0.1: - resolution: - { - integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - } - engines: { node: '>=8' } - - strip-ansi@7.2.0: - resolution: - { - integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== - } - engines: { node: '>=12' } - - strip-json-comments@3.1.1: - resolution: - { - integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== - } - engines: { node: '>=8' } - - superagent@10.3.0: - resolution: - { - integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ== - } - engines: { node: '>=14.18.0' } - - superagent@8.1.2: - resolution: - { - integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA== - } - engines: { node: '>=6.4.0 <13 || >=14' } - deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net - - supertest@7.2.2: - resolution: - { - integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA== - } - engines: { node: '>=14.18.0' } - - supports-color@5.5.0: - resolution: - { - integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== - } - engines: { node: '>=4' } - - supports-color@7.2.0: - resolution: - { - integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== - } - engines: { node: '>=8' } - - supports-color@8.1.1: - resolution: - { - integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - } - engines: { node: '>=10' } - - tinyexec@1.1.2: - resolution: - { - integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA== - } - engines: { node: '>=18' } - - to-regex-range@5.0.1: - resolution: - { - integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== - } - engines: { node: '>=8.0' } - - toidentifier@1.0.1: - resolution: - { - integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== - } - engines: { node: '>=0.6' } - - touch@3.1.1: - resolution: - { - integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA== - } - hasBin: true - - type-check@0.4.0: - resolution: - { - integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== - } - engines: { node: '>= 0.8.0' } - - type-detect@0.1.1: - resolution: - { - integrity: sha512-5rqszGVwYgBoDkIm2oUtvkfZMQ0vk29iDMU0W2qCa3rG0vPDNczCMT4hV/bLBgLg8k8ri6+u3Zbt+S/14eMzlA== - } - - type-detect@1.0.0: - resolution: - { - integrity: sha512-f9Uv6ezcpvCQjJU0Zqbg+65qdcszv3qUQsZfjdRbWiZ7AMenrX1u0lNk9EoWWX6e1F+NULyg27mtdeZ5WhpljA== - } - - type-detect@4.1.0: - resolution: - { - integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw== - } - engines: { node: '>=4' } - - type-is@1.6.18: - resolution: - { - integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== - } - engines: { node: '>= 0.6' } - - type-is@2.1.0: - resolution: - { - integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA== - } - engines: { node: '>= 18' } - - typescript@5.9.3: - resolution: - { - integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== - } - engines: { node: '>=14.17' } - hasBin: true - - uc.micro@2.1.0: - resolution: - { - integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A== - } - - uglify-js@3.19.3: - resolution: - { - integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== - } - engines: { node: '>=0.8.0' } - hasBin: true - - undefsafe@2.0.5: - resolution: - { - integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== - } - - underscore@1.13.8: - resolution: - { - integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ== - } - - undici-types@7.24.6: - resolution: - { - integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg== - } - - unpipe@1.0.0: - resolution: - { - integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== - } - engines: { node: '>= 0.8' } - - uri-js@4.4.1: - resolution: - { - integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== - } - - utils-merge@1.0.1: - resolution: - { - integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== - } - engines: { node: '>= 0.4.0' } - - vary@1.1.2: - resolution: - { - integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== - } - engines: { node: '>= 0.8' } - - which@2.0.2: - resolution: - { - integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== - } - engines: { node: '>= 8' } - hasBin: true - - word-wrap@1.2.5: - resolution: - { - integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== - } - engines: { node: '>=0.10.0' } - - wordwrap@1.0.0: - resolution: - { - integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== - } - - workerpool@9.3.4: - resolution: - { - integrity: sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg== - } - - wrap-ansi@7.0.0: - resolution: - { - integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - } - engines: { node: '>=10' } - - wrap-ansi@8.1.0: - resolution: - { - integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== - } - engines: { node: '>=12' } - - wrappy@1.0.2: - resolution: - { - integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== - } - - ws@8.20.1: - resolution: - { - integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w== - } - engines: { node: '>=10.0.0' } - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - xml2js@0.5.0: - resolution: - { - integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA== - } - engines: { node: '>=4.0.0' } - - xml2js@0.6.2: - resolution: - { - integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA== - } - engines: { node: '>=4.0.0' } - - xmlbuilder@11.0.1: - resolution: - { - integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== - } - engines: { node: '>=4.0' } - - xmlbuilder@15.1.1: - resolution: - { - integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg== - } - engines: { node: '>=8.0' } - - y18n@5.0.8: - resolution: - { - integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== - } - engines: { node: '>=10' } - - yargs-parser@21.1.1: - resolution: - { - integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== - } - engines: { node: '>=12' } - - yargs-unparser@2.0.0: - resolution: - { - integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== - } - engines: { node: '>=10' } - - yargs@17.7.2: - resolution: - { - integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== - } - engines: { node: '>=12' } - - yocto-queue@0.1.0: - resolution: - { - integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== - } - engines: { node: '>=10' } -snapshots: - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/helper-validator-identifier@7.28.5': {} - - '@commitlint/cli@20.5.3(@types/node@25.8.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': - dependencies: - '@commitlint/format': 20.5.0 - '@commitlint/lint': 20.5.3 - '@commitlint/load': 20.5.3(@types/node@25.8.0)(typescript@5.9.3) - '@commitlint/read': 20.5.0(conventional-commits-parser@6.4.0) - '@commitlint/types': 20.5.0 - tinyexec: 1.1.2 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - conventional-commits-filter - - conventional-commits-parser - - typescript - - '@commitlint/config-conventional@20.5.3': - dependencies: - '@commitlint/types': 20.5.0 - conventional-changelog-conventionalcommits: 9.3.1 - - '@commitlint/config-validator@20.5.0': - dependencies: - '@commitlint/types': 20.5.0 - ajv: 8.20.0 - - '@commitlint/ensure@20.5.3': - dependencies: - '@commitlint/types': 20.5.0 - es-toolkit: 1.46.1 - - '@commitlint/execute-rule@20.0.0': {} - - '@commitlint/format@20.5.0': - dependencies: - '@commitlint/types': 20.5.0 - picocolors: 1.1.1 - - '@commitlint/is-ignored@20.5.0': - dependencies: - '@commitlint/types': 20.5.0 - semver: 7.8.0 - - '@commitlint/lint@20.5.3': - dependencies: - '@commitlint/is-ignored': 20.5.0 - '@commitlint/parse': 20.5.0 - '@commitlint/rules': 20.5.3 - '@commitlint/types': 20.5.0 - - '@commitlint/load@20.5.3(@types/node@25.8.0)(typescript@5.9.3)': - dependencies: - '@commitlint/config-validator': 20.5.0 - '@commitlint/execute-rule': 20.0.0 - '@commitlint/resolve-extends': 20.5.3 - '@commitlint/types': 20.5.0 - cosmiconfig: 9.0.1(typescript@5.9.3) - cosmiconfig-typescript-loader: 6.3.0(@types/node@25.8.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) - es-toolkit: 1.46.1 - is-plain-obj: 4.1.0 - picocolors: 1.1.1 - transitivePeerDependencies: - - '@types/node' - - typescript - - '@commitlint/message@20.4.3': {} - - '@commitlint/parse@20.5.0': - dependencies: - '@commitlint/types': 20.5.0 - conventional-changelog-angular: 8.3.1 - conventional-commits-parser: 6.4.0 - - '@commitlint/read@20.5.0(conventional-commits-parser@6.4.0)': - dependencies: - '@commitlint/top-level': 20.4.3 - '@commitlint/types': 20.5.0 - git-raw-commits: 5.0.1(conventional-commits-parser@6.4.0) - minimist: 1.2.8 - tinyexec: 1.1.2 - transitivePeerDependencies: - - conventional-commits-filter - - conventional-commits-parser - - '@commitlint/resolve-extends@20.5.3': - dependencies: - '@commitlint/config-validator': 20.5.0 - '@commitlint/types': 20.5.0 - es-toolkit: 1.46.1 - global-directory: 5.0.0 - import-meta-resolve: 4.2.0 - resolve-from: 5.0.0 - - '@commitlint/rules@20.5.3': - dependencies: - '@commitlint/ensure': 20.5.3 - '@commitlint/message': 20.4.3 - '@commitlint/to-lines': 20.0.0 - '@commitlint/types': 20.5.0 - - '@commitlint/to-lines@20.0.0': {} - - '@commitlint/top-level@20.4.3': - dependencies: - escalade: 3.2.0 - - '@commitlint/types@20.5.0': - dependencies: - conventional-commits-parser: 6.4.0 - picocolors: 1.1.1 - - '@conventional-changelog/git-client@2.7.0(conventional-commits-parser@6.4.0)': - dependencies: - '@simple-libs/child-process-utils': 1.0.2 - '@simple-libs/stream-utils': 1.2.0 - semver: 7.8.0 - optionalDependencies: - conventional-commits-parser: 6.4.0 - - '@eslint-community/eslint-utils@4.9.1(eslint@10.4.0(jiti@2.6.1))': - dependencies: - eslint: 10.4.0(jiti@2.6.1) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.23.5': - dependencies: - '@eslint/object-schema': 3.0.5 - debug: 4.4.3 - minimatch: 10.2.5 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.6.0': - dependencies: - '@eslint/core': 1.2.1 - - '@eslint/core@1.2.1': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/js@10.0.1(eslint@10.4.0(jiti@2.6.1))': - optionalDependencies: - eslint: 10.4.0(jiti@2.6.1) - - '@eslint/object-schema@3.0.5': {} - - '@eslint/plugin-kit@0.7.1': - dependencies: - '@eslint/core': 1.2.1 - levn: 0.4.1 - - '@humanfs/core@0.19.2': - dependencies: - '@humanfs/types': 0.15.0 - - '@humanfs/node@0.16.8': - dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 - '@humanwhocodes/retry': 0.4.3 - - '@humanfs/types@0.15.0': {} - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.2.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - - '@noble/hashes@1.8.0': {} - - '@paralleldrive/cuid2@2.3.1': - dependencies: - '@noble/hashes': 1.8.0 - - '@pkgjs/parseargs@0.11.0': + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@commitlint/cli@20.5.3': + resolution: {integrity: sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==} + engines: {node: '>=v18'} + hasBin: true + + '@commitlint/config-conventional@20.5.3': + resolution: {integrity: sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ==} + engines: {node: '>=v18'} + + '@commitlint/config-validator@20.5.0': + resolution: {integrity: sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==} + engines: {node: '>=v18'} + + '@commitlint/ensure@20.5.3': + resolution: {integrity: sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw==} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@20.0.0': + resolution: {integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==} + engines: {node: '>=v18'} + + '@commitlint/format@20.5.0': + resolution: {integrity: sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==} + engines: {node: '>=v18'} + + '@commitlint/is-ignored@20.5.0': + resolution: {integrity: sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==} + engines: {node: '>=v18'} + + '@commitlint/lint@20.5.3': + resolution: {integrity: sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw==} + engines: {node: '>=v18'} + + '@commitlint/load@20.5.3': + resolution: {integrity: sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ==} + engines: {node: '>=v18'} + + '@commitlint/message@20.4.3': + resolution: {integrity: sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==} + engines: {node: '>=v18'} + + '@commitlint/parse@20.5.0': + resolution: {integrity: sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==} + engines: {node: '>=v18'} + + '@commitlint/read@20.5.0': + resolution: {integrity: sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@20.5.3': + resolution: {integrity: sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew==} + engines: {node: '>=v18'} + + '@commitlint/rules@20.5.3': + resolution: {integrity: sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ==} + engines: {node: '>=v18'} + + '@commitlint/to-lines@20.0.0': + resolution: {integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==} + engines: {node: '>=v18'} + + '@commitlint/top-level@20.4.3': + resolution: {integrity: sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==} + engines: {node: '>=v18'} + + '@commitlint/types@20.5.0': + resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} + engines: {node: '>=v18'} + + '@conventional-changelog/git-client@2.7.0': + resolution: {integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==} + engines: {node: '>=18'} + peerDependencies: + conventional-commits-filter: ^5.0.0 + conventional-commits-parser: ^6.4.0 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.7.1': + resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@simple-libs/child-process-utils@1.0.2': + resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} + engines: {node: '>=18'} + + '@simple-libs/stream-utils@1.2.0': + resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} + engines: {node: '>=18'} + + '@types/chai@4.3.20': + resolution: {integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@22.19.19': + resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + + '@types/node@25.8.0': + resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==} + + '@types/superagent@4.1.13': + resolution: {integrity: sha512-YIGelp3ZyMiH0/A09PMAORO0EBGlF5xIKfDpK74wdYvWUs2o96b5CItJcWPdH409b7SAXIIG6p8NdU/4U2Maww==} + + '@typescript-eslint/eslint-plugin@8.59.4': + resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.4 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.4': + resolution: {integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.4': + resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.4': + resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.4': + resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.4': + resolution: {integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.4': + resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.4': + resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.4': + resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.4': + resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/coverage-v8@2.1.9': + resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} + peerDependencies: + '@vitest/browser': 2.1.9 + vitest: 2.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@1.20.5: + resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browser-stdout@1.3.1: + resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + chai-http@4.4.0: + resolution: {integrity: sha512-uswN3rZpawlRaa5NiDUHcDZ3v2dw5QgLyAwnQ2tnVNuP7CwIsOFuYJ0xR1WiR7ymD4roBnJIzOUep7w9jQMFJA==} + engines: {node: '>=10'} + + chai-json@1.0.0: + resolution: {integrity: sha512-p9eEYu3H2BkpU8PW8kqt0N6Ni6UG3LCCjlqHtZbrgvGRJYD4UnJuh704EFbGRxylzEPWsaOGhgXfdn0n1W3hhA==} + + chai-xml@0.4.1: + resolution: {integrity: sha512-VUf5Ol4ifOAsgz+lN4tfWENgQtrKxHPWsmpL5wdbqQdkpblZkcDlaT2aFvsPQH219Yvl8vc4064yFErgBIn9bw==} + engines: {node: '>= 0.8.0'} + peerDependencies: + chai: '>=1.10.0 ' + + chai@3.5.0: + resolution: {integrity: sha512-eRYY0vPS2a9zt5w5Z0aCeWbrXTEyvk7u/Xf71EzNObrjSCPgMm1Nku/D/u2tiqHBX5j40wWhj54YJLtgn8g55A==} + engines: {node: '>= 0.4.0'} + + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + charset@1.0.1: + resolution: {integrity: sha512-6dVyOOYjpfFcL1Y4qChrAoQLRHvj2ziyhcm0QJlhOcAhykL/k1kTUPbeo+87MNRTRdk2OIIsIXbuF3x2wi5EXg==} + engines: {node: '>=4.0.0'} + + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + conventional-changelog-angular@8.3.1: + resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} + engines: {node: '>=18'} + + conventional-changelog-conventionalcommits@9.3.1: + resolution: {integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==} + engines: {node: '>=18'} + + conventional-commits-parser@6.4.0: + resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} + engines: {node: '>=18'} + hasBin: true + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: optional: true - '@simple-libs/child-process-utils@1.0.2': - dependencies: - '@simple-libs/stream-utils': 1.2.0 + decamelize@4.0.0: + resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} + engines: {node: '>=10'} - '@simple-libs/stream-utils@1.2.0': {} + deep-eql@0.1.3: + resolution: {integrity: sha512-6sEotTRGBFiNcqVoeHwnfopbSpi5NbH1VWJmYCVkmxMmaVTT0bUTrNaGyBwhgP4MZL012W/mkzIn3Da+iDYweg==} - '@types/chai@4.3.20': {} + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} - '@types/cookiejar@2.1.5': {} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} - '@types/esrecurse@4.3.1': {} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - '@types/estree@1.0.9': {} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} - '@types/json-schema@7.0.15': {} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} - '@types/node@25.8.0': - dependencies: - undici-types: 7.24.6 + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - '@types/superagent@4.1.13': - dependencies: - '@types/cookiejar': 2.1.5 - '@types/node': 25.8.0 + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - accepts@1.3.8: - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 + diff@7.0.0: + resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} + engines: {node: '>=0.3.1'} - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} - acorn@8.16.0: {} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} - ajv@6.15.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} - ajv@8.20.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - ansi-regex@5.0.1: {} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - ansi-regex@6.2.2: {} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - ansi-styles@6.2.3: {} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.2 + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} - argparse@2.0.1: {} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} - array-flatten@1.1.1: {} + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - array-ify@1.0.0: {} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} - asap@2.0.6: {} + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} - assertion-error@1.1.0: {} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - asynckit@0.4.0: {} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} - balanced-match@1.0.2: {} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} - balanced-match@4.0.4: {} + es-toolkit@1.46.1: + resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} - basic-auth@2.0.1: - dependencies: - safe-buffer: 5.1.2 + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true - binary-extensions@2.3.0: {} - - body-parser@1.20.5: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.15.1 - raw-body: 2.5.3 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - - body-parser@2.2.2: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 4.4.3 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - on-finished: 2.4.1 - qs: 6.15.1 - raw-body: 3.0.2 - type-is: 2.1.0 - transitivePeerDependencies: - - supports-color - - brace-expansion@1.1.14: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.1.0: - dependencies: - balanced-match: 1.0.2 - - brace-expansion@5.0.6: - dependencies: - balanced-match: 4.0.4 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - browser-stdout@1.3.1: {} - - bytes@3.1.2: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - callsites@3.1.0: {} - - camelcase@6.3.0: {} - - chai-http@4.4.0: - dependencies: - '@types/chai': 4.3.20 - '@types/superagent': 4.1.13 - charset: 1.0.1 - cookiejar: 2.1.4 - is-ip: 2.0.0 - methods: 1.1.2 - qs: 6.15.1 - superagent: 8.1.2 - transitivePeerDependencies: - - supports-color - - chai-json@1.0.0: - dependencies: - chai: 3.5.0 - jsonfile: 3.0.1 - underscore: 1.13.8 - - chai-xml@0.4.1(chai@4.5.0): - dependencies: - chai: 4.5.0 - xml2js: 0.5.0 - - chai@3.5.0: - dependencies: - assertion-error: 1.1.0 - deep-eql: 0.1.3 - type-detect: 1.0.0 - - chai@4.5.0: - dependencies: - assertion-error: 1.1.0 - check-error: 1.0.3 - deep-eql: 4.1.4 - get-func-name: 2.0.2 - loupe: 2.3.7 - pathval: 1.1.1 - type-detect: 4.1.0 - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - charset@1.0.1: {} - - check-error@1.0.3: - dependencies: - get-func-name: 2.0.2 - - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - compare-func@2.0.0: - dependencies: - array-ify: 1.0.0 - dot-prop: 5.3.0 - - component-emitter@1.3.1: {} - - concat-map@0.0.1: {} - - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 - - content-type@1.0.5: {} - - content-type@2.0.0: {} - - conventional-changelog-angular@8.3.1: - dependencies: - compare-func: 2.0.0 - - conventional-changelog-conventionalcommits@9.3.1: - dependencies: - compare-func: 2.0.0 - - conventional-commits-parser@6.4.0: - dependencies: - '@simple-libs/stream-utils': 1.2.0 - meow: 13.2.0 + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true - cookie-signature@1.0.7: {} + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} - cookie-signature@1.2.2: {} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - cookie@0.7.2: {} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} - cookiejar@2.1.4: {} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - cosmiconfig-typescript-loader@6.3.0(@types/node@25.8.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): - dependencies: - '@types/node': 25.8.0 - cosmiconfig: 9.0.1(typescript@5.9.3) - jiti: 2.6.1 - typescript: 5.9.3 + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - cosmiconfig@9.0.1(typescript@5.9.3): - dependencies: - env-paths: 2.2.1 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - parse-json: 5.2.0 - optionalDependencies: - typescript: 5.9.3 + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - dayjs@1.11.20: {} + eslint@10.4.0: + resolution: {integrity: sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true - debug@2.6.9: - dependencies: - ms: 2.0.0 + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - debug@4.4.3: - dependencies: - ms: 2.1.3 + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - debug@4.4.3(supports-color@5.5.0): - dependencies: - ms: 2.1.3 - optionalDependencies: - supports-color: 5.5.0 + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} - debug@4.4.3(supports-color@8.1.1): - dependencies: - ms: 2.1.3 - optionalDependencies: - supports-color: 8.1.1 + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} - decamelize@4.0.0: {} + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} - deep-eql@0.1.3: - dependencies: - type-detect: 0.1.1 + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - deep-eql@4.1.4: - dependencies: - type-detect: 4.1.0 + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} - deep-is@0.1.4: {} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} - delayed-stream@1.0.0: {} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} - depd@2.0.0: {} + express-handlebars@5.3.5: + resolution: {integrity: sha512-r9pzDc94ZNJ7FVvtsxLfPybmN0eFAUnR61oimNPRpD0D7nkLcezrkpZzoXS5TI75wYHRbflPLTU39B62pwB4DA==} + engines: {node: '>=v10.24.1'} - destroy@1.2.0: {} + express@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} + engines: {node: '>= 0.10.0'} - dezalgo@1.0.4: - dependencies: - asap: 2.0.6 - wrappy: 1.0.2 + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - diff@7.0.0: {} + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - dot-prop@5.3.0: - dependencies: - is-obj: 2.0.0 - - dotenv@17.4.2: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - eastasianwidth@0.2.0: {} - - ee-first@1.1.1: {} - - emoji-regex@8.0.0: {} + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - emoji-regex@9.2.2: {} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - encodeurl@2.0.0: {} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} - entities@4.5.0: {} - - env-paths@2.2.1: {} - - error-ex@1.3.4: - dependencies: - is-arrayish: 0.2.1 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.3 - - es-toolkit@1.46.1: {} - - escalade@3.2.0: {} - - escape-html@1.0.3: {} - - escape-string-regexp@4.0.0: {} - - eslint-scope@9.1.2: - dependencies: - '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.9 - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@10.4.0(jiti@2.6.1): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.6.1)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.6.0 - '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.7.1 - '@humanfs/node': 0.16.8 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.9 - ajv: 6.15.0 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 9.1.2 - eslint-visitor-keys: 5.0.1 - espree: 11.2.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.6.1 - transitivePeerDependencies: - - supports-color - - espree@11.2.0: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 5.0.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - esutils@2.0.3: {} - - etag@1.8.1: {} - - express-handlebars@5.3.5: - dependencies: - glob: 7.2.3 - graceful-fs: 4.2.11 - handlebars: 4.7.9 - - express@4.22.2: - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.5 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.0.7 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.2 - fresh: 0.5.2 - http-errors: 2.0.1 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.13 - proxy-addr: 2.0.7 - qs: 6.15.1 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.2 - serve-static: 1.16.3 - setprototypeof: 1.2.0 - statuses: 2.0.2 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - fast-deep-equal@3.1.3: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fast-safe-stringify@2.1.1: {} - - fast-uri@3.1.2: {} - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - finalhandler@1.3.2: - dependencies: - debug: 2.6.9 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.4.2 - keyv: 4.5.4 - - flat@5.0.2: {} - - flatted@3.4.2: {} - - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.3 - mime-types: 2.1.35 - - formidable@2.1.5: - dependencies: - '@paralleldrive/cuid2': 2.3.1 - dezalgo: 1.0.4 - once: 1.4.0 - qs: 6.15.1 - - formidable@3.5.4: - dependencies: - '@paralleldrive/cuid2': 2.3.1 - dezalgo: 1.0.4 - once: 1.4.0 - - forwarded@0.2.0: {} - - fresh@0.5.2: {} - - fs.realpath@1.0.0: {} - - fsevents@2.3.3: + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: optional: true - function-bind@1.1.2: {} - - get-caller-file@2.0.5: {} - - get-func-name@2.0.2: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.3 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formidable@2.1.5: + resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} - git-raw-commits@5.0.1(conventional-commits-parser@6.4.0): - dependencies: - '@conventional-changelog/git-client': 2.7.0(conventional-commits-parser@6.4.0) - meow: 13.2.0 - transitivePeerDependencies: - - conventional-commits-filter - - conventional-commits-parser + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 + git-raw-commits@5.0.1: + resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} + engines: {node: '>=18'} + hasBin: true - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.9 - minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.5 - once: 1.4.0 - path-is-absolute: 1.0.1 + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} - global-directory@5.0.0: - dependencies: - ini: 6.0.0 + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true - gopd@1.2.0: {} + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - graceful-fs@4.2.11: {} + global-directory@5.0.0: + resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} + engines: {node: '>=20'} - handlebars@4.7.9: - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} - has-flag@3.0.0: {} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} - has-flag@4.0.0: {} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - has-symbols@1.1.0: {} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} - hasown@2.0.3: - dependencies: - function-bind: 1.1.2 + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} - he@1.2.0: {} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} - http-errors@2.0.1: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} - https@1.0.0: {} + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} - husky@9.1.7: {} + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} - ignore-by-default@1.0.1: {} + https@1.0.0: + resolution: {integrity: sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==} - ignore@5.3.2: {} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} - import-meta-resolve@4.2.0: {} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} - imurmurhash@0.1.4: {} + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} - inherits@2.0.4: {} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} - ini@6.0.0: {} + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} - ip-regex@2.1.0: {} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} - ipaddr.js@1.9.1: {} + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} - is-arrayish@0.2.1: {} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - is-extglob@2.1.1: {} + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} - is-fullwidth-code-point@3.0.0: {} + ip-regex@2.1.0: + resolution: {integrity: sha512-58yWmlHpp7VYfcdTwMTvwMmqx/Elfxjd9RXTDyMsbL7lLWmhMylLEqiYVLKuLzOZqVgiWXD9MfR62Vv89VRxkw==} + engines: {node: '>=4'} - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} - is-ip@2.0.0: - dependencies: - ip-regex: 2.1.0 + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - is-number@7.0.0: {} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} - is-obj@2.0.0: {} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} - is-path-inside@3.0.3: {} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} - is-plain-obj@2.1.0: {} + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} - is-plain-obj@4.1.0: {} + is-ip@2.0.0: + resolution: {integrity: sha512-9MTn0dteHETtyUx8pxqMwg5hMBi3pvlyglJ+b79KOCca0po23337LbVV2Hl4xmMvfw++ljnO0/+5G6G+0Szh6g==} + engines: {node: '>=4'} - is-string@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} - is-unicode-supported@0.1.0: {} + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} - isexe@2.0.0: {} + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} - jiti@2.6.1: {} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} - js-tokens@4.0.0: {} + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} - json-buffer@3.0.1: {} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - json-parse-even-better-errors@2.3.1: {} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} - json-schema-traverse@0.4.1: {} + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} - json-schema-traverse@1.0.0: {} + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} - json-stable-stringify-without-jsonify@1.0.1: {} + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} - jsonfile@3.0.1: - optionalDependencies: - graceful-fs: 4.2.11 + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} - lines-and-columns@1.2.4: {} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - linkify-it@5.0.0: - dependencies: - uc.micro: 2.1.0 + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - lodash.once@4.1.1: {} + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - log-symbols@4.1.0: - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - loupe@2.3.7: - dependencies: - get-func-name: 2.0.2 + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - lru-cache@10.4.3: {} + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - markdown-it@14.1.1: - dependencies: - argparse: 2.0.1 - entities: 4.5.0 - linkify-it: 5.0.0 - mdurl: 2.0.0 - punycode.js: 2.3.1 - uc.micro: 2.1.0 + jsonfile@3.0.1: + resolution: {integrity: sha512-oBko6ZHlubVB5mRFkur5vgYR1UyqX+S6Y/oCfLhqNdcc2fYFlDpIoNc7AfKS1KOGcnNAkvsr0grLck9ANM815w==} - math-intrinsics@1.1.0: {} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - mdurl@2.0.0: {} + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} - media-typer@0.3.0: {} + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} - media-typer@1.1.0: {} + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - meow@13.2.0: {} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} - merge-descriptors@1.0.3: {} + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - methods@1.1.2: {} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} - mime-db@1.52.0: {} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - mime-db@1.54.0: {} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} - mime-types@3.0.2: - dependencies: - mime-db: 1.54.0 + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} - mime@1.6.0: {} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - mime@2.6.0: {} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.6 + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.14 + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} - minimatch@9.0.9: - dependencies: - brace-expansion: 2.1.0 + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} - minimist@1.2.8: {} + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true - minipass@7.1.3: {} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} - mkdirp@1.0.4: {} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} - mocha-multi@1.1.7(mocha@11.7.5): - dependencies: - debug: 4.4.3 - is-string: 1.1.1 - lodash.once: 4.1.1 - mkdirp: 1.0.4 - mocha: 11.7.5 - object-assign: 4.1.1 - transitivePeerDependencies: - - supports-color + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} - mocha@11.7.5: - dependencies: - browser-stdout: 1.3.1 - chokidar: 4.0.3 - debug: 4.4.3(supports-color@8.1.1) - diff: 7.0.0 - escape-string-regexp: 4.0.0 - find-up: 5.0.0 - glob: 10.5.0 - he: 1.2.0 - is-path-inside: 3.0.3 - js-yaml: 4.1.1 - log-symbols: 4.1.0 - minimatch: 9.0.9 - ms: 2.1.3 - picocolors: 1.1.1 - serialize-javascript: 7.0.5 - strip-json-comments: 3.1.1 - supports-color: 8.1.1 - workerpool: 9.3.4 - yargs: 17.7.2 - yargs-parser: 21.1.1 - yargs-unparser: 2.0.0 + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} - morgan@1.10.1: - dependencies: - basic-auth: 2.0.1 - debug: 2.6.9 - depd: 2.0.0 - on-finished: 2.3.0 - on-headers: 1.1.0 - transitivePeerDependencies: - - supports-color + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} - ms@2.0.0: {} + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - ms@2.1.3: {} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} - natural-compare@1.4.0: {} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} - negotiator@0.6.3: {} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} - neo-async@2.6.2: {} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} - nodemon@3.1.14: - dependencies: - chokidar: 3.6.0 - debug: 4.4.3(supports-color@5.5.0) - ignore-by-default: 1.0.1 - minimatch: 10.2.5 - pstree.remy: 1.1.8 - semver: 7.8.0 - simple-update-notifier: 2.0.0 - supports-color: 5.5.0 - touch: 3.1.1 - undefsafe: 2.0.5 + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} - normalize-path@3.0.0: {} + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true - object-assign@4.1.1: {} + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true - object-inspect@1.13.4: {} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} - on-finished@2.3.0: - dependencies: - ee-first: 1.1.1 + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} - on-headers@1.1.0: {} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - once@1.4.0: - dependencies: - wrappy: 1.0.2 + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 + mocha-multi@1.1.7: + resolution: {integrity: sha512-SXZRgHy0XiRTASyOp0p6fjOkdj+R62L6cqutnYyQOvIjNznJuUwzykxctypeRiOwPd+gfn4yt3NRulMQyI8Tzg==} + engines: {node: '>=6.0.0'} + peerDependencies: + mocha: '>=2.2.0 <7 || >=9' - package-json-from-dist@1.0.1: {} + mocha@11.7.5: + resolution: {integrity: sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 + morgan@1.10.1: + resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==} + engines: {node: '>= 0.8.0'} - parse-json@5.2.0: - dependencies: - '@babel/code-frame': 7.29.0 - error-ex: 1.3.4 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - parseurl@1.3.3: {} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - path-exists@4.0.0: {} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - path-is-absolute@1.0.1: {} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true - path-key@3.1.1: {} + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.3 + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} - path-to-regexp@0.1.13: {} + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - pathval@1.1.1: {} + nodemon@3.1.14: + resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==} + engines: {node: '>=10'} + hasBin: true - picocolors@1.1.1: {} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} - picomatch@2.3.2: {} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} - prelude-ls@1.2.1: {} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} - prettier@3.8.3: {} + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} - pstree.remy@1.1.8: {} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} - punycode.js@2.3.1: {} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - punycode@2.3.1: {} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} - qs@6.15.1: - dependencies: - side-channel: 1.1.0 + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} - range-parser@1.2.1: {} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} - raw-body@2.5.3: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - unpipe: 1.0.0 + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - raw-body@3.0.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - unpipe: 1.0.0 + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} - readdirp@3.6.0: - dependencies: - picomatch: 2.3.2 + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} - readdirp@4.1.2: {} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} - require-directory@2.1.1: {} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} - require-from-string@2.0.2: {} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} - resolve-from@4.0.0: {} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} - resolve-from@5.0.0: {} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} - safe-buffer@5.1.2: {} + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} - safe-buffer@5.2.1: {} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - safer-buffer@2.1.2: {} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - sax@1.6.0: {} + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} - semver@7.8.0: {} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} - send@0.19.2: - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.1 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - serialize-javascript@7.0.5: {} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} - serve-static@1.16.3: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.2 - transitivePeerDependencies: - - supports-color - - setprototypeof@1.2.0: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - side-channel-list@1.0.1: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.1 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - signal-exit@4.1.0: {} - - simple-update-notifier@2.0.0: - dependencies: - semver: 7.8.0 - - source-map@0.6.1: {} - - statuses@2.0.2: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.2.0 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-ansi@7.2.0: - dependencies: - ansi-regex: 6.2.2 - - strip-json-comments@3.1.1: {} - - superagent@10.3.0: - dependencies: - component-emitter: 1.3.1 - cookiejar: 2.1.4 - debug: 4.4.3 - fast-safe-stringify: 2.1.1 - form-data: 4.0.5 - formidable: 3.5.4 - methods: 1.1.2 - mime: 2.6.0 - qs: 6.15.1 - transitivePeerDependencies: - - supports-color - - superagent@8.1.2: - dependencies: - component-emitter: 1.3.1 - cookiejar: 2.1.4 - debug: 4.4.3 - fast-safe-stringify: 2.1.1 - form-data: 4.0.5 - formidable: 2.1.5 - methods: 1.1.2 - mime: 2.6.0 - qs: 6.15.1 - semver: 7.8.0 - transitivePeerDependencies: - - supports-color - - supertest@7.2.2: - dependencies: - cookie-signature: 1.2.2 - methods: 1.1.2 - superagent: 10.3.0 - transitivePeerDependencies: - - supports-color - - supports-color@5.5.0: - dependencies: - has-flag: 3.0.0 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - - tinyexec@1.1.2: {} - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - toidentifier@1.0.1: {} - - touch@3.1.1: {} - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - type-detect@0.1.1: {} - - type-detect@1.0.0: {} - - type-detect@4.1.0: {} - - type-is@1.6.18: - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - - type-is@2.1.0: - dependencies: - content-type: 2.0.0 - media-typer: 1.1.0 - mime-types: 3.0.2 - - typescript@5.9.3: {} - - uc.micro@2.1.0: {} - - uglify-js@3.19.3: + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serialize-javascript@7.0.5: + resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} + engines: {node: '>=20.0.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + superagent@8.1.2: + resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} + engines: {node: '>=6.4.0 <13 || >=14'} + deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: optional: true + typescript: + optional: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@0.1.1: + resolution: {integrity: sha512-5rqszGVwYgBoDkIm2oUtvkfZMQ0vk29iDMU0W2qCa3rG0vPDNczCMT4hV/bLBgLg8k8ri6+u3Zbt+S/14eMzlA==} + + type-detect@1.0.0: + resolution: {integrity: sha512-f9Uv6ezcpvCQjJU0Zqbg+65qdcszv3qUQsZfjdRbWiZ7AMenrX1u0lNk9EoWWX6e1F+NULyg27mtdeZ5WhpljA==} + + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript-eslint@8.59.4: + resolution: {integrity: sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + workerpool@9.3.4: + resolution: {integrity: sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml2js@0.5.0: + resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} + engines: {node: '>=4.0.0'} + + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs-unparser@2.0.0: + resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} + engines: {node: '>=10'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@0.2.3': {} + + '@commitlint/cli@20.5.3(@types/node@25.8.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': + dependencies: + '@commitlint/format': 20.5.0 + '@commitlint/lint': 20.5.3 + '@commitlint/load': 20.5.3(@types/node@25.8.0)(typescript@5.9.3) + '@commitlint/read': 20.5.0(conventional-commits-parser@6.4.0) + '@commitlint/types': 20.5.0 + tinyexec: 1.1.2 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - conventional-commits-filter + - conventional-commits-parser + - typescript + + '@commitlint/config-conventional@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-conventionalcommits: 9.3.1 + + '@commitlint/config-validator@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + ajv: 8.20.0 + + '@commitlint/ensure@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + es-toolkit: 1.46.1 + + '@commitlint/execute-rule@20.0.0': {} + + '@commitlint/format@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + picocolors: 1.1.1 + + '@commitlint/is-ignored@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + semver: 7.8.0 + + '@commitlint/lint@20.5.3': + dependencies: + '@commitlint/is-ignored': 20.5.0 + '@commitlint/parse': 20.5.0 + '@commitlint/rules': 20.5.3 + '@commitlint/types': 20.5.0 + + '@commitlint/load@20.5.3(@types/node@25.8.0)(typescript@5.9.3)': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/execute-rule': 20.0.0 + '@commitlint/resolve-extends': 20.5.3 + '@commitlint/types': 20.5.0 + cosmiconfig: 9.0.1(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@25.8.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) + es-toolkit: 1.46.1 + is-plain-obj: 4.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@20.4.3': {} + + '@commitlint/parse@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-angular: 8.3.1 + conventional-commits-parser: 6.4.0 + + '@commitlint/read@20.5.0(conventional-commits-parser@6.4.0)': + dependencies: + '@commitlint/top-level': 20.4.3 + '@commitlint/types': 20.5.0 + git-raw-commits: 5.0.1(conventional-commits-parser@6.4.0) + minimist: 1.2.8 + tinyexec: 1.1.2 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + '@commitlint/resolve-extends@20.5.3': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/types': 20.5.0 + es-toolkit: 1.46.1 + global-directory: 5.0.0 + import-meta-resolve: 4.2.0 + resolve-from: 5.0.0 + + '@commitlint/rules@20.5.3': + dependencies: + '@commitlint/ensure': 20.5.3 + '@commitlint/message': 20.4.3 + '@commitlint/to-lines': 20.0.0 + '@commitlint/types': 20.5.0 + + '@commitlint/to-lines@20.0.0': {} + + '@commitlint/top-level@20.4.3': + dependencies: + escalade: 3.2.0 + + '@commitlint/types@20.5.0': + dependencies: + conventional-commits-parser: 6.4.0 + picocolors: 1.1.1 + + '@conventional-changelog/git-client@2.7.0(conventional-commits-parser@6.4.0)': + dependencies: + '@simple-libs/child-process-utils': 1.0.2 + '@simple-libs/stream-utils': 1.2.0 + semver: 7.8.0 + optionalDependencies: + conventional-commits-parser: 6.4.0 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.4.0(jiti@2.6.1))': + dependencies: + eslint: 10.4.0(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + dependencies: + eslint: 9.39.4(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@10.0.1(eslint@10.4.0(jiti@2.6.1))': + optionalDependencies: + eslint: 10.4.0(jiti@2.6.1) + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@eslint/plugin-kit@0.7.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@noble/hashes@1.8.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@simple-libs/child-process-utils@1.0.2': + dependencies: + '@simple-libs/stream-utils': 1.2.0 + + '@simple-libs/stream-utils@1.2.0': {} + + '@types/chai@4.3.20': {} + + '@types/cookiejar@2.1.5': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@22.19.19': + dependencies: + undici-types: 6.21.0 + + '@types/node@25.8.0': + dependencies: + undici-types: 7.24.6 + + '@types/superagent@4.1.13': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/node': 22.19.19 + + '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/type-utils': 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.4 + eslint: 9.39.4(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.4 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.4(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.9.3) + '@typescript-eslint/types': 8.59.4 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.4': + dependencies: + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 + + '@typescript-eslint/tsconfig-utils@8.59.4(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.4': {} + + '@typescript-eslint/typescript-estree@8.59.4(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.4(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.9.3) + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.0 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.4': + dependencies: + '@typescript-eslint/types': 8.59.4 + eslint-visitor-keys: 5.0.1 + + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@22.19.19))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 1.2.0 + vitest: 2.1.9(@types/node@22.19.19) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.19))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.19) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + argparse@2.0.1: {} + + array-flatten@1.1.1: {} + + array-ify@1.0.0: {} + + asap@2.0.6: {} + + assertion-error@1.1.0: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + + binary-extensions@2.3.0: {} + + body-parser@1.20.5: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browser-stdout@1.3.1: {} + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + bytes@3.1.2: {} + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@6.3.0: {} + + chai-http@4.4.0: + dependencies: + '@types/chai': 4.3.20 + '@types/superagent': 4.1.13 + charset: 1.0.1 + cookiejar: 2.1.4 + is-ip: 2.0.0 + methods: 1.1.2 + qs: 6.15.1 + superagent: 8.1.2 + transitivePeerDependencies: + - supports-color + + chai-json@1.0.0: + dependencies: + chai: 3.5.0 + jsonfile: 3.0.1 + underscore: 1.13.8 + + chai-xml@0.4.1(chai@4.5.0): + dependencies: + chai: 4.5.0 + xml2js: 0.5.0 + + chai@3.5.0: + dependencies: + assertion-error: 1.1.0 + deep-eql: 0.1.3 + type-detect: 1.0.0 + + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + charset@1.0.1: {} + + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + + check-error@2.1.3: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@4.1.1: {} + + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + + component-emitter@1.3.1: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + conventional-changelog-angular@8.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@9.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-commits-parser@6.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + meow: 13.2.0 + + cookie-signature@1.0.7: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig-typescript-loader@6.3.0(@types/node@25.8.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): + dependencies: + '@types/node': 25.8.0 + cosmiconfig: 9.0.1(typescript@5.9.3) + jiti: 2.6.1 + typescript: 5.9.3 + + cosmiconfig@9.0.1(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + dayjs@1.11.20: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + debug@4.4.3(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decamelize@4.0.0: {} + + deep-eql@0.1.3: + dependencies: + type-detect: 0.1.1 + + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + diff@7.0.0: {} + + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + dotenv@17.4.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ee-first@1.1.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encodeurl@2.0.0: {} + + entities@4.5.0: {} + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + es-toolkit@1.46.1: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.4.0(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + eslint@9.39.4(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + expect-type@1.3.0: {} + + express-handlebars@5.3.5: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + handlebars: 4.7.9 + + express@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.5 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.15.1 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.2: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.60.4 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flat@5.0.2: {} + + flatted@3.4.2: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + formidable@2.1.5: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + qs: 6.15.1 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-caller-file@2.0.5: {} + + get-func-name@2.0.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + git-raw-commits@5.0.1(conventional-commits-parser@6.4.0): + dependencies: + '@conventional-changelog/git-client': 2.7.0(conventional-commits-parser@6.4.0) + meow: 13.2.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + global-directory@5.0.0: + dependencies: + ini: 6.0.0 + + globals@14.0.0: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + html-escaper@2.0.2: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https@1.0.0: {} + + husky@9.1.7: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ignore-by-default@1.0.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-meta-resolve@4.2.0: {} + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@6.0.0: {} + + ip-regex@2.1.0: {} + + ipaddr.js@1.9.1: {} + + is-arrayish@0.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-ip@2.0.0: + dependencies: + ip-regex: 2.1.0 + + is-number@7.0.0: {} + + is-obj@2.0.0: {} + + is-path-inside@3.0.3: {} + + is-plain-obj@2.1.0: {} + + is-plain-obj@4.1.0: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-unicode-supported@0.1.0: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@2.6.1: {} + + joycon@3.1.1: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + jsonfile@3.0.1: + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + load-tsconfig@0.2.5: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.0 + + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + math-intrinsics@1.1.0: {} + + mdurl@2.0.0: {} + + media-typer@0.3.0: {} + + media-typer@1.1.0: {} + + meow@13.2.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@1.6.0: {} + + mime@2.6.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + mkdirp@1.0.4: {} + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + mocha-multi@1.1.7(mocha@11.7.5): + dependencies: + debug: 4.4.3 + is-string: 1.1.1 + lodash.once: 4.1.1 + mkdirp: 1.0.4 + mocha: 11.7.5 + object-assign: 4.1.1 + transitivePeerDependencies: + - supports-color + + mocha@11.7.5: + dependencies: + browser-stdout: 1.3.1 + chokidar: 4.0.3 + debug: 4.4.3(supports-color@8.1.1) + diff: 7.0.0 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 10.5.0 + he: 1.2.0 + is-path-inside: 3.0.3 + js-yaml: 4.1.1 + log-symbols: 4.1.0 + minimatch: 9.0.9 + ms: 2.1.3 + picocolors: 1.1.1 + serialize-javascript: 7.0.5 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + workerpool: 9.3.4 + yargs: 17.7.2 + yargs-parser: 21.1.1 + yargs-unparser: 2.0.0 + + morgan@1.10.1: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 2.0.0 + on-finished: 2.3.0 + on-headers: 1.1.0 + transitivePeerDependencies: + - supports-color + + ms@2.0.0: {} + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.12: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + neo-async@2.6.2: {} + + nodemon@3.1.14: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 10.2.5 + pstree.remy: 1.1.8 + semver: 7.8.0 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} - undefsafe@2.0.5: {} + path-is-absolute@1.0.1: {} - underscore@1.13.8: {} + path-key@3.1.1: {} - undici-types@7.24.6: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 - unpipe@1.0.0: {} + path-to-regexp@0.1.13: {} - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 + pathe@1.1.2: {} - utils-merge@1.0.1: {} + pathe@2.0.3: {} - vary@1.1.2: {} + pathval@1.1.1: {} - which@2.0.2: - dependencies: - isexe: 2.0.0 + pathval@2.0.1: {} - word-wrap@1.2.5: {} + picocolors@1.1.1: {} - wordwrap@1.0.0: {} + picomatch@2.3.2: {} - workerpool@9.3.4: {} + picomatch@4.0.4: {} - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 + pirates@4.0.7: {} - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.2.0 + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 - wrappy@1.0.2: {} + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.15): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 2.6.1 + postcss: 8.5.15 - ws@8.20.1: {} + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 - xml2js@0.5.0: - dependencies: - sax: 1.6.0 - xmlbuilder: 11.0.1 + prelude-ls@1.2.1: {} - xml2js@0.6.2: - dependencies: - sax: 1.6.0 - xmlbuilder: 11.0.1 + prettier@3.8.3: {} - xmlbuilder@11.0.1: {} + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 - xmlbuilder@15.1.1: {} + pstree.remy@1.1.8: {} - y18n@5.0.8: {} + punycode.js@2.3.1: {} - yargs-parser@21.1.1: {} + punycode@2.3.1: {} + + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 - yargs-unparser@2.0.0: - dependencies: - camelcase: 6.3.0 - decamelize: 4.0.0 - flat: 5.0.2 - is-plain-obj: 2.1.0 + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 - yocto-queue@0.1.0: {} + readdirp@4.1.2: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + sax@1.6.0: {} + + semver@7.8.0: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serialize-javascript@7.0.5: {} + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.8.0 + + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + source-map@0.7.6: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-json-comments@3.1.1: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.16 + ts-interface-checker: 0.1.13 + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.1 + transitivePeerDependencies: + - supports-color + + superagent@8.1.2: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 2.1.5 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.1 + semver: 7.8.0 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.5 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyexec@1.1.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + touch@3.1.1: {} + + tree-kill@1.2.2: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.15)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.15) + resolve-from: 5.0.0 + rollup: 4.60.4 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.15 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@0.1.1: {} + + type-detect@1.0.0: {} + + type-detect@4.1.0: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript-eslint@8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + uc.micro@2.1.0: {} + + ufo@1.6.4: {} + + uglify-js@3.19.3: + optional: true + + undefsafe@2.0.5: {} + + underscore@1.13.8: {} + + undici-types@6.21.0: {} + + undici-types@7.24.6: {} + + unpipe@1.0.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + utils-merge@1.0.1: {} + + vary@1.1.2: {} + + vite-node@2.1.9(@types/node@22.19.19): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.19.19) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.19.19): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.15 + rollup: 4.60.4 + optionalDependencies: + '@types/node': 22.19.19 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@22.19.19): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.19.19) + vite-node: 2.1.9(@types/node@22.19.19) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.19 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + + workerpool@9.3.4: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + ws@8.20.1: {} + + xml2js@0.5.0: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + + xml2js@0.6.2: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + xmlbuilder@15.1.1: {} + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs-unparser@2.0.0: + dependencies: + camelcase: 6.3.0 + decamelize: 4.0.0 + flat: 5.0.2 + is-plain-obj: 2.1.0 + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d93aee4..1e13144 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - 'apps/*' + - 'packages/*' diff --git a/release-please-config.json b/release-please-config.json index 9ba67df..2d27a62 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -5,6 +5,12 @@ "release-type": "node", "component": "server", "changelog-path": "CHANGELOG.md" + }, + "packages/core": { + "release-type": "node", + "component": "core", + "changelog-path": "CHANGELOG.md", + "package-name": "@rsscloud/core" } } } From 1372c9487c2bd61fa609cb10fc163eaa92fcfd62 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sun, 24 May 2026 13:48:20 -0500 Subject: [PATCH 03/90] style(server): apply eslint --fix to align code with config rules Existing apps/server code diverged from its eslint.config.js rules (space-before-function-paren: never, brace-style: 1tbs switch-case indentation). Running eslint --fix reformats source to match. No behavior changes; purely whitespace. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/server/app.js | 16 +-- apps/server/client.js | 18 ++-- apps/server/controllers/docs.js | 42 ++++---- apps/server/controllers/home.js | 14 +-- apps/server/controllers/index.js | 2 +- apps/server/controllers/ping-form.js | 14 +-- apps/server/controllers/ping.js | 22 ++--- apps/server/controllers/please-notify-form.js | 14 +-- apps/server/controllers/please-notify.js | 26 ++--- apps/server/controllers/rpc2.js | 98 +++++++++---------- apps/server/controllers/stats.js | 2 +- apps/server/controllers/test.js | 2 +- apps/server/controllers/view-log.js | 2 +- apps/server/services/app-messages.js | 2 +- apps/server/services/parse-notify-params.js | 16 +-- apps/server/services/parse-rpc-request.js | 84 ++++++++-------- apps/server/services/stats.js | 2 +- apps/server/test/fixtures.js | 4 +- apps/server/test/mock.js | 12 +-- apps/server/test/ping.js | 32 +++--- apps/server/test/please-notify.js | 38 +++---- .../test/remove-expired-subscriptions.js | 28 +++--- apps/server/test/static.js | 12 +-- apps/server/test/store-api.js | 22 ++--- 24 files changed, 262 insertions(+), 262 deletions(-) diff --git a/apps/server/app.js b/apps/server/app.js index d0aa10e..fd21254 100644 --- a/apps/server/app.js +++ b/apps/server/app.js @@ -26,7 +26,7 @@ function scheduleCleanupTasks() { // Run subscription cleanup every 24 hours setInterval( - async () => { + async() => { try { console.log('Running scheduled subscription cleanup...'); await removeExpiredSubscriptions(); @@ -150,13 +150,13 @@ async function startServer() { }) .on('error', error => { switch (error.code) { - case 'EADDRINUSE': - console.log( - `Error: Port ${config.port} is already in use.` - ); - break; - default: - console.log(error.code); + case 'EADDRINUSE': + console.log( + `Error: Port ${config.port} is already in use.` + ); + break; + default: + console.log(error.code); } }); } diff --git a/apps/server/client.js b/apps/server/client.js index c2d37f7..cd0f450 100644 --- a/apps/server/client.js +++ b/apps/server/client.js @@ -353,7 +353,7 @@ app.get('/', (req, res) => { }); // Route: Subscribe to feed notifications -app.post('/subscribe', urlencodedParser, async (req, res) => { +app.post('/subscribe', urlencodedParser, async(req, res) => { const feedName = req.body.feedName || 'rss-01.xml'; const useXmlRpc = req.body.xmlrpc === 'on'; const feedUrl = `http://${clientConfig.domain}:${clientConfig.port}/${feedName}`; @@ -428,7 +428,7 @@ app.post('/subscribe', urlencodedParser, async (req, res) => { }); // Route: Ping feed (add item and notify) -app.post('/ping-feed', urlencodedParser, async (req, res) => { +app.post('/ping-feed', urlencodedParser, async(req, res) => { const feedName = req.body.feedName || 'rss-01.xml'; const useXmlRpc = req.body.xmlrpc === 'on'; const feedUrl = `http://${clientConfig.domain}:${clientConfig.port}/${feedName}`; @@ -557,12 +557,12 @@ server = app }) .on('error', error => { switch (error.code) { - case 'EADDRINUSE': - console.log( - `Error: Port ${clientConfig.port} is already in use.` - ); - break; - default: - console.log(error.code); + case 'EADDRINUSE': + console.log( + `Error: Port ${clientConfig.port} is already in use.` + ); + break; + default: + console.log(error.code); } }); diff --git a/apps/server/controllers/docs.js b/apps/server/controllers/docs.js index d40d80d..346d62a 100644 --- a/apps/server/controllers/docs.js +++ b/apps/server/controllers/docs.js @@ -5,28 +5,28 @@ const express = require('express'), router.get('/', (req, res) => { switch (req.accepts('html')) { - case 'html': { - try { - // README keeps its own "# rssCloud Server" heading for GitHub, - // but the docs page uses a consistent "Documentation" header, - // so drop the leading H1 from the rendered output. - const htmltext = md - .render(fs.readFileSync('README.md', { encoding: 'utf8' })) - .replace(/]*>[\s\S]*?<\/h1>\s*/i, ''); - res.render('docs', { - title: 'rssCloud Server: Documentation', - heading: 'rssCloud Server: Documentation', - htmltext - }); - } catch (err) { - console.error('Error reading README.md:', err.message); - res.status(500).send('Internal Server Error'); - } - break; + case 'html': { + try { + // README keeps its own "# rssCloud Server" heading for GitHub, + // but the docs page uses a consistent "Documentation" header, + // so drop the leading H1 from the rendered output. + const htmltext = md + .render(fs.readFileSync('README.md', { encoding: 'utf8' })) + .replace(/]*>[\s\S]*?<\/h1>\s*/i, ''); + res.render('docs', { + title: 'rssCloud Server: Documentation', + heading: 'rssCloud Server: Documentation', + htmltext + }); + } catch (err) { + console.error('Error reading README.md:', err.message); + res.status(500).send('Internal Server Error'); } - default: - res.status(406).send('Not Acceptable'); - break; + break; + } + default: + res.status(406).send('Not Acceptable'); + break; } }); diff --git a/apps/server/controllers/home.js b/apps/server/controllers/home.js index 2275865..eba138c 100644 --- a/apps/server/controllers/home.js +++ b/apps/server/controllers/home.js @@ -1,14 +1,14 @@ const express = require('express'), router = new express.Router(); -router.get('/', function (req, res) { +router.get('/', function(req, res) { switch (req.accepts('html')) { - case 'html': - res.render('home'); - break; - default: - res.status(406).send('Not Acceptable'); - break; + case 'html': + res.render('home'); + break; + default: + res.status(406).send('Not Acceptable'); + break; } }); diff --git a/apps/server/controllers/index.js b/apps/server/controllers/index.js index 58d0f2f..9597378 100644 --- a/apps/server/controllers/index.js +++ b/apps/server/controllers/index.js @@ -44,7 +44,7 @@ router.get('/subscriptions.json', (req, res) => { res.send(JSON.stringify(jsonStore.getData(), null, 2)); }); -router.get('/feeds.opml', async (req, res, next) => { +router.get('/feeds.opml', async(req, res, next) => { try { const dayjs = await getDayjs(); const nowIso = dayjs().utc().format(); diff --git a/apps/server/controllers/ping-form.js b/apps/server/controllers/ping-form.js index 72286f8..5e5397c 100644 --- a/apps/server/controllers/ping-form.js +++ b/apps/server/controllers/ping-form.js @@ -1,14 +1,14 @@ const express = require('express'), router = new express.Router(); -router.get('/', function (req, res) { +router.get('/', function(req, res) { switch (req.accepts('html')) { - case 'html': - res.render('ping-form'); - break; - default: - res.status(406).send('Not Acceptable'); - break; + case 'html': + res.render('ping-form'); + break; + default: + res.status(406).send('Not Acceptable'); + break; } }); diff --git a/apps/server/controllers/ping.js b/apps/server/controllers/ping.js index 2a242d3..edf5932 100644 --- a/apps/server/controllers/ping.js +++ b/apps/server/controllers/ping.js @@ -10,16 +10,16 @@ const bodyParser = require('body-parser'), function processResponse(req, res, result) { switch (req.accepts('xml', 'json')) { - case 'xml': - res.set('Content-Type', 'text/xml'); - res.send(restReturnSuccess(result.success, result.msg, 'result')); - break; - case 'json': - res.json(result); - break; - default: - res.status(406).send('Not Acceptable'); - break; + case 'xml': + res.set('Content-Type', 'text/xml'); + res.send(restReturnSuccess(result.success, result.msg, 'result')); + break; + case 'json': + res.json(result); + break; + default: + res.status(406).send('Not Acceptable'); + break; } } @@ -30,7 +30,7 @@ function handleError(req, res, err) { processResponse(req, res, errorResult(err.message)); } -router.post('/', urlencodedParser, async (req, res) => { +router.post('/', urlencodedParser, async(req, res) => { try { const params = parsePingParams.rest(req); const result = await ping(params.url); diff --git a/apps/server/controllers/please-notify-form.js b/apps/server/controllers/please-notify-form.js index 34c706a..657ceb3 100644 --- a/apps/server/controllers/please-notify-form.js +++ b/apps/server/controllers/please-notify-form.js @@ -1,14 +1,14 @@ const express = require('express'), router = new express.Router(); -router.get('/', function (req, res) { +router.get('/', function(req, res) { switch (req.accepts('html')) { - case 'html': - res.render('please-notify-form'); - break; - default: - res.status(406).send('Not Acceptable'); - break; + case 'html': + res.render('please-notify-form'); + break; + default: + res.status(406).send('Not Acceptable'); + break; } }); diff --git a/apps/server/controllers/please-notify.js b/apps/server/controllers/please-notify.js index b664785..f624803 100644 --- a/apps/server/controllers/please-notify.js +++ b/apps/server/controllers/please-notify.js @@ -10,18 +10,18 @@ const bodyParser = require('body-parser'), function processResponse(req, res, result) { switch (req.accepts('xml', 'json')) { - case 'xml': - res.set('Content-Type', 'text/xml'); - res.send( - restReturnSuccess(result.success, result.msg, 'notifyResult') - ); - break; - case 'json': - res.json(result); - break; - default: - res.status(406).send('Not Acceptable'); - break; + case 'xml': + res.set('Content-Type', 'text/xml'); + res.send( + restReturnSuccess(result.success, result.msg, 'notifyResult') + ); + break; + case 'json': + res.json(result); + break; + default: + res.status(406).send('Not Acceptable'); + break; } } @@ -32,7 +32,7 @@ function handleError(req, res, err) { processResponse(req, res, errorResult(err.message)); } -router.post('/', urlencodedParser, async function (req, res) { +router.post('/', urlencodedParser, async function(req, res) { try { const params = parseNotifyParams.rest(req); const result = await pleaseNotify( diff --git a/apps/server/controllers/rpc2.js b/apps/server/controllers/rpc2.js index 74681bd..6d67ee5 100644 --- a/apps/server/controllers/rpc2.js +++ b/apps/server/controllers/rpc2.js @@ -15,13 +15,13 @@ const bodyParser = require('body-parser'), function processResponse(req, res, xmlString) { switch (req.accepts('xml')) { - case 'xml': - res.set('Content-Type', 'text/xml'); - res.send(xmlString); - break; - default: - res.status(406).send('Not Acceptable'); - break; + case 'xml': + res.set('Content-Type', 'text/xml'); + res.send(xmlString); + break; + default: + res.status(406).send('Not Acceptable'); + break; } } @@ -32,7 +32,7 @@ function handleError(req, res, err) { processResponse(req, res, rpcReturnFault(4, err.message)); } -router.post('/', textParser, async function (req, res) { +router.post('/', textParser, async function(req, res) { let params; const dayjs = await getDayjs(); @@ -49,50 +49,50 @@ router.post('/', textParser, async function (req, res) { ); switch (request.methodName) { - case 'rssCloud.hello': - processResponse(req, res, rpcReturnSuccess(true)); - break; - case 'rssCloud.pleaseNotify': + case 'rssCloud.hello': + processResponse(req, res, rpcReturnSuccess(true)); + break; + case 'rssCloud.pleaseNotify': + try { + params = parseNotifyParams.rpc(req, request.params); + const result = await pleaseNotify( + params.notifyProcedure, + params.apiurl, + params.protocol, + params.urlList, + params.diffDomain + ); + processResponse(req, res, rpcReturnSuccess(result.success)); + } catch (err) { + handleError(req, res, err); + } + break; + case 'rssCloud.ping': + try { + params = parsePingParams.rpc(req, request.params); + // Dave's rssCloud server always returns true whether it succeeded or not try { - params = parseNotifyParams.rpc(req, request.params); - const result = await pleaseNotify( - params.notifyProcedure, - params.apiurl, - params.protocol, - params.urlList, - params.diffDomain + const result = await ping(params.url); + processResponse( + req, + res, + rpcReturnSuccess(result.success) ); - processResponse(req, res, rpcReturnSuccess(result.success)); - } catch (err) { - handleError(req, res, err); + } catch { + processResponse(req, res, rpcReturnSuccess(true)); } - break; - case 'rssCloud.ping': - try { - params = parsePingParams.rpc(req, request.params); - // Dave's rssCloud server always returns true whether it succeeded or not - try { - const result = await ping(params.url); - processResponse( - req, - res, - rpcReturnSuccess(result.success) - ); - } catch { - processResponse(req, res, rpcReturnSuccess(true)); - } - } catch (err) { - handleError(req, res, err); - } - break; - default: - handleError( - req, - res, - new Error( - `Can't make the call because "${request.methodName}" is not defined.` - ) - ); + } catch (err) { + handleError(req, res, err); + } + break; + default: + handleError( + req, + res, + new Error( + `Can't make the call because "${request.methodName}" is not defined.` + ) + ); } } catch (err) { handleError(req, res, err); diff --git a/apps/server/controllers/stats.js b/apps/server/controllers/stats.js index 63dcd73..4a879d9 100644 --- a/apps/server/controllers/stats.js +++ b/apps/server/controllers/stats.js @@ -2,7 +2,7 @@ const express = require('express'), { getStats } = require('../services/stats'), router = new express.Router(); -router.get('/', function (req, res) { +router.get('/', function(req, res) { const stats = getStats(); res.render('stats', stats); }); diff --git a/apps/server/controllers/test.js b/apps/server/controllers/test.js index 46b2023..ceb634e 100644 --- a/apps/server/controllers/test.js +++ b/apps/server/controllers/test.js @@ -70,7 +70,7 @@ router.post('/getData', (req, res) => { } }); -router.post('/removeExpired', async (req, res) => { +router.post('/removeExpired', async(req, res) => { try { const result = await removeExpiredSubscriptions(); res.json({ success: true, result }); diff --git a/apps/server/controllers/view-log.js b/apps/server/controllers/view-log.js index e601bf0..b948e9b 100644 --- a/apps/server/controllers/view-log.js +++ b/apps/server/controllers/view-log.js @@ -1,7 +1,7 @@ const express = require('express'), router = new express.Router(); -router.get('/', function (req, res) { +router.get('/', function(req, res) { const wsProtocol = req.protocol === 'https' ? 'wss' : 'ws'; const wsUrl = `${wsProtocol}://${req.get('host')}/wsLog`; res.render('view-log', { diff --git a/apps/server/services/app-messages.js b/apps/server/services/app-messages.js index 21ce56d..7cdff22 100644 --- a/apps/server/services/app-messages.js +++ b/apps/server/services/app-messages.js @@ -26,7 +26,7 @@ module.exports = { }, success: { subscription: - "Thanks for the registration. It worked. When the resource updates we'll notify you. Don't forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!", + 'Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!', ping: 'Thanks for the ping.' } }; diff --git a/apps/server/services/parse-notify-params.js b/apps/server/services/parse-notify-params.js index b2068fe..57831c9 100644 --- a/apps/server/services/parse-notify-params.js +++ b/apps/server/services/parse-notify-params.js @@ -3,14 +3,14 @@ const appMessages = require('./app-messages'), function validProtocol(protocol) { switch (protocol) { - case 'http-post': - case 'https-post': - case 'xml-rpc': - return true; - default: - throw new ErrorResponse( - appMessages.error.subscription.invalidProtocol(protocol) - ); + case 'http-post': + case 'https-post': + case 'xml-rpc': + return true; + default: + throw new ErrorResponse( + appMessages.error.subscription.invalidProtocol(protocol) + ); } } diff --git a/apps/server/services/parse-rpc-request.js b/apps/server/services/parse-rpc-request.js index 3a6ef40..8c6f3ab 100644 --- a/apps/server/services/parse-rpc-request.js +++ b/apps/server/services/parse-rpc-request.js @@ -8,48 +8,48 @@ async function parseRpcParam(param, dayjs) { for (tag in value) { switch (tag) { - case 'i4': - case 'int': - case 'double': - returnedValue = Number(value[tag]); - break; - case 'string': - returnedValue = value[tag]; - break; - case 'boolean': - returnedValue = 'true' === value[tag] || !!Number(value[tag]); - break; - case 'dateTime.iso8601': - returnedValue = dayjs.utc(value[tag], [ - 'YYYYMMDDTHHmmss', - dayjs.ISO_8601 - ]); - break; - case 'base64': - returnedValue = Buffer.from(value[tag], 'base64').toString( - 'utf8' - ); - break; - case 'struct': - member = value[tag].member || []; - if (!Array.isArray(member)) { - member = [member]; - } - returnedValue = {}; - for (const item of member) { - returnedValue[item.name] = await parseRpcParam(item, dayjs); - } - break; - case 'array': - values = (value[tag].data || {}).value || []; - if (!Array.isArray(values)) { - values = [values]; - } - returnedValue = []; - for (const item of values) { - returnedValue.push(await parseRpcParam(item, dayjs)); - } - break; + case 'i4': + case 'int': + case 'double': + returnedValue = Number(value[tag]); + break; + case 'string': + returnedValue = value[tag]; + break; + case 'boolean': + returnedValue = 'true' === value[tag] || !!Number(value[tag]); + break; + case 'dateTime.iso8601': + returnedValue = dayjs.utc(value[tag], [ + 'YYYYMMDDTHHmmss', + dayjs.ISO_8601 + ]); + break; + case 'base64': + returnedValue = Buffer.from(value[tag], 'base64').toString( + 'utf8' + ); + break; + case 'struct': + member = value[tag].member || []; + if (!Array.isArray(member)) { + member = [member]; + } + returnedValue = {}; + for (const item of member) { + returnedValue[item.name] = await parseRpcParam(item, dayjs); + } + break; + case 'array': + values = (value[tag].data || {}).value || []; + if (!Array.isArray(values)) { + values = [values]; + } + returnedValue = []; + for (const item of values) { + returnedValue.push(await parseRpcParam(item, dayjs)); + } + break; } } diff --git a/apps/server/services/stats.js b/apps/server/services/stats.js index 2a74cd4..d6b29d9 100644 --- a/apps/server/services/stats.js +++ b/apps/server/services/stats.js @@ -121,7 +121,7 @@ async function generateStats() { } function scheduleStatsGeneration() { - setInterval(async () => { + setInterval(async() => { try { await generateStats(); } catch (error) { diff --git a/apps/server/test/fixtures.js b/apps/server/test/fixtures.js index 3b15cc6..8b05fe3 100644 --- a/apps/server/test/fixtures.js +++ b/apps/server/test/fixtures.js @@ -1,9 +1,9 @@ const storeApi = require('./store-api'); -exports.mochaGlobalSetup = async function () { +exports.mochaGlobalSetup = async function() { await storeApi.before(); }; -exports.mochaGlobalTeardown = async function () { +exports.mochaGlobalTeardown = async function() { await storeApi.after(); }; diff --git a/apps/server/test/mock.js b/apps/server/test/mock.js index b58a2a9..742b2ff 100644 --- a/apps/server/test/mock.js +++ b/apps/server/test/mock.js @@ -90,21 +90,21 @@ module.exports = { POST: {}, RPC2: {} }, - route: function (method, path, status, responseBody) { + route: function(method, path, status, responseBody) { this.requests[method][path] = []; this.routes[method][path] = { status, responseBody }; }, - rpc: function (methodName, responseBody) { + rpc: function(methodName, responseBody) { const method = 'RPC2'; this.requests[method][methodName] = []; this.routes[method][methodName] = { responseBody }; }, - before: async function () { + before: async function() { this.app.post('/RPC2', textParser, rpcController.bind(this)); this.app.get('*', restController.bind(this)); this.app.post('*', urlencodedParser, restController.bind(this)); @@ -125,7 +125,7 @@ module.exports = { ` → Mock secure server started on port: ${SECURE_MOCK_SERVER_PORT}` ); }, - after: async function () { + after: async function() { if (this.server) { this.server.close(); delete this.server; @@ -140,10 +140,10 @@ module.exports = { }; } }, - beforeEach: async function () { + beforeEach: async function() { // Nothing }, - afterEach: async function () { + afterEach: async function() { this.requests = { GET: {}, POST: {}, diff --git a/apps/server/test/ping.js b/apps/server/test/ping.js index 7bc421b..ef27abf 100644 --- a/apps/server/test/ping.js +++ b/apps/server/test/ping.js @@ -49,26 +49,26 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { continue; } - describe(`Ping ${pingProtocol} to ${protocol} returning ${returnFormat}`, function () { - before(async function () { + describe(`Ping ${pingProtocol} to ${protocol} returning ${returnFormat}`, function() { + before(async function() { await mock.before(); }); - after(async function () { + after(async function() { await mock.after(); }); - beforeEach(async function () { + beforeEach(async function() { await storeApi.beforeEach(); await mock.beforeEach(); }); - afterEach(async function () { + afterEach(async function() { await storeApi.afterEach(); await mock.afterEach(); }); - it('should accept a ping for new resource', async function () { + it('should accept a ping for new resource', async function() { const feedPath = '/rss.xml', pingPath = '/feedupdated', resourceUrl = mock.serverUrl + feedPath; @@ -156,7 +156,7 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { } }); - it('should ping multiple subscribers on same domain', async function () { + it('should ping multiple subscribers on same domain', async function() { const feedPath = '/rss.xml', pingPath1 = '/feedupdated1', pingPath2 = '/feedupdated2', @@ -273,7 +273,7 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { ); }); - it('should reject a ping for bad resource', async function () { + it('should reject a ping for bad resource', async function() { const feedPath = '/rss.xml', pingPath = '/feedupdated', resourceUrl = mock.serverUrl + feedPath; @@ -345,7 +345,7 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { } }); - it('should reject a ping with a missing url', async function () { + it('should reject a ping with a missing url', async function() { const feedPath = '/rss.xml', pingPath = '/feedupdated', resourceUrl = null; @@ -411,7 +411,7 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { } }); - it('should accept a ping for unchanged resource', async function () { + it('should accept a ping for unchanged resource', async function() { const feedPath = '/rss.xml', pingPath = '/feedupdated', resourceUrl = mock.serverUrl + feedPath; @@ -502,7 +502,7 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { } }); - it('should accept a ping with slow subscribers', async function () { + it('should accept a ping with slow subscribers', async function() { this.timeout(5000); const feedPath = '/rss.xml', @@ -521,8 +521,8 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { } function slowPostResponse(_req) { - return new Promise(function (resolve) { - global.setTimeout(function () { + return new Promise(function(resolve) { + global.setTimeout(function() { resolve('Thanks for the update! :-)'); }, 1000); }); @@ -613,7 +613,7 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { } }); - it('should not notify expired subscribers', async function () { + it('should not notify expired subscribers', async function() { const feedPath = '/rss.xml', pingPath = '/feedupdated', resourceUrl = mock.serverUrl + feedPath; @@ -694,7 +694,7 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { } }); - it('should not notify subscribers with excessive errors', async function () { + it('should not notify subscribers with excessive errors', async function() { const feedPath = '/rss.xml', pingPath = '/feedupdated', resourceUrl = mock.serverUrl + feedPath; @@ -772,7 +772,7 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { } }); - it('should consider a very slow subscription an error', async function () { + it('should consider a very slow subscription an error', async function() { const feedPath = '/rss.xml', pingPath = '/feedupdated', resourceUrl = mock.serverUrl + feedPath; diff --git a/apps/server/test/please-notify.js b/apps/server/test/please-notify.js index a1f43ad..944b5d8 100644 --- a/apps/server/test/please-notify.js +++ b/apps/server/test/please-notify.js @@ -43,28 +43,28 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { continue; } - describe(`PleaseNotify ${pingProtocol} to ${protocol} returning ${returnFormat}`, function () { - before(async function () { + describe(`PleaseNotify ${pingProtocol} to ${protocol} returning ${returnFormat}`, function() { + before(async function() { await storeApi.before(); await mock.before(); }); - after(async function () { + after(async function() { await storeApi.after(); await mock.after(); }); - beforeEach(async function () { + beforeEach(async function() { await storeApi.beforeEach(); await mock.beforeEach(); }); - afterEach(async function () { + afterEach(async function() { await storeApi.afterEach(); await mock.afterEach(); }); - it('should accept a pleaseNotify for new resource', async function () { + it('should accept a pleaseNotify for new resource', async function() { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath; @@ -121,7 +121,7 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { if ('JSON' === returnFormat) { expect(res.body).deep.equal({ success: true, - msg: "Thanks for the registration. It worked. When the resource updates we'll notify you. Don't forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!" + msg: 'Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!' }); } else { expect(res.text).xml.equal( @@ -161,7 +161,7 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { expect(resDoc).to.have.property('lastSize'); }); - it('should accept a pleaseNotify without domain for new resource', async function () { + it('should accept a pleaseNotify without domain for new resource', async function() { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath; @@ -219,7 +219,7 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { if ('JSON' === returnFormat) { expect(res.body).deep.equal({ success: true, - msg: "Thanks for the registration. It worked. When the resource updates we'll notify you. Don't forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!" + msg: 'Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!' }); } else { expect(res.text).xml.equal( @@ -253,7 +253,7 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { } }); - it('should reject a pleaseNotify for bad resource', async function () { + it('should reject a pleaseNotify for bad resource', async function() { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath; @@ -349,28 +349,28 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { continue; } - describe(`PleaseNotify ${pingProtocol} to ${protocol} via redirect returning ${returnFormat}`, function () { - before(async function () { + describe(`PleaseNotify ${pingProtocol} to ${protocol} via redirect returning ${returnFormat}`, function() { + before(async function() { await storeApi.before(); await mock.before(); }); - after(async function () { + after(async function() { await storeApi.after(); await mock.after(); }); - beforeEach(async function () { + beforeEach(async function() { await storeApi.beforeEach(); await mock.beforeEach(); }); - afterEach(async function () { + afterEach(async function() { await storeApi.afterEach(); await mock.afterEach(); }); - it('should accept a pleaseNotify for a redirected subscriber', async function () { + it('should accept a pleaseNotify for a redirected subscriber', async function() { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath; @@ -425,7 +425,7 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { if ('JSON' === returnFormat) { expect(res.body).deep.equal({ success: true, - msg: "Thanks for the registration. It worked. When the resource updates we'll notify you. Don't forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!" + msg: 'Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!' }); } else { expect(res.text).xml.equal( @@ -442,7 +442,7 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { .lengthOf(1, `Missing GET ${pingPath}`); }); - it('should accept a pleaseNotify without domain for a redirected subscriber', async function () { + it('should accept a pleaseNotify without domain for a redirected subscriber', async function() { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath; @@ -498,7 +498,7 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { if ('JSON' === returnFormat) { expect(res.body).deep.equal({ success: true, - msg: "Thanks for the registration. It worked. When the resource updates we'll notify you. Don't forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!" + msg: 'Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!' }); } else { expect(res.text).xml.equal( diff --git a/apps/server/test/remove-expired-subscriptions.js b/apps/server/test/remove-expired-subscriptions.js index ab49b7b..cba0c11 100644 --- a/apps/server/test/remove-expired-subscriptions.js +++ b/apps/server/test/remove-expired-subscriptions.js @@ -5,28 +5,28 @@ const chai = require('chai'), mock = require('./mock'), storeApi = require('./store-api'); -describe('RemoveExpiredSubscriptions', function () { - before(async function () { +describe('RemoveExpiredSubscriptions', function() { + before(async function() { await storeApi.before(); await mock.before(); }); - after(async function () { + after(async function() { await storeApi.after(); await mock.after(); }); - beforeEach(async function () { + beforeEach(async function() { await storeApi.beforeEach(); await mock.beforeEach(); }); - afterEach(async function () { + afterEach(async function() { await storeApi.afterEach(); await mock.afterEach(); }); - it('should remove expired subscriptions', async function () { + it('should remove expired subscriptions', async function() { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath, pingPath = '/feedupdated', @@ -51,7 +51,7 @@ describe('RemoveExpiredSubscriptions', function () { expect(doc).to.be.null; }); - it('should remove resource when all subscriptions are removed', async function () { + it('should remove resource when all subscriptions are removed', async function() { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath, pingPath = '/feedupdated', @@ -96,7 +96,7 @@ describe('RemoveExpiredSubscriptions', function () { expect(storeData).to.not.have.property(resourceUrl); }); - it('should remove resource when all subscriptions are removed and whenLastUpdate is absent', async function () { + it('should remove resource when all subscriptions are removed and whenLastUpdate is absent', async function() { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath, pingPath = '/feedupdated', @@ -138,7 +138,7 @@ describe('RemoveExpiredSubscriptions', function () { expect(storeData).to.not.have.property(resourceUrl); }); - it('should retain empty-subscribers entry when whenLastUpdate is within retention window', async function () { + it('should retain empty-subscribers entry when whenLastUpdate is within retention window', async function() { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath, dayjs = await getDayjs(); @@ -168,7 +168,7 @@ describe('RemoveExpiredSubscriptions', function () { expect(storeData[resourceUrl].subscribers).to.deep.equal([]); }); - it('should remove empty-subscribers entry when whenLastUpdate is beyond retention window', async function () { + it('should remove empty-subscribers entry when whenLastUpdate is beyond retention window', async function() { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath, dayjs = await getDayjs(); @@ -200,7 +200,7 @@ describe('RemoveExpiredSubscriptions', function () { expect(storeData).to.not.have.property(resourceUrl); }); - it('should retain entry when last subscription expires but whenLastUpdate is recent', async function () { + it('should retain entry when last subscription expires but whenLastUpdate is recent', async function() { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath, pingPath = '/feedupdated', @@ -248,7 +248,7 @@ describe('RemoveExpiredSubscriptions', function () { expect(storeData[resourceUrl].subscribers).to.deep.equal([]); }); - it('should not remove resource when valid subscriptions remain', async function () { + it('should not remove resource when valid subscriptions remain', async function() { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath, pingPath1 = '/feedupdated1', @@ -298,7 +298,7 @@ describe('RemoveExpiredSubscriptions', function () { expect(resDoc).to.not.be.null; }); - it('should remove subscription document with empty pleaseNotify array', async function () { + it('should remove subscription document with empty pleaseNotify array', async function() { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath; @@ -325,7 +325,7 @@ describe('RemoveExpiredSubscriptions', function () { expect(storeData).to.not.have.property(resourceUrl); }); - it('should remove orphaned resource with no subscription document', async function () { + it('should remove orphaned resource with no subscription document', async function() { const feedPath = '/rss.xml', resourceUrl = mock.serverUrl + feedPath, dayjs = await getDayjs(); diff --git a/apps/server/test/static.js b/apps/server/test/static.js index 83c8477..a2f796c 100644 --- a/apps/server/test/static.js +++ b/apps/server/test/static.js @@ -5,32 +5,32 @@ const chai = require('chai'), chai.use(chaiHttp); -describe('Static Pages', function () { - it('docs should return 200', async function () { +describe('Static Pages', function() { + it('docs should return 200', async function() { let res = await chai.request(SERVER_URL).get('/docs'); expect(res).status(200); }); - it('home should return 200', async function () { + it('home should return 200', async function() { let res = await chai.request(SERVER_URL).get('/'); expect(res).status(200); }); - it('pingForm should return 200', async function () { + it('pingForm should return 200', async function() { let res = await chai.request(SERVER_URL).get('/pingForm'); expect(res).status(200); }); - it('pleaseNotifyForm should return 200', async function () { + it('pleaseNotifyForm should return 200', async function() { let res = await chai.request(SERVER_URL).get('/pleaseNotifyForm'); expect(res).status(200); }); - it('viewLog should return 200', async function () { + it('viewLog should return 200', async function() { let res = await chai.request(SERVER_URL).get('/viewLog'); expect(res).status(200); diff --git a/apps/server/test/store-api.js b/apps/server/test/store-api.js index 5270439..875d716 100644 --- a/apps/server/test/store-api.js +++ b/apps/server/test/store-api.js @@ -30,26 +30,26 @@ async function setSubscriptions(resourceUrl, pleaseNotify) { } module.exports = { - addResource: async function (resourceUrl, resourceObj) { + addResource: async function(resourceUrl, resourceObj) { await postJson('/test/setResource', { feedUrl: resourceUrl, resource: resourceObj }); }, - findResource: async function (resourceUrl) { + findResource: async function(resourceUrl) { const { found, resource } = await postJson('/test/getResource', { feedUrl: resourceUrl }); return found ? resource : null; }, - findSubscription: async function (resourceUrl) { + findSubscription: async function(resourceUrl) { const { found, subscriptions } = await postJson( '/test/getSubscriptions', { feedUrl: resourceUrl } ); return found ? subscriptions : null; }, - addSubscription: async function ( + addSubscription: async function( resourceUrl, notifyProcedure, apiurl, @@ -75,7 +75,7 @@ module.exports = { throw Error(`Cannot find ${apiurl} subscription`); }, - updateSubscription: async function (resourceUrl, subscription) { + updateSubscription: async function(resourceUrl, subscription) { const subscriptions = await fetchSubscriptions(resourceUrl), index = subscriptions.pleaseNotify.findIndex(match => { return subscription.url === match.url; @@ -90,18 +90,18 @@ module.exports = { throw Error(`Cannot find ${subscription.url} subscription`); }, setSubscriptions, - getData: async function () { + getData: async function() { const { data } = await postJson('/test/getData', {}); return data; }, - removeExpired: async function () { + removeExpired: async function() { const { result } = await postJson('/test/removeExpired', {}); return result; }, - before: async function () {}, - after: async function () {}, - beforeEach: async function () {}, - afterEach: async function () { + before: async function() {}, + after: async function() {}, + beforeEach: async function() {}, + afterEach: async function() { await postJson('/test/clear', {}); } }; From 59a687f1cb6f2c1104d61149a0b85abd99d04ca8 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sun, 24 May 2026 15:48:28 -0500 Subject: [PATCH 04/90] build: add turbo for task orchestration and caching Routes build, lint, typecheck, and test through `turbo run` at the workspace root. Establishes `^build` dependencies for typecheck and test so consumers of `@rsscloud/core` will wait on its `dist/` once they exist. Caches build outputs and lint/typecheck inputs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .dockerignore | 1 + .gitignore | 1 + package.json | 13 ++++++---- pnpm-lock.yaml | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++ turbo.json | 48 +++++++++++++++++++++++++++++++++++++ 5 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 turbo.json diff --git a/.dockerignore b/.dockerignore index 769ecb5..47452f1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,7 @@ **/data packages/*/dist packages/*/coverage +.turbo xunit .nyc_output .env diff --git a/.gitignore b/.gitignore index 68288f3..999d196 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,6 @@ data/ dist/ node_modules/ xunit/ +.turbo/ Procfile tunnel.sh diff --git a/package.json b/package.json index 1f8f683..e6e0cdf 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,14 @@ "scripts": { "start": "pnpm --filter @rsscloud/server run dev", "client": "pnpm --filter @rsscloud/server run client", - "build": "pnpm -r --if-present run build", - "lint": "pnpm -r --if-present run lint", - "typecheck": "pnpm -r --if-present run typecheck", + "build": "turbo run build", + "lint": "turbo run lint", + "typecheck": "turbo run typecheck", "format": "prettier --write .", "test": "docker-compose up --build --abort-on-container-exit --attach rsscloud-tests --no-log-prefix", - "test:core": "pnpm --filter @rsscloud/core run test", + "test:unit": "turbo run test", + "test:core": "turbo run test --filter=@rsscloud/core", + "clean": "turbo run clean", "prepare": "husky" }, "packageManager": "pnpm@10.11.0", @@ -31,6 +33,7 @@ "@commitlint/cli": "^20.5.3", "@commitlint/config-conventional": "^20.5.3", "husky": "^9.1.7", - "prettier": "^3.8.3" + "prettier": "^3.8.3", + "turbo": "^2.9.14" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8db3c63..dfb6091 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: prettier: specifier: ^3.8.3 version: 3.8.3 + turbo: + specifier: ^2.9.14 + version: 2.9.14 apps/server: dependencies: @@ -778,6 +781,36 @@ packages: resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} engines: {node: '>=18'} + '@turbo/darwin-64@2.9.14': + resolution: {integrity: sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A==} + cpu: [x64] + os: [darwin] + + '@turbo/darwin-arm64@2.9.14': + resolution: {integrity: sha512-d23147mC9BsCPA9mJ0h/ubcpbRgcJBXbcG3+Vq7YLhjz3IXuvQsJ1UXH8f4MD76ZjJ4m/E4aRdJV+MW88CDfbw==} + cpu: [arm64] + os: [darwin] + + '@turbo/linux-64@2.9.14': + resolution: {integrity: sha512-P3ZKB5tuUDdDQWuAsACGUR1qv9W7BNWxdxqVJ0kZNuNNPRaVYTPPikLcp79+GiEcW3npsR+KyP38lnQiBc5aSA==} + cpu: [x64] + os: [linux] + + '@turbo/linux-arm64@2.9.14': + resolution: {integrity: sha512-ZRTlzcUMrrPv9ZuDzRF9n60Ym13bKeG9jDB8WjxyLhWNzV+AJQN+zdpIk3NJYf2zQsGUm1mNar2P0elRzLw25g==} + cpu: [arm64] + os: [linux] + + '@turbo/windows-64@2.9.14': + resolution: {integrity: sha512-exanwN6sIduZwykYeiTQj8kCmOhazP5WOz3bvXMcYtjhL6Z3iRWLewKrXCBq0bqwSP3iBMb/AerRCnHI4lx46A==} + cpu: [x64] + os: [win32] + + '@turbo/windows-arm64@2.9.14': + resolution: {integrity: sha512-fVdCsnmYoKICsycbWuuGp6Jvi51/3G/UluFWuAUCvR8PIW5IJkAk5BM9UF8PSm0Q2IphWHFZjYEgjHsh3B9y/g==} + cpu: [arm64] + os: [win32] + '@types/chai@4.3.20': resolution: {integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==} @@ -2341,6 +2374,10 @@ packages: typescript: optional: true + turbo@2.9.14: + resolution: {integrity: sha512-BQqXRr4UoWI3UPFrtznCLykYHxwxWh53iCB57x092jPMjIlW1wnm3N895g5irpiXmnxUhREBB0n6+y8BHhs4nw==} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3055,6 +3092,24 @@ snapshots: '@simple-libs/stream-utils@1.2.0': {} + '@turbo/darwin-64@2.9.14': + optional: true + + '@turbo/darwin-arm64@2.9.14': + optional: true + + '@turbo/linux-64@2.9.14': + optional: true + + '@turbo/linux-arm64@2.9.14': + optional: true + + '@turbo/windows-64@2.9.14': + optional: true + + '@turbo/windows-arm64@2.9.14': + optional: true + '@types/chai@4.3.20': {} '@types/cookiejar@2.1.5': {} @@ -4806,6 +4861,15 @@ snapshots: - tsx - yaml + turbo@2.9.14: + optionalDependencies: + '@turbo/darwin-64': 2.9.14 + '@turbo/darwin-arm64': 2.9.14 + '@turbo/linux-64': 2.9.14 + '@turbo/linux-arm64': 2.9.14 + '@turbo/windows-64': 2.9.14 + '@turbo/windows-arm64': 2.9.14 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..e944da5 --- /dev/null +++ b/turbo.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://turborepo.com/schema.json", + "ui": "stream", + "globalDependencies": ["pnpm-lock.yaml", ".node-version", ".npmrc"], + "globalEnv": ["NODE_ENV", "CI"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "inputs": ["src/**", "tsup.config.ts", "tsconfig*.json", "package.json"], + "outputs": ["dist/**"] + }, + "lint": { + "inputs": [ + "src/**", + "controllers/**", + "services/**", + "test/**", + "*.js", + "eslint.config.*", + "package.json" + ], + "outputs": [] + }, + "typecheck": { + "dependsOn": ["^build"], + "inputs": ["src/**", "tsconfig*.json", "package.json"], + "outputs": [] + }, + "test": { + "dependsOn": ["^build"], + "inputs": [ + "src/**", + "test/**", + "vitest.config.ts", + "tsconfig*.json", + "package.json" + ], + "outputs": ["coverage/**"] + }, + "clean": { + "cache": false + }, + "dev": { + "cache": false, + "persistent": true + } + } +} From a6cc96408156c279b030e91db06ed9b9fb5293fa Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sun, 24 May 2026 15:48:33 -0500 Subject: [PATCH 05/90] chore(server): rename test script to e2e-test The mocha test suite requires the running app plus mock-server containers, so it cannot be invoked on the host outside Docker. The generic `test` script name collided with `turbo run test` (which should run unit tests across the workspace). Renaming to `e2e-test` makes the intent explicit and lets turbo's `test` task cleanly map to vitest in `@rsscloud/core`. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/server/package.json | 2 +- docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 17a2be7..bfa3527 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -8,7 +8,7 @@ "dev": "nodemon --use_strict --ignore data/ ./app.js", "client": "nodemon --use_strict --ignore data/ ./client.js", "lint": "eslint --fix controllers/ services/ test/ *.js", - "test": "mocha" + "e2e-test": "mocha" }, "engines": { "node": ">=22" diff --git a/docker-compose.yml b/docker-compose.yml index 8012936..e60c64b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: rsscloud-tests: build: . working_dir: /app/apps/server - command: dockerize -wait http://rsscloud:5337 -timeout 10s bash -c "pnpm exec mocha" + command: dockerize -wait http://rsscloud:5337 -timeout 10s bash -c "pnpm run e2e-test" environment: APP_URL: http://rsscloud:5337 MOCK_SERVER_DOMAIN: rsscloud-tests From b8ca40a621e131e213e06a73f8e042e9e9d78bc7 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sun, 24 May 2026 15:48:38 -0500 Subject: [PATCH 06/90] ci: add github actions workflow with node 22/24/26 matrix Matrix job runs lint, typecheck, and unit tests across Node 22, 24, and 26. Integration job runs the docker-compose e2e suite once on Node 22, gated on the matrix passing. Swap CircleCI badge for the CI workflow badge in the README. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 77 ++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5c07ba6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main, 4.x] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + lint-and-unit: + name: Lint & unit (Node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ['22', '24', '26'] + steps: + - uses: actions/checkout@v4 + + - name: Enable corepack + run: corepack enable + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + - name: Unit tests + run: pnpm test:unit + + - name: Upload core coverage + if: matrix.node == '22' + uses: actions/upload-artifact@v4 + with: + name: core-coverage + path: packages/core/coverage/ + if-no-files-found: ignore + + integration: + name: Integration (Docker, Node 22) + runs-on: ubuntu-latest + needs: lint-and-unit + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Run integration tests + run: docker compose up --build --abort-on-container-exit --attach rsscloud-tests --no-log-prefix + + - name: Upload xunit results + if: always() + uses: actions/upload-artifact@v4 + with: + name: server-xunit + path: xunit/test-results.xml + if-no-files-found: warn diff --git a/README.md b/README.md index d9b5115..6ea2a63 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # rssCloud Server [![MIT License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE.md) -[![CircleCI](https://circleci.com/gh/rsscloud/rsscloud-server.svg?style=shield)](https://circleci.com/gh/rsscloud/rsscloud-server) +[![CI](https://github.com/rsscloud/rsscloud-server/actions/workflows/ci.yml/badge.svg)](https://github.com/rsscloud/rsscloud-server/actions/workflows/ci.yml) [![Andrew Shell's Weblog](https://img.shields.io/badge/weblog-rssCloud-brightgreen)](https://andrewshell.org/search/?keywords=rsscloud) rssCloud Server implementation in Node.js From 33517933b388eb04ae0358b537fb0bba62feb861 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sun, 24 May 2026 15:48:43 -0500 Subject: [PATCH 07/90] ci: remove circleci configuration GitHub Actions now runs the test suite via .github/workflows/ci.yml. The CircleCI project should be deactivated in the CircleCI UI after merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- .circleci/config.yml | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 .circleci/config.yml diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 3457d5b..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,10 +0,0 @@ -version: 2 -jobs: - build: - machine: true - working_directory: ~/repo - steps: - - checkout - - run: docker-compose up --build --abort-on-container-exit - - store_test_results: - path: xunit From e9d7ff63247fc488ccb85a7ba7255cf32364518e Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sun, 24 May 2026 15:48:48 -0500 Subject: [PATCH 08/90] fix(server): restore /docs and /LICENSE.md routes after monorepo split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The monorepo conversion (6940429) changed the server's CWD from the repo root to apps/server/, but the /docs and /LICENSE.md handlers still read README.md and LICENSE.md as CWD-relative paths — so both routes returned HTTP 500 because the files only existed at the repo root. - Move the server-specific README.md into apps/server/, where the /docs handler now finds it via its existing CWD-relative read. The content (install, API docs, upgrade notes) was always about the server, not the monorepo as a whole. - Add a new minimal monorepo-level README.md at the repo root that describes the workspace and links to each package. - Fix the /LICENSE.md handler to read LICENSE.md via a __dirname- anchored path; LICENSE.md stays at the repo root since it covers the whole project. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 134 ++++--------------------------- apps/server/README.md | 133 ++++++++++++++++++++++++++++++ apps/server/controllers/index.js | 3 +- 3 files changed, 151 insertions(+), 119 deletions(-) create mode 100644 apps/server/README.md diff --git a/README.md b/README.md index 6ea2a63..9564d90 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,31 @@ -# rssCloud Server +# rssCloud [![MIT License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE.md) [![CI](https://github.com/rsscloud/rsscloud-server/actions/workflows/ci.yml/badge.svg)](https://github.com/rsscloud/rsscloud-server/actions/workflows/ci.yml) [![Andrew Shell's Weblog](https://img.shields.io/badge/weblog-rssCloud-brightgreen)](https://andrewshell.org/search/?keywords=rsscloud) -rssCloud Server implementation in Node.js +A monorepo for the [rssCloud](http://rsscloud.org/) notification protocol. -## How to install +## Packages -This project uses [pnpm](https://pnpm.io/) via corepack. Node.js 22+ is required. +- **[`apps/server`](apps/server/README.md)** — rssCloud Server: an Express implementation of the rssCloud notification protocol. Handles subscriptions, ping, and notifications for RSS feed updates. +- **[`packages/core`](packages/core/README.md)** — `@rsscloud/core`: shared primitives for subscriptions, notifications, and feed processing. + +## Development + +This repo is a [pnpm](https://pnpm.io/) workspace using [Turborepo](https://turborepo.com/) for task orchestration. Node.js 22+ is required. ```bash git clone https://github.com/rsscloud/rsscloud-server.git cd rsscloud-server corepack enable pnpm install -pnpm start -``` - -## Data storage - -State (resources and subscriptions) is held in memory and persisted to a JSON -file on disk, configured via `DATA_FILE_PATH` (default -`./data/subscriptions.json`). The store loads at startup and flushes atomically -on an interval, at shutdown, and on unexpected exit. No external database is -required. - -## Upgrading from 2.x to 3.0 - -Version 3.0 removes MongoDB entirely; the JSON file is the only data store. -There is no automatic migration from MongoDB, so do **not** upgrade directly -from an older 2.x release to 3.0 or your existing subscriptions will be lost. - -Migrate in two steps: - -1. **Upgrade to 2.4.0 first.** This release dual-writes to both MongoDB and - the JSON file. Run it until the data file (`DATA_FILE_PATH`, default - `./data/subscriptions.json`) has been written and reflects your current - subscriptions. -2. **Then upgrade to 3.0.** It reads only the JSON file and ignores - `MONGODB_URI`. Make sure the data directory is on a persistent volume so - the file survives restarts and redeploys. - -Once on 3.0 you can decommission MongoDB. - -## How to test - -The API is tested using docker containers. I've only tested on MacOS so if you have experience testing on other platforms I'd love having these notes updated for those platforms. - -### MacOS - -First install [Docker Desktop for Mac](https://hub.docker.com/editions/community/docker-ce-desktop-mac) - -```bash -pnpm test +pnpm start # start the server in dev mode +pnpm build # build all packages +pnpm lint # lint all packages +pnpm typecheck # typecheck all packages +pnpm test:unit # run unit tests across all packages +pnpm test # run docker-based end-to-end tests (server) ``` -This should build the appropriate containers and show the test output. - -Our tests create mock API endpoints so we can verify rssCloud server works correctly when reading resources and notifying subscribers. - -## How to use - -### POST /pleaseNotify - -Posting to /pleaseNotify is your way of alerting the server that you want to receive notifications when one or more resources are updated. - -The POST parameters are: - -1. domain -- optional, if omitted the requesting IP address is used -2. port -3. path -4. registerProcedure -- required, but isn't used in this server as it only applies to xml-rpc or soap. -5. protocol -- the spec allows for http-post, xml-rpc or soap but this server only supports http-post and xml-rpc. This server also supports https-post which is identical to http-post except it notifies using https as the scheme instead of http. _Note: if you specify http-post with port 443, the server will automatically use the https scheme for notifications._ For other ports that expect https, use https-post as the protocol. -6. url1, url2, ..., urlN this is the resource you're requesting to be notified about. In the case of an RSS feed you would specify the URL of the RSS feed. - -When you POST the server first checks if the urls you specifed are returning an [HTTP 2xx status code](http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2) then it attempts to notify the subscriber of an update to make sure it works. This is done in one of two ways. - -1. If you did not specify a domain parameter and we're using the requesting IP address we perform a POST request to the URL represented by `http://:` with a single parameter `url`. To accept the subscription that resource just needs to return an HTTP 2xx status code. -2. If you did specify a domain parameter then we perform a GET request to the URL represented by `http://:` with two query string parameters, url and challenge. To accept the subscription that resource needs to return an HTTP 2xx status code and have the challenge value as the response body. - -You will receive a response with two values: - -1. success -- true or false depending on whether or not the subscription suceeded -2. msg -- a string that explains either that you succeed or why it failed - -The default response type is text/xml but if you POST with an [accept header](http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1) specifying `application/json` we will return a JSON formatted response. - -Examples: - -```xml - - -``` - -```json -{ - "success": false, - "msg": "The subscription was cancelled because the call failed when we tested the handler." -} -``` - -### POST /ping - -Posting to /ping is your way of alerting the server that a resource has been updated. - -The POST parameters are: - -1. url - -When you POST the server first checks if the url has actually changed since the last time it checked. If it has, it will go through it's list of subscribers and POST to the subscriber with the parameter `url`. - -The default response type is text/xml but if you POST with an [accept header](http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1) specifying `application/json` we will return a JSON formatted response. - -Examples: - -```xml - - -``` - -```json -{ "success": true, "msg": "Thanks for the ping." } -``` - -### GET /pingForm - -The path /pingForm is an HTML form intented to allow you to ping via a web browser. - -### GET /viewLog - -The path /viewLog is a log of recent events that have occured on the server. It's very useful if you're trying to debug your tools. +See each package's README for package-specific usage and API documentation. diff --git a/apps/server/README.md b/apps/server/README.md new file mode 100644 index 0000000..6ea2a63 --- /dev/null +++ b/apps/server/README.md @@ -0,0 +1,133 @@ +# rssCloud Server + +[![MIT License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE.md) +[![CI](https://github.com/rsscloud/rsscloud-server/actions/workflows/ci.yml/badge.svg)](https://github.com/rsscloud/rsscloud-server/actions/workflows/ci.yml) +[![Andrew Shell's Weblog](https://img.shields.io/badge/weblog-rssCloud-brightgreen)](https://andrewshell.org/search/?keywords=rsscloud) + +rssCloud Server implementation in Node.js + +## How to install + +This project uses [pnpm](https://pnpm.io/) via corepack. Node.js 22+ is required. + +```bash +git clone https://github.com/rsscloud/rsscloud-server.git +cd rsscloud-server +corepack enable +pnpm install +pnpm start +``` + +## Data storage + +State (resources and subscriptions) is held in memory and persisted to a JSON +file on disk, configured via `DATA_FILE_PATH` (default +`./data/subscriptions.json`). The store loads at startup and flushes atomically +on an interval, at shutdown, and on unexpected exit. No external database is +required. + +## Upgrading from 2.x to 3.0 + +Version 3.0 removes MongoDB entirely; the JSON file is the only data store. +There is no automatic migration from MongoDB, so do **not** upgrade directly +from an older 2.x release to 3.0 or your existing subscriptions will be lost. + +Migrate in two steps: + +1. **Upgrade to 2.4.0 first.** This release dual-writes to both MongoDB and + the JSON file. Run it until the data file (`DATA_FILE_PATH`, default + `./data/subscriptions.json`) has been written and reflects your current + subscriptions. +2. **Then upgrade to 3.0.** It reads only the JSON file and ignores + `MONGODB_URI`. Make sure the data directory is on a persistent volume so + the file survives restarts and redeploys. + +Once on 3.0 you can decommission MongoDB. + +## How to test + +The API is tested using docker containers. I've only tested on MacOS so if you have experience testing on other platforms I'd love having these notes updated for those platforms. + +### MacOS + +First install [Docker Desktop for Mac](https://hub.docker.com/editions/community/docker-ce-desktop-mac) + +```bash +pnpm test +``` + +This should build the appropriate containers and show the test output. + +Our tests create mock API endpoints so we can verify rssCloud server works correctly when reading resources and notifying subscribers. + +## How to use + +### POST /pleaseNotify + +Posting to /pleaseNotify is your way of alerting the server that you want to receive notifications when one or more resources are updated. + +The POST parameters are: + +1. domain -- optional, if omitted the requesting IP address is used +2. port +3. path +4. registerProcedure -- required, but isn't used in this server as it only applies to xml-rpc or soap. +5. protocol -- the spec allows for http-post, xml-rpc or soap but this server only supports http-post and xml-rpc. This server also supports https-post which is identical to http-post except it notifies using https as the scheme instead of http. _Note: if you specify http-post with port 443, the server will automatically use the https scheme for notifications._ For other ports that expect https, use https-post as the protocol. +6. url1, url2, ..., urlN this is the resource you're requesting to be notified about. In the case of an RSS feed you would specify the URL of the RSS feed. + +When you POST the server first checks if the urls you specifed are returning an [HTTP 2xx status code](http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2) then it attempts to notify the subscriber of an update to make sure it works. This is done in one of two ways. + +1. If you did not specify a domain parameter and we're using the requesting IP address we perform a POST request to the URL represented by `http://:` with a single parameter `url`. To accept the subscription that resource just needs to return an HTTP 2xx status code. +2. If you did specify a domain parameter then we perform a GET request to the URL represented by `http://:` with two query string parameters, url and challenge. To accept the subscription that resource needs to return an HTTP 2xx status code and have the challenge value as the response body. + +You will receive a response with two values: + +1. success -- true or false depending on whether or not the subscription suceeded +2. msg -- a string that explains either that you succeed or why it failed + +The default response type is text/xml but if you POST with an [accept header](http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1) specifying `application/json` we will return a JSON formatted response. + +Examples: + +```xml + + +``` + +```json +{ + "success": false, + "msg": "The subscription was cancelled because the call failed when we tested the handler." +} +``` + +### POST /ping + +Posting to /ping is your way of alerting the server that a resource has been updated. + +The POST parameters are: + +1. url + +When you POST the server first checks if the url has actually changed since the last time it checked. If it has, it will go through it's list of subscribers and POST to the subscriber with the parameter `url`. + +The default response type is text/xml but if you POST with an [accept header](http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1) specifying `application/json` we will return a JSON formatted response. + +Examples: + +```xml + + +``` + +```json +{ "success": true, "msg": "Thanks for the ping." } +``` + +### GET /pingForm + +The path /pingForm is an HTML form intented to allow you to ping via a web browser. + +### GET /viewLog + +The path /viewLog is a log of recent events that have occured on the server. It's very useful if you're trying to debug your tools. diff --git a/apps/server/controllers/index.js b/apps/server/controllers/index.js index 9597378..9f9c760 100644 --- a/apps/server/controllers/index.js +++ b/apps/server/controllers/index.js @@ -1,6 +1,7 @@ const express = require('express'), builder = require('xmlbuilder'), fs = require('fs'), + path = require('path'), md = require('markdown-it')(), config = require('../config'), getDayjs = require('../services/dayjs-wrapper'), @@ -13,7 +14,7 @@ router.use('/docs', require('./docs')); router.get('/LICENSE.md', (req, res) => { try { const htmltext = md.render( - fs.readFileSync('LICENSE.md', { encoding: 'utf8' }) + fs.readFileSync(path.join(__dirname, '..', '..', '..', 'LICENSE.md'), { encoding: 'utf8' }) ); res.render('docs', { title: 'rssCloud Server: License', From 2d4563afc14b7215788a945ead79f10ce9b391a8 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sun, 24 May 2026 15:48:53 -0500 Subject: [PATCH 09/90] refactor(server)!: extract e2e test suite into apps/e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mocha suite was never unit tests of server internals — every file talks to the server over HTTP via APP_URL and spins up its own mock servers on separate ports. Living inside apps/server forced the server's devDependencies to carry mocha, chai*, supertest, and the like, and bundled test code into anything that built the server. Move the entire suite into a new apps/e2e workspace package: - apps/e2e/package.json (private) owns mocha, chai*, supertest and the mock-server runtime deps (express, body-parser, xml2js, xmlbuilder, dayjs). - All test files git-mv'd to apps/e2e/test/ so history follows. - The 5 server helper modules the tests previously imported from ../services (dayjs-wrapper, init-subscription, parse-rpc-request, rpc-return-fault, rpc-return-success) plus the 3 config keys pulled from ../config are duplicated into apps/e2e/test/helpers/. The tests now have no cross-package require()s. - apps/server/package.json drops the e2e-test script, the mocha/ chai/supertest devDeps, the unused 'https' stub, and 'test/' from the lint glob. - Root 'pnpm test' delegates to 'pnpm --filter @rsscloud/e2e run test:e2e' — the e2e package owns its docker-compose invocation. BREAKING CHANGE: apps/server no longer exposes the mocha test suite or its devDependencies. Anything that ran 'pnpm --filter @rsscloud/server run test' (none externally) must switch to 'pnpm --filter @rsscloud/e2e run e2e-test' inside the docker container, or 'pnpm test' at the root. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/{server => e2e}/.mocharc.js | 0 apps/e2e/package.json | 32 +++++++ apps/{server => e2e}/test/fixtures.js | 0 apps/e2e/test/helpers/config.js | 10 ++ apps/e2e/test/helpers/dayjs-wrapper.js | 21 +++++ apps/e2e/test/helpers/init-subscription.js | 46 ++++++++++ apps/e2e/test/helpers/parse-rpc-request.js | 91 +++++++++++++++++++ apps/e2e/test/helpers/rpc-return-fault.js | 32 +++++++ apps/e2e/test/helpers/rpc-return-success.js | 21 +++++ apps/{server => e2e}/test/keys/README.md | 0 apps/{server => e2e}/test/keys/server.cert | 0 apps/{server => e2e}/test/keys/server.key | 0 apps/{server => e2e}/test/mock.js | 4 +- apps/{server => e2e}/test/ping.js | 8 +- apps/{server => e2e}/test/please-notify.js | 4 +- .../test/remove-expired-subscriptions.js | 4 +- apps/{server => e2e}/test/static.js | 0 apps/{server => e2e}/test/store-api.js | 2 +- apps/{server => e2e}/test/xmlrpc-builder.js | 0 apps/server/package.json | 13 +-- package.json | 2 +- pnpm-lock.yaml | 74 +++++++++------ 22 files changed, 312 insertions(+), 52 deletions(-) rename apps/{server => e2e}/.mocharc.js (100%) create mode 100644 apps/e2e/package.json rename apps/{server => e2e}/test/fixtures.js (100%) create mode 100644 apps/e2e/test/helpers/config.js create mode 100644 apps/e2e/test/helpers/dayjs-wrapper.js create mode 100644 apps/e2e/test/helpers/init-subscription.js create mode 100644 apps/e2e/test/helpers/parse-rpc-request.js create mode 100644 apps/e2e/test/helpers/rpc-return-fault.js create mode 100644 apps/e2e/test/helpers/rpc-return-success.js rename apps/{server => e2e}/test/keys/README.md (100%) rename apps/{server => e2e}/test/keys/server.cert (100%) rename apps/{server => e2e}/test/keys/server.key (100%) rename apps/{server => e2e}/test/mock.js (97%) rename apps/{server => e2e}/test/ping.js (99%) rename apps/{server => e2e}/test/please-notify.js (99%) rename apps/{server => e2e}/test/remove-expired-subscriptions.js (99%) rename apps/{server => e2e}/test/static.js (100%) rename apps/{server => e2e}/test/store-api.js (97%) rename apps/{server => e2e}/test/xmlrpc-builder.js (100%) diff --git a/apps/server/.mocharc.js b/apps/e2e/.mocharc.js similarity index 100% rename from apps/server/.mocharc.js rename to apps/e2e/.mocharc.js diff --git a/apps/e2e/package.json b/apps/e2e/package.json new file mode 100644 index 0000000..ed75281 --- /dev/null +++ b/apps/e2e/package.json @@ -0,0 +1,32 @@ +{ + "name": "@rsscloud/e2e", + "version": "0.0.0", + "private": true, + "description": "End-to-end tests for rssCloud server", + "engines": { + "node": ">=22" + }, + "scripts": { + "e2e-test": "mocha", + "test:e2e": "docker-compose up --build --abort-on-container-exit --attach rsscloud-tests --no-log-prefix", + "lint": "eslint --fix test/ *.js" + }, + "author": "Andrew Shell ", + "license": "MIT", + "devDependencies": { + "@eslint/js": "^10.0.1", + "body-parser": "^2.2.2", + "chai": "^4.5.0", + "chai-http": "^4.4.0", + "chai-json": "^1.0.0", + "chai-xml": "^0.4.1", + "dayjs": "^1.11.20", + "eslint": "^10.4.0", + "express": "^4.22.2", + "mocha": "^11.7.5", + "mocha-multi": "^1.1.7", + "supertest": "^7.2.2", + "xml2js": "^0.6.2", + "xmlbuilder": "^15.1.1" + } +} diff --git a/apps/server/test/fixtures.js b/apps/e2e/test/fixtures.js similarity index 100% rename from apps/server/test/fixtures.js rename to apps/e2e/test/fixtures.js diff --git a/apps/e2e/test/helpers/config.js b/apps/e2e/test/helpers/config.js new file mode 100644 index 0000000..3e09322 --- /dev/null +++ b/apps/e2e/test/helpers/config.js @@ -0,0 +1,10 @@ +function getNumericConfig(key, defaultValue) { + const value = process.env[key]; + return value ? parseInt(value, 10) : defaultValue; +} + +module.exports = { + ctSecsResourceExpire: getNumericConfig('CT_SECS_RESOURCE_EXPIRE', 90000), + maxConsecutiveErrors: getNumericConfig('MAX_CONSECUTIVE_ERRORS', 3), + feedsChangedWindowDays: getNumericConfig('FEEDS_CHANGED_WINDOW_DAYS', 7) +}; diff --git a/apps/e2e/test/helpers/dayjs-wrapper.js b/apps/e2e/test/helpers/dayjs-wrapper.js new file mode 100644 index 0000000..1c1be18 --- /dev/null +++ b/apps/e2e/test/helpers/dayjs-wrapper.js @@ -0,0 +1,21 @@ +let dayjs; + +async function getDayjs() { + if (!dayjs) { + const dayjsModule = await import('dayjs'); + const utc = await import('dayjs/plugin/utc.js'); + const advancedFormat = await import('dayjs/plugin/advancedFormat.js'); + const duration = await import('dayjs/plugin/duration.js'); + const customParseFormat = + await import('dayjs/plugin/customParseFormat.js'); + + dayjs = dayjsModule.default; + dayjs.extend(utc.default); + dayjs.extend(advancedFormat.default); + dayjs.extend(duration.default); + dayjs.extend(customParseFormat.default); + } + return dayjs; +} + +module.exports = getDayjs; diff --git a/apps/e2e/test/helpers/init-subscription.js b/apps/e2e/test/helpers/init-subscription.js new file mode 100644 index 0000000..3676025 --- /dev/null +++ b/apps/e2e/test/helpers/init-subscription.js @@ -0,0 +1,46 @@ +const getDayjs = require('./dayjs-wrapper'); + +const ctSecsResourceExpire = + parseInt(process.env.CT_SECS_RESOURCE_EXPIRE, 10) || 90000; + +async function initSubscription( + subscriptions, + notifyProcedure, + apiurl, + protocol +) { + const dayjs = await getDayjs(); + const defaultSubscription = { + ctUpdates: 0, + whenLastUpdate: new Date(dayjs.utc('0', 'x').format()), + ctErrors: 0, + ctConsecutiveErrors: 0, + whenLastError: new Date(dayjs.utc('0', 'x').format()), + whenExpires: new Date( + dayjs() + .utc() + .add(ctSecsResourceExpire, 'seconds') + .format() + ), + url: apiurl, + notifyProcedure, + protocol + }, + index = subscriptions.pleaseNotify.findIndex(subscription => { + return subscription.url === apiurl; + }); + + if (-1 === index) { + subscriptions.pleaseNotify.push(defaultSubscription); + } else { + subscriptions.pleaseNotify[index] = Object.assign( + {}, + defaultSubscription, + subscriptions.pleaseNotify[index] + ); + } + + return subscriptions; +} + +module.exports = initSubscription; diff --git a/apps/e2e/test/helpers/parse-rpc-request.js b/apps/e2e/test/helpers/parse-rpc-request.js new file mode 100644 index 0000000..8c6f3ab --- /dev/null +++ b/apps/e2e/test/helpers/parse-rpc-request.js @@ -0,0 +1,91 @@ +const getDayjs = require('./dayjs-wrapper'), + xml2js = require('xml2js'); + +async function parseRpcParam(param, dayjs) { + let returnedValue, tag, member, values; + + const value = param.value || param; + + for (tag in value) { + switch (tag) { + case 'i4': + case 'int': + case 'double': + returnedValue = Number(value[tag]); + break; + case 'string': + returnedValue = value[tag]; + break; + case 'boolean': + returnedValue = 'true' === value[tag] || !!Number(value[tag]); + break; + case 'dateTime.iso8601': + returnedValue = dayjs.utc(value[tag], [ + 'YYYYMMDDTHHmmss', + dayjs.ISO_8601 + ]); + break; + case 'base64': + returnedValue = Buffer.from(value[tag], 'base64').toString( + 'utf8' + ); + break; + case 'struct': + member = value[tag].member || []; + if (!Array.isArray(member)) { + member = [member]; + } + returnedValue = {}; + for (const item of member) { + returnedValue[item.name] = await parseRpcParam(item, dayjs); + } + break; + case 'array': + values = (value[tag].data || {}).value || []; + if (!Array.isArray(values)) { + values = [values]; + } + returnedValue = []; + for (const item of values) { + returnedValue.push(await parseRpcParam(item, dayjs)); + } + break; + } + } + + if (undefined === returnedValue) { + returnedValue = value; + } + + return returnedValue; +} + +async function parseRpcRequest(req) { + const dayjs = await getDayjs(); + const parser = new xml2js.Parser({ explicitArray: false }), + jstruct = await parser.parseStringPromise(req.body), + methodCall = jstruct.methodCall, + methodName = (methodCall || {}).methodName, + params = ((methodCall || {}).params || {}).param || []; + + if (undefined === methodCall) { + throw new Error('Bad XML-RPC call, missing "methodCall" element.'); + } + + if (undefined === methodName) { + throw new Error('Bad XML-RPC call, missing "methodName" element.'); + } + + const parsedParams = []; + const paramArray = Array.isArray(params) ? params : [params]; + for (const param of paramArray) { + parsedParams.push(await parseRpcParam(param, dayjs)); + } + + return { + methodName, + params: parsedParams + }; +} + +module.exports = parseRpcRequest; diff --git a/apps/e2e/test/helpers/rpc-return-fault.js b/apps/e2e/test/helpers/rpc-return-fault.js new file mode 100644 index 0000000..368a3a7 --- /dev/null +++ b/apps/e2e/test/helpers/rpc-return-fault.js @@ -0,0 +1,32 @@ +const builder = require('xmlbuilder'); + +function rpcReturnFault(faultCode, faultString) { + return builder + .create({ + methodResponse: { + fault: { + value: { + struct: { + member: [ + { + name: 'faultCode', + value: { + int: faultCode + } + }, + { + name: 'faultString', + value: { + string: faultString + } + } + ] + } + } + } + } + }) + .end({ pretty: true }); +} + +module.exports = rpcReturnFault; diff --git a/apps/e2e/test/helpers/rpc-return-success.js b/apps/e2e/test/helpers/rpc-return-success.js new file mode 100644 index 0000000..95ab3c9 --- /dev/null +++ b/apps/e2e/test/helpers/rpc-return-success.js @@ -0,0 +1,21 @@ +const builder = require('xmlbuilder'); + +function rpcReturnSuccess(success) { + return builder + .create({ + methodResponse: { + params: { + param: [ + { + value: { + boolean: success ? 1 : 0 + } + } + ] + } + } + }) + .end({ pretty: true }); +} + +module.exports = rpcReturnSuccess; diff --git a/apps/server/test/keys/README.md b/apps/e2e/test/keys/README.md similarity index 100% rename from apps/server/test/keys/README.md rename to apps/e2e/test/keys/README.md diff --git a/apps/server/test/keys/server.cert b/apps/e2e/test/keys/server.cert similarity index 100% rename from apps/server/test/keys/server.cert rename to apps/e2e/test/keys/server.cert diff --git a/apps/server/test/keys/server.key b/apps/e2e/test/keys/server.key similarity index 100% rename from apps/server/test/keys/server.key rename to apps/e2e/test/keys/server.key diff --git a/apps/server/test/mock.js b/apps/e2e/test/mock.js similarity index 97% rename from apps/server/test/mock.js rename to apps/e2e/test/mock.js index 742b2ff..083806a 100644 --- a/apps/server/test/mock.js +++ b/apps/e2e/test/mock.js @@ -4,7 +4,7 @@ const https = require('https'), bodyParser = require('body-parser'), textParser = bodyParser.text({ type: '*/xml' }), urlencodedParser = bodyParser.urlencoded({ extended: false }), - parseRpcRequest = require('../services/parse-rpc-request'), + parseRpcRequest = require('./helpers/parse-rpc-request'), querystring = require('querystring'), MOCK_SERVER_DOMAIN = process.env.MOCK_SERVER_DOMAIN, MOCK_SERVER_PORT = process.env.MOCK_SERVER_PORT || 8002, @@ -15,7 +15,7 @@ const https = require('https'), SECURE_MOCK_SERVER_URL = process.env.SECURE_MOCK_SERVER_URL || `https://${MOCK_SERVER_DOMAIN}:${SECURE_MOCK_SERVER_PORT}`, - rpcReturnFault = require('../services/rpc-return-fault'); + rpcReturnFault = require('./helpers/rpc-return-fault'); async function restController(req, res) { const method = req.method, diff --git a/apps/server/test/ping.js b/apps/e2e/test/ping.js similarity index 99% rename from apps/server/test/ping.js rename to apps/e2e/test/ping.js index ef27abf..48ef193 100644 --- a/apps/server/test/ping.js +++ b/apps/e2e/test/ping.js @@ -1,15 +1,15 @@ const chai = require('chai'), chaiHttp = require('chai-http'), chaiXml = require('chai-xml'), - config = require('../config'), + config = require('./helpers/config'), expect = chai.expect, - getDayjs = require('../services/dayjs-wrapper'), + getDayjs = require('./helpers/dayjs-wrapper'), SERVER_URL = process.env.APP_URL || 'http://localhost:5337', mock = require('./mock'), storeApi = require('./store-api'), xmlrpcBuilder = require('./xmlrpc-builder'), - rpcReturnSuccess = require('../services/rpc-return-success'), - rpcReturnFault = require('../services/rpc-return-fault'); + rpcReturnSuccess = require('./helpers/rpc-return-success'), + rpcReturnFault = require('./helpers/rpc-return-fault'); chai.use(chaiHttp); chai.use(chaiXml); diff --git a/apps/server/test/please-notify.js b/apps/e2e/test/please-notify.js similarity index 99% rename from apps/server/test/please-notify.js rename to apps/e2e/test/please-notify.js index 944b5d8..05c5ebe 100644 --- a/apps/server/test/please-notify.js +++ b/apps/e2e/test/please-notify.js @@ -6,8 +6,8 @@ const chai = require('chai'), mock = require('./mock'), storeApi = require('./store-api'), xmlrpcBuilder = require('./xmlrpc-builder'), - rpcReturnSuccess = require('../services/rpc-return-success'), - rpcReturnFault = require('../services/rpc-return-fault'); + rpcReturnSuccess = require('./helpers/rpc-return-success'), + rpcReturnFault = require('./helpers/rpc-return-fault'); chai.use(chaiHttp); chai.use(chaiXml); diff --git a/apps/server/test/remove-expired-subscriptions.js b/apps/e2e/test/remove-expired-subscriptions.js similarity index 99% rename from apps/server/test/remove-expired-subscriptions.js rename to apps/e2e/test/remove-expired-subscriptions.js index cba0c11..fe34d24 100644 --- a/apps/server/test/remove-expired-subscriptions.js +++ b/apps/e2e/test/remove-expired-subscriptions.js @@ -1,7 +1,7 @@ const chai = require('chai'), - config = require('../config'), + config = require('./helpers/config'), expect = chai.expect, - getDayjs = require('../services/dayjs-wrapper'), + getDayjs = require('./helpers/dayjs-wrapper'), mock = require('./mock'), storeApi = require('./store-api'); diff --git a/apps/server/test/static.js b/apps/e2e/test/static.js similarity index 100% rename from apps/server/test/static.js rename to apps/e2e/test/static.js diff --git a/apps/server/test/store-api.js b/apps/e2e/test/store-api.js similarity index 97% rename from apps/server/test/store-api.js rename to apps/e2e/test/store-api.js index 875d716..97d19b4 100644 --- a/apps/server/test/store-api.js +++ b/apps/e2e/test/store-api.js @@ -1,4 +1,4 @@ -const initSubscription = require('../services/init-subscription'); +const initSubscription = require('./helpers/init-subscription'); const SERVER_URL = process.env.APP_URL || 'http://localhost:5337'; diff --git a/apps/server/test/xmlrpc-builder.js b/apps/e2e/test/xmlrpc-builder.js similarity index 100% rename from apps/server/test/xmlrpc-builder.js rename to apps/e2e/test/xmlrpc-builder.js diff --git a/apps/server/package.json b/apps/server/package.json index bfa3527..cad1ea6 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -7,8 +7,7 @@ "start": "node --use_strict app.js", "dev": "nodemon --use_strict --ignore data/ ./app.js", "client": "nodemon --use_strict --ignore data/ ./client.js", - "lint": "eslint --fix controllers/ services/ test/ *.js", - "e2e-test": "mocha" + "lint": "eslint --fix controllers/ services/ *.js" }, "engines": { "node": ">=22" @@ -30,15 +29,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "chai": "^4.5.0", - "chai-http": "^4.4.0", - "chai-json": "^1.0.0", - "chai-xml": "^0.4.1", "eslint": "^10.4.0", - "https": "^1.0.0", - "mocha": "^11.7.5", - "mocha-multi": "^1.1.7", - "nodemon": "3.1.14", - "supertest": "^7.2.2" + "nodemon": "3.1.14" } } diff --git a/package.json b/package.json index e6e0cdf..c4ae9c0 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "lint": "turbo run lint", "typecheck": "turbo run typecheck", "format": "prettier --write .", - "test": "docker-compose up --build --abort-on-container-exit --attach rsscloud-tests --no-log-prefix", + "test": "pnpm --filter @rsscloud/e2e run test:e2e", "test:unit": "turbo run test", "test:core": "turbo run test --filter=@rsscloud/core", "clean": "turbo run clean", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfb6091..6c61f23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,51 @@ importers: specifier: ^2.9.14 version: 2.9.14 + apps/e2e: + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.4.0(jiti@2.6.1)) + body-parser: + specifier: ^2.2.2 + version: 2.2.2 + chai: + specifier: ^4.5.0 + version: 4.5.0 + chai-http: + specifier: ^4.4.0 + version: 4.4.0 + chai-json: + specifier: ^1.0.0 + version: 1.0.0 + chai-xml: + specifier: ^0.4.1 + version: 0.4.1(chai@4.5.0) + dayjs: + specifier: ^1.11.20 + version: 1.11.20 + eslint: + specifier: ^10.4.0 + version: 10.4.0(jiti@2.6.1) + express: + specifier: ^4.22.2 + version: 4.22.2 + mocha: + specifier: ^11.7.5 + version: 11.7.5 + mocha-multi: + specifier: ^1.1.7 + version: 1.1.7(mocha@11.7.5) + supertest: + specifier: ^7.2.2 + version: 7.2.2 + xml2js: + specifier: ^0.6.2 + version: 0.6.2 + xmlbuilder: + specifier: ^15.1.1 + version: 15.1.1 + apps/server: dependencies: body-parser: @@ -66,36 +111,12 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.4.0(jiti@2.6.1)) - chai: - specifier: ^4.5.0 - version: 4.5.0 - chai-http: - specifier: ^4.4.0 - version: 4.4.0 - chai-json: - specifier: ^1.0.0 - version: 1.0.0 - chai-xml: - specifier: ^0.4.1 - version: 0.4.1(chai@4.5.0) eslint: specifier: ^10.4.0 version: 10.4.0(jiti@2.6.1) - https: - specifier: ^1.0.0 - version: 1.0.0 - mocha: - specifier: ^11.7.5 - version: 11.7.5 - mocha-multi: - specifier: ^1.1.7 - version: 1.1.7(mocha@11.7.5) nodemon: specifier: 3.1.14 version: 3.1.14 - supertest: - specifier: ^7.2.2 - version: 7.2.2 packages/core: devDependencies: @@ -1615,9 +1636,6 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - https@1.0.0: - resolution: {integrity: sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==} - husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -4108,8 +4126,6 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - https@1.0.0: {} - husky@9.1.7: {} iconv-lite@0.4.24: From ebcc699398abffc2a6d73c7eb5a5be7ee0d9134a Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sun, 24 May 2026 15:48:58 -0500 Subject: [PATCH 10/90] build: split Dockerfile into per-app images and colocate LICENSE Before: a single root Dockerfile built one image used as both the production server runtime AND the e2e test runner. The image shipped every workspace devDep (mocha, chai, supertest, dockerize) into the production server. Split into two Dockerfiles, each consumed by the apps/e2e docker-compose.yml: - apps/server/Dockerfile: lean production image. Installs only @rsscloud/server's runtime deps via 'pnpm install --filter @rsscloud/server --prod --ignore-scripts'. No dockerize, no mocha, no test fixtures. - apps/e2e/Dockerfile: test-runner image. Installs dockerize, mocha, chai*, etc. via 'pnpm install --filter @rsscloud/e2e --ignore-scripts'. - apps/e2e/docker-compose.yml: orchestrates both services using 'context: ../..' so pnpm sees the workspace lockfile. - Root Dockerfile and docker-compose.yml deleted. Also colocate LICENSE.md inside apps/server so the new lean image can serve /LICENSE.md without reaching across the workspace; the handler reverts to the CWD-relative read that /docs already uses post-monorepo-split. --ignore-scripts is required because the root 'prepare' script runs husky, which isn't present in production installs. Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile => apps/e2e/Dockerfile | 15 ++++++------- .../e2e/docker-compose.yml | 13 ++++++----- apps/server/Dockerfile | 22 +++++++++++++++++++ apps/server/LICENSE.md | 20 +++++++++++++++++ apps/server/controllers/index.js | 3 +-- 5 files changed, 57 insertions(+), 16 deletions(-) rename Dockerfile => apps/e2e/Dockerfile (67%) rename docker-compose.yml => apps/e2e/docker-compose.yml (74%) create mode 100644 apps/server/Dockerfile create mode 100644 apps/server/LICENSE.md diff --git a/Dockerfile b/apps/e2e/Dockerfile similarity index 67% rename from Dockerfile rename to apps/e2e/Dockerfile index bfa2aa3..ee97f6e 100644 --- a/Dockerfile +++ b/apps/e2e/Dockerfile @@ -6,22 +6,21 @@ RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSI && tar -C /usr/local/bin -xzvf dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz \ && rm dockerize-alpine-linux-amd64-$DOCKERIZE_VERSION.tar.gz -# Enable corepack for pnpm RUN corepack enable -RUN mkdir -p /app - WORKDIR /app -COPY package.json . -COPY pnpm-workspace.yaml . -COPY pnpm-lock.yaml . +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ COPY apps/server/package.json apps/server/ +COPY apps/e2e/package.json apps/e2e/ +COPY packages/core/package.json packages/core/ FROM base AS dependencies -RUN pnpm install --frozen-lockfile +RUN pnpm install --frozen-lockfile --filter @rsscloud/e2e --ignore-scripts FROM dependencies AS runtime -COPY . . +COPY apps/e2e apps/e2e + +WORKDIR /app/apps/e2e diff --git a/docker-compose.yml b/apps/e2e/docker-compose.yml similarity index 74% rename from docker-compose.yml rename to apps/e2e/docker-compose.yml index e60c64b..7f715ba 100644 --- a/docker-compose.yml +++ b/apps/e2e/docker-compose.yml @@ -1,9 +1,9 @@ services: rsscloud: - build: . - working_dir: /app/apps/server - command: node --use_strict app.js + build: + context: ../.. + dockerfile: apps/server/Dockerfile environment: DOMAIN: rsscloud PORT: 5337 @@ -13,8 +13,9 @@ services: - 5337 rsscloud-tests: - build: . - working_dir: /app/apps/server + build: + context: ../.. + dockerfile: apps/e2e/Dockerfile command: dockerize -wait http://rsscloud:5337 -timeout 10s bash -c "pnpm run e2e-test" environment: APP_URL: http://rsscloud:5337 @@ -22,7 +23,7 @@ services: MOCK_SERVER_PORT: 8002 SECURE_MOCK_SERVER_PORT: 8003 volumes: - - ./xunit:/app/apps/server/xunit + - ./xunit:/app/apps/e2e/xunit expose: - 8002 - 8003 diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile new file mode 100644 index 0000000..6fdd677 --- /dev/null +++ b/apps/server/Dockerfile @@ -0,0 +1,22 @@ +FROM node:22 AS base + +RUN corepack enable + +WORKDIR /app + +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY apps/server/package.json apps/server/ +COPY apps/e2e/package.json apps/e2e/ +COPY packages/core/package.json packages/core/ + +FROM base AS dependencies + +RUN pnpm install --frozen-lockfile --filter @rsscloud/server --prod --ignore-scripts + +FROM dependencies AS runtime + +COPY apps/server apps/server + +WORKDIR /app/apps/server + +CMD ["node", "--use_strict", "app.js"] diff --git a/apps/server/LICENSE.md b/apps/server/LICENSE.md new file mode 100644 index 0000000..d81b273 --- /dev/null +++ b/apps/server/LICENSE.md @@ -0,0 +1,20 @@ +Copyright (c) 2015-2026 Andrew Shell + +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. diff --git a/apps/server/controllers/index.js b/apps/server/controllers/index.js index 9f9c760..9597378 100644 --- a/apps/server/controllers/index.js +++ b/apps/server/controllers/index.js @@ -1,7 +1,6 @@ const express = require('express'), builder = require('xmlbuilder'), fs = require('fs'), - path = require('path'), md = require('markdown-it')(), config = require('../config'), getDayjs = require('../services/dayjs-wrapper'), @@ -14,7 +13,7 @@ router.use('/docs', require('./docs')); router.get('/LICENSE.md', (req, res) => { try { const htmltext = md.render( - fs.readFileSync(path.join(__dirname, '..', '..', '..', 'LICENSE.md'), { encoding: 'utf8' }) + fs.readFileSync('LICENSE.md', { encoding: 'utf8' }) ); res.render('docs', { title: 'rssCloud Server: License', From b91de2a8725f218b7299ca0e0ad350d2fd87b731 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sun, 24 May 2026 15:49:05 -0500 Subject: [PATCH 11/90] ci: point integration job at apps/e2e docker-compose After the e2e split, docker-compose.yml lives at apps/e2e/docker-compose.yml. Update the integration job to use it explicitly and adjust the xunit artifact path accordingly (apps/e2e/xunit/test-results.xml). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c07ba6..0eec85e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,12 +66,12 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Run integration tests - run: docker compose up --build --abort-on-container-exit --attach rsscloud-tests --no-log-prefix + run: docker compose -f apps/e2e/docker-compose.yml up --build --abort-on-container-exit --attach rsscloud-tests --no-log-prefix - name: Upload xunit results if: always() uses: actions/upload-artifact@v4 with: name: server-xunit - path: xunit/test-results.xml + path: apps/e2e/xunit/test-results.xml if-no-files-found: warn From 3878a64b7a9e7469335fb926cff11bcb935f5d45 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sun, 24 May 2026 15:49:11 -0500 Subject: [PATCH 12/90] build: make xunit pattern recursive in .dockerignore After the e2e split, xunit output lives at apps/e2e/xunit/ rather than the repo root. The non-recursive 'xunit' pattern no longer matched the new location. '**/xunit' catches it anywhere in the build context. Co-Authored-By: Claude Opus 4.7 (1M context) --- .dockerignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index 47452f1..303a13f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,7 +6,7 @@ packages/*/dist packages/*/coverage .turbo -xunit +**/xunit .nyc_output .env .DS_Store From 9eb17389dbad939027ad1208de1befe8bb17d38f Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sun, 24 May 2026 15:49:16 -0500 Subject: [PATCH 13/90] docs: refresh CLAUDE.md and prune .dockerignore for new structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md: update structure tree to show apps/e2e, packages/core, and the per-app Dockerfiles. Add test:unit/build/typecheck to the command list. Rewrite the Testing section to point at apps/e2e/ and note the duplicated helpers pattern. - .dockerignore: drop the .circleci entry — directory was removed in the CircleCI → GitHub Actions migration. Co-Authored-By: Claude Opus 4.7 (1M context) --- .dockerignore | 1 - CLAUDE.md | 48 +++++++++++++++++++++++++++++------------------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/.dockerignore b/.dockerignore index 303a13f..bddc5fe 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ .git .github -.circleci **/node_modules **/data packages/*/dist diff --git a/CLAUDE.md b/CLAUDE.md index 7359094..619aae1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,21 +8,27 @@ This is an rssCloud Server implementation in Node.js - a notification protocol s ## Monorepo Structure -This project is a pnpm workspace monorepo. The server application lives in `apps/server/`. +This project is a pnpm workspace monorepo orchestrated by [Turborepo](https://turborepo.com/). ``` -/ # Workspace root -├── apps/server/ # rssCloud Server application -│ ├── app.js # Express entry point -│ ├── config.js # Configuration from env vars -│ ├── controllers/ # Route handlers -│ ├── services/ # Business logic -│ ├── views/ # Handlebars templates -│ ├── public/ # Static assets -│ └── test/ # Mocha/Chai tests -├── pnpm-workspace.yaml # Workspace definition -├── Dockerfile # Docker build -└── docker-compose.yml # Test environment +/ # Workspace root +├── apps/ +│ ├── server/ # rssCloud Server application +│ │ ├── app.js # Express entry point +│ │ ├── config.js # Configuration from env vars +│ │ ├── controllers/ # Route handlers +│ │ ├── services/ # Business logic +│ │ ├── views/ # Handlebars templates +│ │ ├── public/ # Static assets +│ │ └── Dockerfile # Lean production image +│ └── e2e/ # End-to-end test suite (private) +│ ├── test/ # Mocha/Chai tests + mock servers +│ ├── Dockerfile # Test-runner image (mocha + dockerize) +│ └── docker-compose.yml # Orchestrates server + e2e containers +├── packages/ +│ └── core/ # @rsscloud/core: shared primitives (TS, tsup) +├── pnpm-workspace.yaml # Workspace definition +└── turbo.json # Task orchestration + caching ``` ## Development Commands @@ -34,10 +40,13 @@ This project uses pnpm with corepack. Run `corepack enable` to set up pnpm autom - `pnpm start` - Start server with nodemon (auto-reload on changes) - `pnpm run client` - Start client with nodemon -### Testing & Quality (from repo root) +### Testing & Quality (from repo root, all routed through turbo) -- `pnpm test` - Run full API tests using Docker containers (MacOS tested) -- `pnpm run lint` - Run ESLint with auto-fix on server code +- `pnpm test` - Run docker-based end-to-end tests via `apps/e2e/docker-compose.yml` (MacOS tested) +- `pnpm run test:unit` - Run unit tests across all packages (just `@rsscloud/core`'s vitest today) +- `pnpm run build` - Build all packages (just `@rsscloud/core`'s tsup today) +- `pnpm run lint` - Run ESLint across all packages +- `pnpm run typecheck` - Run TypeScript typecheck across all packages - `pnpm run format` - Run Prettier on the entire repo ## Architecture @@ -80,9 +89,10 @@ State is persisted to a JSON file (default `./data/subscriptions.json`) managed ### Testing -- Tests in apps/server/test/ using Mocha/Chai -- Docker-based API testing with mock endpoints -- Test fixtures and SSL certificates in apps/server/test/keys/ +- End-to-end tests live in `apps/e2e/test/` (separate workspace package, `@rsscloud/e2e`) +- Tests use Mocha/Chai, orchestrated via `apps/e2e/docker-compose.yml` +- Mock servers spin up on ports 8002/8003; SSL certs in `apps/e2e/test/keys/` +- Server-internal helpers used by tests (RPC builders, dayjs wrapper, init-subscription, config) are duplicated under `apps/e2e/test/helpers/` to keep the suite decoupled from `apps/server` internals ## Commits and Releases From e72a97e91e35fd4282c36d454cecf64004eb0adc Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sun, 24 May 2026 15:49:21 -0500 Subject: [PATCH 14/90] docs: trim CLAUDE.md to non-discoverable context only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed directory tree, command list, API endpoints, env var listing, per-directory descriptions, and conventional-commits boilerplate. All of these are trivially discoverable from package.json, controllers/, config.js, or the commitlint config. Kept: project identity, data-storage architecture (incl. the 2.x → 2.4.0 → 3.0 migration constraint), the deliberate e2e helper-duplication pattern (easy to misread as a bug), and release-please specifics. 124 lines → 25 lines. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 130 +++++------------------------------------------------- 1 file changed, 10 insertions(+), 120 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 619aae1..a8b70f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,132 +2,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Project Overview +## What this is -This is an rssCloud Server implementation in Node.js - a notification protocol server that allows RSS feeds to notify subscribers when they are updated. The server handles subscription management and real-time notifications for RSS/feed updates. +An [rssCloud](http://rsscloud.org/) notification protocol server. Subscribers register a callback URL via `/pleaseNotify`; publishers `/ping` when feeds update; the server fans notifications out to subscribers. Implementation lives in `apps/server/`. -## Monorepo Structure +## Data storage -This project is a pnpm workspace monorepo orchestrated by [Turborepo](https://turborepo.com/). +State (resources and subscriptions) is held in memory and persisted atomically to a JSON file (default `./data/subscriptions.json`, configurable via `DATA_FILE_PATH`). The flush happens on an interval, at shutdown, and on unexpected exit. There is no external database. -``` -/ # Workspace root -├── apps/ -│ ├── server/ # rssCloud Server application -│ │ ├── app.js # Express entry point -│ │ ├── config.js # Configuration from env vars -│ │ ├── controllers/ # Route handlers -│ │ ├── services/ # Business logic -│ │ ├── views/ # Handlebars templates -│ │ ├── public/ # Static assets -│ │ └── Dockerfile # Lean production image -│ └── e2e/ # End-to-end test suite (private) -│ ├── test/ # Mocha/Chai tests + mock servers -│ ├── Dockerfile # Test-runner image (mocha + dockerize) -│ └── docker-compose.yml # Orchestrates server + e2e containers -├── packages/ -│ └── core/ # @rsscloud/core: shared primitives (TS, tsup) -├── pnpm-workspace.yaml # Workspace definition -└── turbo.json # Task orchestration + caching -``` +## End-to-end tests -## Development Commands +`apps/e2e/` is a private workspace package holding a full mocha suite. Tests talk to the server over HTTP via `APP_URL` and spin up their own mock servers on ports 8002/8003. -This project uses pnpm with corepack. Run `corepack enable` to set up pnpm automatically. +A handful of server-internal helpers (RPC builders, dayjs wrapper, `init-subscription`, three config keys) are **intentionally duplicated** in `apps/e2e/test/helpers/` rather than imported across the workspace boundary. This preserves the e2e package as an independent consumer of the server's HTTP+RPC protocol, at the cost of some maintenance overhead if those helpers' wire-shape ever changes. If you find yourself adding a new `require('../...')` in a test file, prefer copying the dependency into `helpers/` instead. -### Start Development (from repo root) +## Releases -- `pnpm start` - Start server with nodemon (auto-reload on changes) -- `pnpm run client` - Start client with nodemon +Conventional Commits are enforced by commitlint (via husky). Pushes to `main` trigger [release-please](https://github.com/googleapis/release-please) which opens or updates a Release PR per tracked package (`apps/server`, `packages/core`). `apps/e2e` is private and not tracked. Merging the Release PR cuts the release and git tag. -### Testing & Quality (from repo root, all routed through turbo) - -- `pnpm test` - Run docker-based end-to-end tests via `apps/e2e/docker-compose.yml` (MacOS tested) -- `pnpm run test:unit` - Run unit tests across all packages (just `@rsscloud/core`'s vitest today) -- `pnpm run build` - Build all packages (just `@rsscloud/core`'s tsup today) -- `pnpm run lint` - Run ESLint across all packages -- `pnpm run typecheck` - Run TypeScript typecheck across all packages -- `pnpm run format` - Run Prettier on the entire repo - -## Architecture - -### Core Application Structure (apps/server/) - -- **app.js** - Main Express application entry point, sets up middleware, loads jsonStore from disk, and starts server -- **config.js** - Configuration management reading from env vars with defaults -- **controllers/** - Express route handlers for API endpoints -- **services/** - Business logic modules for core functionality -- **views/** - Handlebars templates for web interface - -### Key Services - -- **services/json-store.js** - Disk-backed in-memory store; the sole source of truth for resources and subscriptions. Flushes atomically to `./data/subscriptions.json` on an interval and at shutdown. -- **services/notify-\*.js** - Notification system for subscribers -- **services/ping.js** - RSS feed update detection and processing -- **services/please-notify.js** - Subscription management - -### API Endpoints (defined in controllers/index.js) - -- `/pleaseNotify` - Subscribe to RSS feed notifications -- `/ping` - Notify server of RSS feed updates -- `/viewLog` - Event log viewer for debugging -- `/RPC2` - XML-RPC endpoint -- Web forms available at `/pleaseNotifyForm` and `/pingForm` - -### Configuration - -Environment variables (with defaults in apps/server/config.js): - -- `DOMAIN` (default: localhost) -- `PORT` (default: 5337) -- `DATA_FILE_PATH` (default: `./data/subscriptions.json`) -- Resource limits: MAX_RESOURCE_SIZE, REQUEST_TIMEOUT, etc. - -### Data Storage - -State is persisted to a JSON file (default `./data/subscriptions.json`) managed by services/json-store.js. The store loads into memory at startup and flushes atomically on an interval and at shutdown. No external database is required. - -### Testing - -- End-to-end tests live in `apps/e2e/test/` (separate workspace package, `@rsscloud/e2e`) -- Tests use Mocha/Chai, orchestrated via `apps/e2e/docker-compose.yml` -- Mock servers spin up on ports 8002/8003; SSL certs in `apps/e2e/test/keys/` -- Server-internal helpers used by tests (RPC builders, dayjs wrapper, init-subscription, config) are duplicated under `apps/e2e/test/helpers/` to keep the suite decoupled from `apps/server` internals - -## Commits and Releases - -This project uses [Conventional Commits](https://www.conventionalcommits.org/) enforced by commitlint via husky git hooks. - -### Commit Format - -``` -type: description - -[optional body] -``` - -### Commit Types - -**Trigger releases:** - -- `fix:` - Bug fixes → patch release -- `feat:` - New features → minor release -- `feat!:` or `BREAKING CHANGE:` → major release - -**No release triggered:** - -- `chore:` - Maintenance tasks, dependencies -- `docs:` - Documentation only -- `style:` - Code style/formatting -- `refactor:` - Code refactoring -- `test:` - Adding/updating tests -- `ci:` - CI/CD changes -- `build:` - Build system changes - -### Release Workflow - -1. Push commits to `main` -2. release-please automatically creates/updates a Release PR -3. Review the Release PR (contains changelog and version bump) -4. Merge the Release PR when ready to release -5. release-please creates GitHub Release and git tag +`fix:` → patch, `feat:` → minor, `feat!:` / `BREAKING CHANGE:` → major. Other types (`chore:`, `docs:`, `style:`, `refactor:`, `test:`, `ci:`, `build:`) don't trigger releases. From a4a523f12d86ed98797450ab6294ed39d8d34cbf Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sun, 24 May 2026 15:59:28 -0500 Subject: [PATCH 15/90] ci: bump checkout and setup-node to v5 for Node 24 runtime v4 of these actions uses Node.js 20, which GitHub deprecated and will force-upgrade in June 2026. v5 ships with the Node 24 runtime. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0eec85e..1a7789d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,12 +22,12 @@ jobs: matrix: node: ['22', '24', '26'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Enable corepack run: corepack enable - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: ${{ matrix.node }} cache: 'pnpm' @@ -60,7 +60,7 @@ jobs: runs-on: ubuntu-latest needs: lint-and-unit steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 From 2f3c20516291920da853ed046b4e0c543de0063c Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sun, 24 May 2026 16:15:36 -0500 Subject: [PATCH 16/90] chore(deps): clear moderate audit findings on dev tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump vitest 2.1.8 → 3.2.4 (and @vitest/coverage-v8 in step) so it pulls in vite 8.x and esbuild 0.27.x, closing the path-traversal and dev-server request advisories. Add pnpm overrides for qs (>=6.15.2) and vite (>=6.4.2) so the patched versions stick across the workspace, including the e2e suite's body-parser transitive. The remaining low-severity diff advisory (via mocha) would require forcing mocha onto an incompatible diff major; deferred. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 4 +- packages/core/package.json | 4 +- pnpm-lock.yaml | 875 ++++++++++++++++++++++--------------- 3 files changed, 534 insertions(+), 349 deletions(-) diff --git a/package.json b/package.json index c4ae9c0..52072c7 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "packageManager": "pnpm@10.11.0", "pnpm": { "overrides": { - "serialize-javascript": ">=7.0.5" + "serialize-javascript": ">=7.0.5", + "qs": ">=6.15.2", + "vite": ">=6.4.2" }, "onlyBuiltDependencies": [ "esbuild" diff --git a/packages/core/package.json b/packages/core/package.json index 5933eaa..6dddb33 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -59,11 +59,11 @@ "devDependencies": { "@eslint/js": "^9.18.0", "@types/node": "^22.10.0", - "@vitest/coverage-v8": "^2.1.8", + "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.18.0", "tsup": "^8.3.5", "typescript": "^5.7.2", "typescript-eslint": "^8.20.0", - "vitest": "^2.1.8" + "vitest": "^3.2.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c61f23..fc2f2cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,8 @@ settings: overrides: serialize-javascript: '>=7.0.5' + qs: '>=6.15.2' + vite: '>=6.4.2' importers: @@ -127,8 +129,8 @@ importers: specifier: ^22.10.0 version: 22.19.19 '@vitest/coverage-v8': - specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9(@types/node@22.19.19)) + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1)) eslint: specifier: ^9.18.0 version: 9.39.4(jiti@2.6.1) @@ -142,8 +144,8 @@ importers: specifier: ^8.20.0 version: 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vitest: - specifier: ^2.1.8 - version: 2.1.9(@types/node@22.19.19) + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1) packages: @@ -172,8 +174,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@bcoe/v8-coverage@0.2.3': - resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} '@commitlint/cli@20.5.3': resolution: {integrity: sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==} @@ -256,11 +259,14 @@ packages: conventional-commits-parser: optional: true - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} @@ -268,192 +274,96 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} @@ -466,12 +376,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} @@ -484,12 +388,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} @@ -502,48 +400,24 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} @@ -658,10 +532,19 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@oxc-project/types@0.132.0': + resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} + '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -669,6 +552,98 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@rolldown/binding-android-arm64@1.0.2': + resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.2': + resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.2': + resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.2': + resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.2': + resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.2': + resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.2': + resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/rollup-android-arm-eabi@4.60.4': resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} cpu: [arm] @@ -832,12 +807,21 @@ packages: cpu: [arm64] os: [win32] + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/chai@4.3.20': resolution: {integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -918,43 +902,43 @@ packages: resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitest/coverage-v8@2.1.9': - resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} peerDependencies: - '@vitest/browser': 2.1.9 - vitest: 2.1.9 + '@vitest/browser': 3.2.4 + vitest: 3.2.4 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@2.1.9': - resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/mocker@2.1.9': - resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 + vite: '>=6.4.2' peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@2.1.9': - resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/runner@2.1.9': - resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} - '@vitest/snapshot@2.1.9': - resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} - '@vitest/spy@2.1.9': - resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/utils@2.1.9': - resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} @@ -1018,6 +1002,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1288,6 +1275,10 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} @@ -1356,11 +1347,6 @@ packages: es-toolkit@1.46.1: resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} - hasBin: true - esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -1771,9 +1757,15 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -1803,6 +1795,76 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -2049,9 +2111,6 @@ packages: path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2126,8 +2185,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.15.1: - resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} range-parser@1.2.1: @@ -2166,6 +2225,11 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + rolldown@1.0.2: + resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.60.4: resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2281,6 +2345,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -2340,12 +2407,12 @@ packages: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} - tinyrainbow@1.2.0: - resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} to-regex-range@5.0.1: @@ -2373,6 +2440,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.5.1: resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} engines: {node: '>=18'} @@ -2468,30 +2538,38 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - vite-node@2.1.9: - resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} - engines: {node: ^18.0.0 || >=20.0.0} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} + vite@8.0.14: + resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: '@types/node': optional: true - less: + '@vitejs/devtools': + optional: true + esbuild: optional: true - lightningcss: + jiti: + optional: true + less: optional: true sass: optional: true @@ -2503,21 +2581,28 @@ packages: optional: true terser: optional: true + tsx: + optional: true + yaml: + optional: true - vitest@2.1.9: - resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} - engines: {node: ^18.0.0 || >=20.0.0} + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 2.1.9 - '@vitest/ui': 2.1.9 + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 happy-dom: '*' jsdom: '*' peerDependenciesMeta: '@edge-runtime/vm': optional: true + '@types/debug': + optional: true '@types/node': optional: true '@vitest/browser': @@ -2634,7 +2719,7 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@bcoe/v8-coverage@0.2.3': {} + '@bcoe/v8-coverage@1.0.2': {} '@commitlint/cli@20.5.3(@types/node@25.8.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': dependencies: @@ -2754,150 +2839,97 @@ snapshots: optionalDependencies: conventional-commits-parser: 6.4.0 - '@esbuild/aix-ppc64@0.21.5': + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.27.7': + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 optional: true - '@esbuild/android-arm64@0.21.5': + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 optional: true - '@esbuild/android-arm64@0.27.7': + '@esbuild/aix-ppc64@0.27.7': optional: true - '@esbuild/android-arm@0.21.5': + '@esbuild/android-arm64@0.27.7': optional: true '@esbuild/android-arm@0.27.7': optional: true - '@esbuild/android-x64@0.21.5': - optional: true - '@esbuild/android-x64@0.27.7': optional: true - '@esbuild/darwin-arm64@0.21.5': - optional: true - '@esbuild/darwin-arm64@0.27.7': optional: true - '@esbuild/darwin-x64@0.21.5': - optional: true - '@esbuild/darwin-x64@0.27.7': optional: true - '@esbuild/freebsd-arm64@0.21.5': - optional: true - '@esbuild/freebsd-arm64@0.27.7': optional: true - '@esbuild/freebsd-x64@0.21.5': - optional: true - '@esbuild/freebsd-x64@0.27.7': optional: true - '@esbuild/linux-arm64@0.21.5': - optional: true - '@esbuild/linux-arm64@0.27.7': optional: true - '@esbuild/linux-arm@0.21.5': - optional: true - '@esbuild/linux-arm@0.27.7': optional: true - '@esbuild/linux-ia32@0.21.5': - optional: true - '@esbuild/linux-ia32@0.27.7': optional: true - '@esbuild/linux-loong64@0.21.5': - optional: true - '@esbuild/linux-loong64@0.27.7': optional: true - '@esbuild/linux-mips64el@0.21.5': - optional: true - '@esbuild/linux-mips64el@0.27.7': optional: true - '@esbuild/linux-ppc64@0.21.5': - optional: true - '@esbuild/linux-ppc64@0.27.7': optional: true - '@esbuild/linux-riscv64@0.21.5': - optional: true - '@esbuild/linux-riscv64@0.27.7': optional: true - '@esbuild/linux-s390x@0.21.5': - optional: true - '@esbuild/linux-s390x@0.27.7': optional: true - '@esbuild/linux-x64@0.21.5': - optional: true - '@esbuild/linux-x64@0.27.7': optional: true '@esbuild/netbsd-arm64@0.27.7': optional: true - '@esbuild/netbsd-x64@0.21.5': - optional: true - '@esbuild/netbsd-x64@0.27.7': optional: true '@esbuild/openbsd-arm64@0.27.7': optional: true - '@esbuild/openbsd-x64@0.21.5': - optional: true - '@esbuild/openbsd-x64@0.27.7': optional: true '@esbuild/openharmony-arm64@0.27.7': optional: true - '@esbuild/sunos-x64@0.21.5': - optional: true - '@esbuild/sunos-x64@0.27.7': optional: true - '@esbuild/win32-arm64@0.21.5': - optional: true - '@esbuild/win32-arm64@0.27.7': optional: true - '@esbuild/win32-ia32@0.21.5': - optional: true - '@esbuild/win32-ia32@0.27.7': optional: true - '@esbuild/win32-x64@0.21.5': - optional: true - '@esbuild/win32-x64@0.27.7': optional: true @@ -3020,8 +3052,17 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + '@noble/hashes@1.8.0': {} + '@oxc-project/types@0.132.0': {} + '@paralleldrive/cuid2@2.3.1': dependencies: '@noble/hashes': 1.8.0 @@ -3029,6 +3070,57 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@rolldown/binding-android-arm64@1.0.2': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.2': + optional: true + + '@rolldown/binding-darwin-x64@1.0.2': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.2': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.2': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.2': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.2': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.2': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.2': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@rollup/rollup-android-arm-eabi@4.60.4': optional: true @@ -3128,10 +3220,22 @@ snapshots: '@turbo/windows-arm64@2.9.14': optional: true + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + '@types/chai@4.3.20': {} + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/cookiejar@2.1.5': {} + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.8': {} @@ -3244,10 +3348,11 @@ snapshots: '@typescript-eslint/types': 8.59.4 eslint-visitor-keys: 5.0.1 - '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@22.19.19))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1))': dependencies: '@ampproject/remapping': 2.3.0 - '@bcoe/v8-coverage': 0.2.3 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 debug: 4.4.3 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -3257,50 +3362,52 @@ snapshots: magicast: 0.3.5 std-env: 3.10.0 test-exclude: 7.0.2 - tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@22.19.19) + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1) transitivePeerDependencies: - supports-color - '@vitest/expect@2.1.9': + '@vitest/expect@3.2.4': dependencies: - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 chai: 5.3.3 - tinyrainbow: 1.2.0 + tinyrainbow: 2.0.0 - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.19))': + '@vitest/mocker@3.2.4(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1))': dependencies: - '@vitest/spy': 2.1.9 + '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(@types/node@22.19.19) + vite: 8.0.14(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1) - '@vitest/pretty-format@2.1.9': + '@vitest/pretty-format@3.2.4': dependencies: - tinyrainbow: 1.2.0 + tinyrainbow: 2.0.0 - '@vitest/runner@2.1.9': + '@vitest/runner@3.2.4': dependencies: - '@vitest/utils': 2.1.9 - pathe: 1.1.2 + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 - '@vitest/snapshot@2.1.9': + '@vitest/snapshot@3.2.4': dependencies: - '@vitest/pretty-format': 2.1.9 + '@vitest/pretty-format': 3.2.4 magic-string: 0.30.21 - pathe: 1.1.2 + pathe: 2.0.3 - '@vitest/spy@2.1.9': + '@vitest/spy@3.2.4': dependencies: - tinyspy: 3.0.2 + tinyspy: 4.0.4 - '@vitest/utils@2.1.9': + '@vitest/utils@3.2.4': dependencies: - '@vitest/pretty-format': 2.1.9 + '@vitest/pretty-format': 3.2.4 loupe: 3.2.1 - tinyrainbow: 1.2.0 + tinyrainbow: 2.0.0 accepts@1.3.8: dependencies: @@ -3356,6 +3463,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + asynckit@0.4.0: {} balanced-match@1.0.2: {} @@ -3378,7 +3491,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.4.24 on-finished: 2.4.1 - qs: 6.15.1 + qs: 6.15.2 raw-body: 2.5.3 type-is: 1.6.18 unpipe: 1.0.0 @@ -3393,7 +3506,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.15.1 + qs: 6.15.2 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -3449,7 +3562,7 @@ snapshots: cookiejar: 2.1.4 is-ip: 2.0.0 methods: 1.1.2 - qs: 6.15.1 + qs: 6.15.2 superagent: 8.1.2 transitivePeerDependencies: - supports-color @@ -3647,6 +3760,8 @@ snapshots: destroy@1.2.0: {} + detect-libc@2.1.2: {} + dezalgo@1.0.4: dependencies: asap: 2.0.6 @@ -3703,32 +3818,6 @@ snapshots: es-toolkit@1.46.1: {} - esbuild@0.21.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -3921,7 +4010,7 @@ snapshots: parseurl: 1.3.3 path-to-regexp: 0.1.13 proxy-addr: 2.0.7 - qs: 6.15.1 + qs: 6.15.2 range-parser: 1.2.1 safe-buffer: 5.2.1 send: 0.19.2 @@ -4006,7 +4095,7 @@ snapshots: '@paralleldrive/cuid2': 2.3.1 dezalgo: 1.0.4 once: 1.4.0 - qs: 6.15.1 + qs: 6.15.2 formidable@3.5.4: dependencies: @@ -4232,8 +4321,12 @@ snapshots: joycon@3.1.1: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -4261,6 +4354,55 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -4511,8 +4653,6 @@ snapshots: path-to-regexp@0.1.13: {} - pathe@1.1.2: {} - pathe@2.0.3: {} pathval@1.1.1: {} @@ -4561,7 +4701,7 @@ snapshots: punycode@2.3.1: {} - qs@6.15.1: + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -4595,6 +4735,27 @@ snapshots: resolve-from@5.0.0: {} + rolldown@1.0.2: + dependencies: + '@oxc-project/types': 0.132.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.2 + '@rolldown/binding-darwin-arm64': 1.0.2 + '@rolldown/binding-darwin-x64': 1.0.2 + '@rolldown/binding-freebsd-x64': 1.0.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.2 + '@rolldown/binding-linux-arm64-gnu': 1.0.2 + '@rolldown/binding-linux-arm64-musl': 1.0.2 + '@rolldown/binding-linux-ppc64-gnu': 1.0.2 + '@rolldown/binding-linux-s390x-gnu': 1.0.2 + '@rolldown/binding-linux-x64-gnu': 1.0.2 + '@rolldown/binding-linux-x64-musl': 1.0.2 + '@rolldown/binding-openharmony-arm64': 1.0.2 + '@rolldown/binding-wasm32-wasi': 1.0.2 + '@rolldown/binding-win32-arm64-msvc': 1.0.2 + '@rolldown/binding-win32-x64-msvc': 1.0.2 + rollup@4.60.4: dependencies: '@types/estree': 1.0.8 @@ -4743,6 +4904,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -4763,7 +4928,7 @@ snapshots: formidable: 3.5.4 methods: 1.1.2 mime: 2.6.0 - qs: 6.15.1 + qs: 6.15.2 transitivePeerDependencies: - supports-color @@ -4777,7 +4942,7 @@ snapshots: formidable: 2.1.5 methods: 1.1.2 mime: 2.6.0 - qs: 6.15.1 + qs: 6.15.2 semver: 7.8.0 transitivePeerDependencies: - supports-color @@ -4829,9 +4994,9 @@ snapshots: tinypool@1.1.1: {} - tinyrainbow@1.2.0: {} + tinyrainbow@2.0.0: {} - tinyspy@3.0.2: {} + tinyspy@4.0.4: {} to-regex-range@5.0.1: dependencies: @@ -4849,6 +5014,9 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@2.8.1: + optional: true + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.15)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) @@ -4945,60 +5113,73 @@ snapshots: vary@1.1.2: {} - vite-node@2.1.9(@types/node@22.19.19): + vite-node@3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 - pathe: 1.1.2 - vite: 5.4.21(@types/node@22.19.19) + pathe: 2.0.3 + vite: 8.0.14(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1) transitivePeerDependencies: - '@types/node' + - '@vitejs/devtools' + - esbuild + - jiti - less - - lightningcss - sass - sass-embedded - stylus - sugarss - supports-color - terser + - tsx + - yaml - vite@5.4.21(@types/node@22.19.19): + vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1): dependencies: - esbuild: 0.21.5 + lightningcss: 1.32.0 + picomatch: 4.0.4 postcss: 8.5.15 - rollup: 4.60.4 + rolldown: 1.0.2 + tinyglobby: 0.2.16 optionalDependencies: '@types/node': 22.19.19 + esbuild: 0.27.7 fsevents: 2.3.3 + jiti: 2.6.1 - vitest@2.1.9(@types/node@22.19.19): + vitest@3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1): dependencies: - '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19)) - '@vitest/pretty-format': 2.1.9 - '@vitest/runner': 2.1.9 - '@vitest/snapshot': 2.1.9 - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 chai: 5.3.3 debug: 4.4.3 expect-type: 1.3.0 magic-string: 0.30.21 - pathe: 1.1.2 + pathe: 2.0.3 + picomatch: 4.0.4 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 + tinyglobby: 0.2.16 tinypool: 1.1.1 - tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@22.19.19) - vite-node: 2.1.9(@types/node@22.19.19) + tinyrainbow: 2.0.0 + vite: 8.0.14(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1) + vite-node: 3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.19 transitivePeerDependencies: + - '@vitejs/devtools' + - esbuild + - jiti - less - - lightningcss - msw - sass - sass-embedded @@ -5006,6 +5187,8 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml which@2.0.2: dependencies: From bf57497c6747515af4bf27a54da08589f6863630 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Tue, 9 Jun 2026 16:51:03 -0500 Subject: [PATCH 17/90] feat(core): add @rsscloud/core interface contracts Map out the type-only contracts for the reusable rssCloud engine ahead of implementation: protocol-neutral request/response DTOs, the async Store port, the rssCloud-first Subscription/Resource model, the ProtocolPlugin seam (verify/deliver) for pluggable transports including future WebSub, a typed observability EventBus, plus config/errors/stats and the RssCloudCore facade. No runtime behavior is added and the server is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/config.ts | 28 +++++++++++++ packages/core/src/core.ts | 66 +++++++++++++++++++++++++++++++ packages/core/src/dto.ts | 66 +++++++++++++++++++++++++++++++ packages/core/src/errors.ts | 17 ++++++++ packages/core/src/events.ts | 62 +++++++++++++++++++++++++++++ packages/core/src/feed.ts | 22 +++++++++++ packages/core/src/index.ts | 31 +++++++++++++++ packages/core/src/plugin.ts | 59 +++++++++++++++++++++++++++ packages/core/src/protocol.ts | 15 +++++++ packages/core/src/resource.ts | 26 ++++++++++++ packages/core/src/stats.ts | 33 ++++++++++++++++ packages/core/src/store.ts | 31 +++++++++++++++ packages/core/src/subscription.ts | 40 +++++++++++++++++++ 13 files changed, 496 insertions(+) create mode 100644 packages/core/src/config.ts create mode 100644 packages/core/src/core.ts create mode 100644 packages/core/src/dto.ts create mode 100644 packages/core/src/errors.ts create mode 100644 packages/core/src/events.ts create mode 100644 packages/core/src/feed.ts create mode 100644 packages/core/src/plugin.ts create mode 100644 packages/core/src/protocol.ts create mode 100644 packages/core/src/resource.ts create mode 100644 packages/core/src/stats.ts create mode 100644 packages/core/src/store.ts create mode 100644 packages/core/src/subscription.ts diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts new file mode 100644 index 0000000..bdac1ae --- /dev/null +++ b/packages/core/src/config.ts @@ -0,0 +1,28 @@ +/** + * Protocol-relevant tunables. Host concerns (port, domain, file paths, flush + * intervals) are deliberately excluded — those belong to the adapter and the + * Store implementation, not core. + */ +export interface RssCloudConfig { + /** Minimum seconds between accepted pings for a resource (0 disables). */ + minSecsBetweenPings: number; + /** Seconds a subscription lasts before it must be renewed. */ + ctSecsResourceExpire: number; + /** Consecutive delivery failures tolerated before a sub is dropped. */ + maxConsecutiveErrors: number; + /** Largest feed body (bytes) core will parse. */ + maxResourceSize: number; + /** Per-request timeout (ms) for outbound fetches. */ + requestTimeoutMs: number; + /** Window (days) used by stats and expiry housekeeping. */ + feedsChangedWindowDays: number; +} + +/** + * Signature of the helper core will provide to fill a partial config with + * defaults. The composition root calls this once and shares the result with + * both core and the plugins. + */ +export type ResolveConfig = ( + config?: Partial +) => RssCloudConfig; diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts new file mode 100644 index 0000000..0b11759 --- /dev/null +++ b/packages/core/src/core.ts @@ -0,0 +1,66 @@ +import type { RssCloudConfig } from './config.js'; +import type { + PingRequest, + PingResponse, + SubscribeRequest, + SubscribeResponse, + UnsubscribeRequest, + UnsubscribeResponse +} from './dto.js'; +import type { EventBus } from './events.js'; +import type { FeedParser } from './feed.js'; +import type { ProtocolPlugin } from './plugin.js'; +import type { MaintenanceResult, Stats } from './stats.js'; +import type { Store } from './store.js'; + +/** + * Everything core needs, assembled by the host's composition root. The shared + * dependencies (`config`, `events`, `fetch`, clock) are created there and + * injected into both the plugins and core, so there is a single source of truth + * for each. + */ +export interface RssCloudCoreOptions { + store: Store; + /** The plugin stack, already constructed and ready to use. */ + plugins: ProtocolPlugin[]; + /** Fully-resolved config (see `ResolveConfig` for defaults). */ + config: RssCloudConfig; + /** Shared event bus; core creates a default if omitted. */ + events?: EventBus; + /** Injectable fetch (tests, edge runtimes); defaults to global fetch. */ + fetch?: typeof fetch; + /** Injectable clock; defaults to `() => new Date()`. */ + now?: () => Date; + /** Feed metadata parser; defaults to core's built-in. */ + feedParser?: FeedParser; +} + +/** + * The protocol-neutral engine an adapter drives. It accepts the use-case DTOs, + * owns change detection and fan-out, and exposes the housekeeping jobs the host + * schedules. + */ +export interface RssCloudCore { + /** Establish or renew subscriptions. */ + subscribe(req: SubscribeRequest): Promise; + /** Cancel subscriptions. */ + unsubscribe(req: UnsubscribeRequest): Promise; + /** + * Handle a change signal: re-fetch the resource, detect a change, and on a + * change fan out to every subscriber via its protocol's plugin. + */ + ping(req: PingRequest): Promise; + + /** The observability bus (same instance passed in options, if any). */ + readonly events: EventBus; + + /** Drop expired/errored subscriptions and prune empty feeds. */ + removeExpired(): Promise; + /** Compute the activity snapshot. */ + generateStats(): Promise; +} + +/** Signature of the core factory the implementation step will provide. */ +export type CreateRssCloudCore = ( + options: RssCloudCoreOptions +) => RssCloudCore; diff --git a/packages/core/src/dto.ts b/packages/core/src/dto.ts new file mode 100644 index 0000000..be6a52d --- /dev/null +++ b/packages/core/src/dto.ts @@ -0,0 +1,66 @@ +import type { Protocol } from './protocol.js'; + +/** + * Wire-neutral request/response DTOs — one pair per use case. Adapters + * translate their transport (rssCloud REST/XML-RPC, WebSub `hub.*`, JSON) into + * these and back; core never sees HTTP. + */ + +/** Register or renew a subscription. rssCloud `pleaseNotify` / WebSub subscribe. */ +export interface SubscribeRequest { + /** Feeds/topics to be notified about; a subscribe may cover several. */ + resourceUrls: string[]; + /** Where notifications are delivered. */ + callbackUrl: string; + /** Delivery protocol chosen by the subscriber. */ + protocol: Protocol; + /** XML-RPC method name, when `protocol` is `'xml-rpc'`. */ + notifyProcedure?: string; + /** + * Whether the callback host differs from the requester's address. rssCloud + * uses this to pick challenge verification vs. a same-domain test notify. + */ + diffDomain?: boolean; + /** Protocol-specific extras (e.g. WebSub `secret`, `leaseSeconds`). */ + details?: Record; +} + +/** Outcome for a single resource within a subscribe request. */ +export interface SubscribeResult { + resourceUrl: string; + success: boolean; + error?: string; +} + +export interface SubscribeResponse { + success: boolean; + message: string; + /** Per-resource outcomes when several URLs were requested. */ + results?: SubscribeResult[]; +} + +/** Cancel a subscription. WebSub unsubscribe (rssCloud has no explicit form). */ +export interface UnsubscribeRequest { + resourceUrls: string[]; + callbackUrl: string; + protocol: Protocol; + details?: Record; +} + +export interface UnsubscribeResponse { + success: boolean; + message: string; +} + +/** + * A publisher signalling that a resource changed. The inbound protocol is + * irrelevant by this point — it has been reduced to a URL. + */ +export interface PingRequest { + resourceUrl: string; +} + +export interface PingResponse { + success: boolean; + message: string; +} diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000..5972f6a --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,17 @@ +/** Stable, machine-readable causes for an RssCloudError. */ +export type RssCloudErrorCode = + | 'PING_TOO_RECENT' + | 'RESOURCE_READ_FAILED' + | 'NO_RESOURCES' + | 'INVALID_PROTOCOL' + | 'UNSUPPORTED_PROTOCOL' + | 'SUBSCRIPTION_VERIFICATION_FAILED'; + +/** + * Shape of the domain error core raises. The concrete class (with `instanceof` + * support) lands in the implementation step; consumers match on `code` rather + * than on message text. + */ +export interface RssCloudError extends Error { + code: RssCloudErrorCode; +} diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts new file mode 100644 index 0000000..0779d77 --- /dev/null +++ b/packages/core/src/events.ts @@ -0,0 +1,62 @@ +import type { Protocol } from './protocol.js'; + +/** + * Payloads for the observability bus. Core emits these as side effects of its + * work (Model A: delivery is dispatched directly, not driven by events); the + * bus is for logging, stats, and optional plugin reactions. + */ +export interface RssCloudEventMap { + /** A publisher ping was processed (whether or not the feed changed). */ + ping: { + resourceUrl: string; + changed: boolean; + hash: string; + size: number; + durationMs: number; + }; + /** A subscription was established or renewed. */ + subscribe: { + callbackUrl: string; + protocol: Protocol; + resourceUrl: string; + diffDomain: boolean; + }; + /** A changed resource is about to be fanned out to its subscribers. */ + resourceChanged: { + resourceUrl: string; + subscriberCount: number; + }; + /** A subscriber was successfully notified. */ + notify: { + callbackUrl: string; + protocol: Protocol; + resourceUrl: string; + }; + /** A delivery to a subscriber failed. */ + notifyFailed: { + callbackUrl: string; + protocol: Protocol; + resourceUrl: string; + error: string; + }; + /** An unexpected error surfaced inside core. */ + error: { + scope: string; + error: Error; + }; +} + +/** Wrapper over an event emitter. `on` returns an unsubscribe function. */ +export interface EventBus { + on( + event: K, + listener: (payload: RssCloudEventMap[K]) => void + ): () => void; + emit( + event: K, + payload: RssCloudEventMap[K] + ): void; +} + +/** Signature of the default event-bus factory core will provide. */ +export type CreateEventBus = () => EventBus; diff --git a/packages/core/src/feed.ts b/packages/core/src/feed.ts new file mode 100644 index 0000000..6e73e82 --- /dev/null +++ b/packages/core/src/feed.ts @@ -0,0 +1,22 @@ +/** Metadata extracted from a feed body during change detection. */ +export interface FeedMetadata { + /** Feed flavour, e.g. `'rss'` or `'atom'`. */ + type?: 'rss' | 'atom' | (string & {}); + title?: string; + description?: string; + /** Human-facing site URL (RSS `link` / Atom alternate link). */ + htmlUrl?: string; + language?: string; +} + +/** + * Port for turning a raw feed body into FeedMetadata. Core ships a default + * implementation; hosts may inject their own. + */ +export interface FeedParser { + /** + * Resolves to `null` when the body is not a recognised/valid feed or + * exceeds the configured size limit. + */ + parse(body: string): Promise; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5e29e9c..60f65f3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1,32 @@ export const version = '0.0.0'; + +export type { BuiltInProtocol, Protocol } from './protocol.js'; +export type { FeedMetadata, FeedParser } from './feed.js'; +export type { Resource } from './resource.js'; +export type { Subscription } from './subscription.js'; +export type { FeedEntry, Store } from './store.js'; +export type { + SubscribeRequest, + SubscribeResult, + SubscribeResponse, + UnsubscribeRequest, + UnsubscribeResponse, + PingRequest, + PingResponse +} from './dto.js'; +export type { + ResourcePayload, + DeliveryResult, + VerifyContext, + DeliveryContext, + ProtocolPlugin +} from './plugin.js'; +export type { RssCloudEventMap, EventBus, CreateEventBus } from './events.js'; +export type { RssCloudConfig, ResolveConfig } from './config.js'; +export type { RssCloudErrorCode, RssCloudError } from './errors.js'; +export type { FeedStat, Stats, MaintenanceResult } from './stats.js'; +export type { + RssCloudCoreOptions, + RssCloudCore, + CreateRssCloudCore +} from './core.js'; diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts new file mode 100644 index 0000000..a6d495c --- /dev/null +++ b/packages/core/src/plugin.ts @@ -0,0 +1,59 @@ +import type { Protocol } from './protocol.js'; +import type { Resource } from './resource.js'; +import type { Subscription } from './subscription.js'; + +/** + * The feed body captured during change detection, handed to content-distributing + * protocols. rssCloud delivery ignores it; WebSub signs and sends it. + */ +export interface ResourcePayload { + body: string; + contentType: string | null; +} + +/** Outcome of a single delivery attempt. */ +export interface DeliveryResult { + ok: boolean; + /** Present when `ok` is `false`. */ + error?: Error; +} + +/** Passed to `ProtocolPlugin.verify` at subscribe time. */ +export interface VerifyContext { + subscription: Subscription; + resourceUrl: string; + diffDomain: boolean; +} + +/** Passed to `ProtocolPlugin.deliver` for each fan-out notification. */ +export interface DeliveryContext { + subscription: Subscription; + resource: Resource; + payload: ResourcePayload; +} + +/** + * A delivery protocol. Plugins are built and wired by the host's composition + * root (with whatever fetch/clock/config/event-bus they need) and handed to + * core ready to use; their constructor dependencies are not core's concern. + * + * Core calls `verify` when a subscription is established and `deliver` for each + * subscriber when a resource changes, selecting the plugin by the + * subscription's `protocol`. + */ +export interface ProtocolPlugin { + /** Protocol value(s) this plugin owns for delivery. */ + protocols: Protocol[]; + + /** + * Confirm the subscriber controls the callback (rssCloud challenge + * handshake, WebSub verification GET, …). Throw to reject the subscription. + */ + verify(ctx: VerifyContext): Promise; + + /** Deliver a change notification to one subscriber. */ + deliver(ctx: DeliveryContext): Promise; + + /** Optional async startup hook, run once when core is created. */ + init?(): void | Promise; +} diff --git a/packages/core/src/protocol.ts b/packages/core/src/protocol.ts new file mode 100644 index 0000000..491bc64 --- /dev/null +++ b/packages/core/src/protocol.ts @@ -0,0 +1,15 @@ +/** + * Delivery transport stored on a Subscription. Selects which plugin performs + * the outbound notification at fan-out time. + * + * The four built-ins cover today's server; the open `(string & {})` arm keeps + * the type extensible so a plugin can introduce a new protocol value without a + * core change, while preserving autocomplete for the built-ins. + */ +export type BuiltInProtocol = + | 'http-post' + | 'https-post' + | 'xml-rpc' + | 'websub'; + +export type Protocol = BuiltInProtocol | (string & {}); diff --git a/packages/core/src/resource.ts b/packages/core/src/resource.ts new file mode 100644 index 0000000..b27bd4f --- /dev/null +++ b/packages/core/src/resource.ts @@ -0,0 +1,26 @@ +import type { FeedMetadata } from './feed.js'; + +/** + * A feed the server tracks. Holds change-detection state plus the most recently + * parsed feed metadata. Keyed in the Store by `url`. + */ +export interface Resource { + /** The feed URL. Also the store key and the WebSub "topic". */ + url: string; + + /** md5 of the body at the last successful check. */ + lastHash: string; + /** Byte length of the body at the last successful check. */ + lastSize: number; + /** Total number of times the feed has been fetched/checked. */ + ctChecks: number; + /** When the feed was last fetched. */ + whenLastCheck: Date; + /** Total number of times the feed was observed to have changed. */ + ctUpdates: number; + /** When the feed last changed. */ + whenLastUpdate: Date; + + /** Cached feed metadata, refreshed whenever the body changes. */ + feed?: FeedMetadata; +} diff --git a/packages/core/src/stats.ts b/packages/core/src/stats.ts new file mode 100644 index 0000000..7c5ef74 --- /dev/null +++ b/packages/core/src/stats.ts @@ -0,0 +1,33 @@ +/** One feed's line in the stats report. */ +export interface FeedStat { + url: string; + subscriberCount: number; + /** ISO 8601, or `null` if the feed has never been seen to change. */ + whenLastUpdate: string | null; + feedTitle: string | null; +} + +/** Aggregate snapshot of server activity. */ +export interface Stats { + /** ISO 8601 generation time, or `null` before the first run. */ + generatedAt: string | null; + feedsChangedLastWindow: number; + feedsWithSubscribers: number; + /** Distinct subscriber hostnames. */ + uniqueAggregators: number; + totalActiveSubscriptions: number; + /** Most-subscribed feeds (ties at the boundary included). */ + topFeeds: FeedStat[]; + /** The remainder, below the top cut. */ + moreFeeds: FeedStat[]; + /** Active subscription counts keyed by protocol. */ + protocolBreakdown: Record; +} + +/** Summary returned by an expiry/cleanup pass. */ +export interface MaintenanceResult { + subscriptionsRemoved: number; + feedsProcessed: number; + feedsDeleted: number; + orphanedResourcesRemoved: number; +} diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts new file mode 100644 index 0000000..0cabb5a --- /dev/null +++ b/packages/core/src/store.ts @@ -0,0 +1,31 @@ +import type { Resource } from './resource.js'; +import type { Subscription } from './subscription.js'; + +/** One feed's complete record: its resource state and its subscribers. */ +export interface FeedEntry { + feedUrl: string; + resource: Resource | null; + subscriptions: Subscription[]; +} + +/** + * Persistence port, injected into core. Implementations may be in-memory, + * file-backed, or database-backed; every method is async so any backend fits. + * Core owns all reads and writes — plugins never touch the store directly. + */ +export interface Store { + getResource(feedUrl: string): Promise; + putResource(feedUrl: string, resource: Resource): Promise; + + getSubscriptions(feedUrl: string): Promise; + putSubscriptions( + feedUrl: string, + subscriptions: Subscription[] + ): Promise; + + /** Every tracked feed; used by maintenance, stats, and OPML export. */ + list(): Promise; + + /** Remove a feed entirely (resource + subscriptions). */ + remove(feedUrl: string): Promise; +} diff --git a/packages/core/src/subscription.ts b/packages/core/src/subscription.ts new file mode 100644 index 0000000..63c07f3 --- /dev/null +++ b/packages/core/src/subscription.ts @@ -0,0 +1,40 @@ +import type { Protocol } from './protocol.js'; + +/** + * A subscriber's standing request to be notified when a resource changes. + * + * The shape is rssCloud-first: the canonical fields below mirror the original + * `pleaseNotify` record (with unused ones simply absent), while anything a + * non-rssCloud protocol needs lives in `details`, which core stores verbatim + * and never interprets. + */ +export interface Subscription { + /** Subscriber callback (rssCloud apiurl / WebSub hub.callback). */ + url: string; + /** Delivery protocol; selects the plugin used at fan-out. */ + protocol: Protocol; + /** XML-RPC method to call; absent for REST and other protocols. */ + notifyProcedure?: string; + + /** Successful deliveries to this subscriber. */ + ctUpdates: number; + /** Total failed deliveries. */ + ctErrors: number; + /** Failures since the last success; drives auto-expiry. */ + ctConsecutiveErrors: number; + + /** When the subscription was first created. */ + whenCreated: Date; + /** Last successful delivery, or `null` if none yet. */ + whenLastUpdate: Date | null; + /** Last failed delivery, or `null` if none yet. */ + whenLastError: Date | null; + /** When the subscription lapses unless renewed (WebSub lease maps here). */ + whenExpires: Date; + + /** + * Protocol-specific fields owned by the delivering plugin (e.g. a WebSub + * `secret` and `leaseSeconds`). Core round-trips this opaquely. + */ + details?: Record; +} From 07d5c868a76671ef50f5ffc597f959cac4213e14 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Tue, 9 Jun 2026 18:22:10 -0500 Subject: [PATCH 18/90] feat(core): implement REST-capable rssCloud engine Add the concrete logic fulfilling the @rsscloud/core contracts: the protocol-neutral engine (createRssCloudCore) with ping change-detection and fan-out, subscribe/unsubscribe, generateStats, and removeExpired; the rssCloud REST protocol plugin (http-post/https-post) with challenge verification and form-encoded delivery; an xml2js feed parser; a Map-backed in-memory Store; plus the config resolver, event bus, and RssCloudError class. Built test-first with 100% coverage (93 tests). xml2js is core's first runtime dependency. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/README.md | 43 +- packages/core/package.json | 4 + packages/core/src/config.test.ts | 27 + packages/core/src/config.ts | 16 + packages/core/src/create-core.test.ts | 1036 ++++++++++++++++++++++++ packages/core/src/create-core.ts | 550 +++++++++++++ packages/core/src/errors.test.ts | 14 + packages/core/src/errors.ts | 16 +- packages/core/src/events.test.ts | 62 ++ packages/core/src/events.ts | 35 + packages/core/src/feed-parser.test.ts | 188 +++++ packages/core/src/feed-parser.ts | 187 +++++ packages/core/src/index.ts | 18 +- packages/core/src/memory-store.test.ts | 135 +++ packages/core/src/memory-store.ts | 60 ++ packages/core/src/rest-plugin.test.ts | 349 ++++++++ packages/core/src/rest-plugin.ts | 132 +++ pnpm-lock.yaml | 14 + 18 files changed, 2876 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/config.test.ts create mode 100644 packages/core/src/create-core.test.ts create mode 100644 packages/core/src/create-core.ts create mode 100644 packages/core/src/errors.test.ts create mode 100644 packages/core/src/events.test.ts create mode 100644 packages/core/src/feed-parser.test.ts create mode 100644 packages/core/src/feed-parser.ts create mode 100644 packages/core/src/memory-store.test.ts create mode 100644 packages/core/src/memory-store.ts create mode 100644 packages/core/src/rest-plugin.test.ts create mode 100644 packages/core/src/rest-plugin.ts diff --git a/packages/core/README.md b/packages/core/README.md index 353bc43..db78cfd 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -2,7 +2,9 @@ Core primitives for [rssCloud](https://github.com/rsscloud/rsscloud-server) — subscriptions, notifications, and feed-update processing. -> **Status:** Early scaffolding. Logic is being migrated from `@rsscloud/server`. +> **Status:** The protocol-neutral engine and the rssCloud **REST** transport +> (`http-post` / `https-post`) are implemented. XML-RPC and WebSub delivery +> plugins are not yet provided. ## Install @@ -12,12 +14,45 @@ pnpm add @rsscloud/core ## Usage -```ts -import { version } from '@rsscloud/core'; +Assemble the engine in your composition root from a `Store`, the protocol +plugins you want, and resolved config: -console.log(version); +```ts +import { + createRssCloudCore, + createInMemoryStore, + createRestProtocolPlugin, + resolveConfig +} from '@rsscloud/core'; + +const config = resolveConfig(); + +const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [ + createRestProtocolPlugin({ + requestTimeoutMs: config.requestTimeoutMs + }) + ], + config +}); + +// A subscriber registers a callback (rssCloud `pleaseNotify`). +await core.subscribe({ + resourceUrls: ['https://example.com/feed.xml'], + callbackUrl: 'https://subscriber.example/notify', + protocol: 'http-post', + diffDomain: true +}); + +// A publisher pings — re-check the feed and fan out on a change. +await core.ping({ resourceUrl: 'https://example.com/feed.xml' }); ``` +`createInMemoryStore` is a reference `Store`; provide your own (file- or +database-backed) for durability. Core never touches HTTP, the filesystem, or a +clock directly — those are injected, so the engine stays testable and portable. + ## License MIT — see [LICENSE.md](./LICENSE.md). diff --git a/packages/core/package.json b/packages/core/package.json index 6dddb33..459d404 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -43,6 +43,9 @@ "node": ">=22" }, "sideEffects": false, + "dependencies": { + "xml2js": "^0.6.2" + }, "scripts": { "build": "tsup", "dev": "tsup --watch", @@ -59,6 +62,7 @@ "devDependencies": { "@eslint/js": "^9.18.0", "@types/node": "^22.10.0", + "@types/xml2js": "^0.4.14", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.18.0", "tsup": "^8.3.5", diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts new file mode 100644 index 0000000..e3d60fe --- /dev/null +++ b/packages/core/src/config.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_CONFIG, resolveConfig } from './config.js'; + +describe('resolveConfig', () => { + it('returns the documented defaults when given nothing', () => { + expect(resolveConfig()).toEqual({ + minSecsBetweenPings: 0, + ctSecsResourceExpire: 90000, + maxConsecutiveErrors: 3, + maxResourceSize: 256000, + requestTimeoutMs: 4000, + feedsChangedWindowDays: 7 + }); + }); + + it('overrides only the provided keys', () => { + const resolved = resolveConfig({ maxConsecutiveErrors: 5 }); + expect(resolved.maxConsecutiveErrors).toBe(5); + expect(resolved.requestTimeoutMs).toBe(DEFAULT_CONFIG.requestTimeoutMs); + }); + + it('preserves explicit zero values', () => { + expect( + resolveConfig({ ctSecsResourceExpire: 0 }).ctSecsResourceExpire + ).toBe(0); + }); +}); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index bdac1ae..acead1f 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -26,3 +26,19 @@ export interface RssCloudConfig { export type ResolveConfig = ( config?: Partial ) => RssCloudConfig; + +/** Built-in defaults, matching the historical @rsscloud/server values. */ +export const DEFAULT_CONFIG: RssCloudConfig = { + minSecsBetweenPings: 0, + ctSecsResourceExpire: 90000, + maxConsecutiveErrors: 3, + maxResourceSize: 256000, + requestTimeoutMs: 4000, + feedsChangedWindowDays: 7 +}; + +/** Fill a partial config with {@link DEFAULT_CONFIG} values. */ +export const resolveConfig: ResolveConfig = config => ({ + ...DEFAULT_CONFIG, + ...config +}); diff --git a/packages/core/src/create-core.test.ts b/packages/core/src/create-core.test.ts new file mode 100644 index 0000000..8674a28 --- /dev/null +++ b/packages/core/src/create-core.test.ts @@ -0,0 +1,1036 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createRssCloudCore } from './create-core.js'; +import { resolveConfig } from './config.js'; +import { createEventBus } from './events.js'; +import type { RssCloudEventMap } from './events.js'; +import { createInMemoryStore } from './memory-store.js'; +import type { ProtocolPlugin } from './plugin.js'; +import type { Resource } from './resource.js'; +import type { Store } from './store.js'; +import type { Subscription } from './subscription.js'; + +const FEED = 'https://feed.example/rss'; +const RSS = 'Hi'; + +function fetchReturning(body: string, status = 200): typeof fetch { + return vi.fn( + async () => new Response(body, { status }) + ) as unknown as typeof fetch; +} + +function subscription(overrides: Partial = {}): Subscription { + return { + url: 'https://sub.example/notify', + protocol: 'http-post', + ctUpdates: 0, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: new Date(0), + whenLastUpdate: null, + whenLastError: null, + whenExpires: new Date('2099-01-01T00:00:00Z'), + ...overrides + }; +} + +function deliverPlugin( + deliver: ProtocolPlugin['deliver'], + protocols: ProtocolPlugin['protocols'] = ['http-post', 'https-post'] +): ProtocolPlugin { + return { + protocols, + verify: async () => undefined, + deliver + }; +} + +function resource(overrides: Partial = {}): Resource { + return { + url: FEED, + lastHash: 'hash', + lastSize: 1, + ctChecks: 1, + whenLastCheck: new Date(0), + ctUpdates: 0, + whenLastUpdate: new Date(0), + ...overrides + }; +} + +function makePlugin(overrides: Partial = {}): ProtocolPlugin { + return { + protocols: ['http-post', 'https-post'], + verify: vi.fn(async () => undefined), + deliver: vi.fn(async () => ({ ok: true })), + ...overrides + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('createRssCloudCore ping', () => { + it('records a first ping as a change and stores the resource', async () => { + const store = createInMemoryStore(); + + const core = createRssCloudCore({ + store, + plugins: [], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + + const result = await core.ping({ resourceUrl: FEED }); + + expect(result.success).toBe(true); + + const stored = await store.getResource(FEED); + expect(stored?.ctChecks).toBe(1); + expect(stored?.ctUpdates).toBe(1); + expect(stored?.lastSize).toBe(RSS.length); + expect(stored?.lastHash).not.toBe(''); + expect(stored?.feed).toMatchObject({ title: 'Hi' }); + }); + + it('rejects a ping that arrives sooner than the minimum interval', async () => { + const store = createInMemoryStore(); + const fixedNow = new Date('2026-01-01T00:00:00Z'); + await store.putResource(FEED, { + url: FEED, + lastHash: 'h', + lastSize: 1, + ctChecks: 1, + whenLastCheck: fixedNow, + ctUpdates: 0, + whenLastUpdate: fixedNow + }); + const fetchMock = fetchReturning(RSS); + + const core = createRssCloudCore({ + store, + plugins: [], + config: resolveConfig({ minSecsBetweenPings: 60 }), + fetch: fetchMock, + now: () => fixedNow + }); + + await expect(core.ping({ resourceUrl: FEED })).rejects.toMatchObject({ + code: 'PING_TOO_RECENT' + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects when the resource responds non-2xx', async () => { + const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [], + config: resolveConfig(), + fetch: fetchReturning('error', 500) + }); + + await expect(core.ping({ resourceUrl: FEED })).rejects.toMatchObject({ + code: 'RESOURCE_READ_FAILED' + }); + }); + + it('rejects when the resource fetch throws', async () => { + const fetchMock = vi.fn(async () => { + throw new Error('network down'); + }) as unknown as typeof fetch; + + const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [], + config: resolveConfig(), + fetch: fetchMock + }); + + await expect(core.ping({ resourceUrl: FEED })).rejects.toMatchObject({ + code: 'RESOURCE_READ_FAILED' + }); + }); + + it('rejects when reading the resource times out', async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn( + (_url: string | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject( + Object.assign(new Error('aborted'), { + name: 'AbortError' + }) + ); + }); + }) + ) as unknown as typeof fetch; + + const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [], + config: resolveConfig({ requestTimeoutMs: 50 }), + fetch: fetchMock + }); + + const assertion = expect( + core.ping({ resourceUrl: FEED }) + ).rejects.toMatchObject({ code: 'RESOURCE_READ_FAILED' }); + await vi.advanceTimersByTimeAsync(50); + await assertion; + }); + + it('does not re-notify when the feed is unchanged', async () => { + const store = createInMemoryStore(); + const core = createRssCloudCore({ + store, + plugins: [], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + + await core.ping({ resourceUrl: FEED }); + await core.ping({ resourceUrl: FEED }); + + const stored = await store.getResource(FEED); + expect(stored?.ctChecks).toBe(2); + expect(stored?.ctUpdates).toBe(1); + }); + + it('treats an empty body as a change without parsing a feed', async () => { + const store = createInMemoryStore(); + const core = createRssCloudCore({ + store, + plugins: [], + config: resolveConfig(), + fetch: fetchReturning('') + }); + + await core.ping({ resourceUrl: FEED }); + + const stored = await store.getResource(FEED); + expect(stored?.lastSize).toBe(0); + expect(stored?.feed).toBeUndefined(); + expect(stored?.ctUpdates).toBe(1); + }); + + it('re-attempts feed parsing while metadata is still unknown', async () => { + const parse = vi.fn(async () => null); + const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [], + config: resolveConfig(), + fetch: fetchReturning(''), + feedParser: { parse } + }); + + await core.ping({ resourceUrl: FEED }); + await core.ping({ resourceUrl: FEED }); + + expect(parse).toHaveBeenCalledTimes(2); + }); + + it('emits a ping event describing the result', async () => { + const pings: RssCloudEventMap['ping'][] = []; + const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + core.events.on('ping', event => pings.push(event)); + + await core.ping({ resourceUrl: FEED }); + + expect(pings[0]).toMatchObject({ resourceUrl: FEED, changed: true }); + expect(typeof pings[0]?.durationMs).toBe('number'); + }); +}); + +describe('createRssCloudCore ping fan-out', () => { + it('notifies an active subscriber when the feed changes', async () => { + const store = createInMemoryStore(); + await store.putSubscriptions(FEED, [subscription()]); + const deliver = vi.fn(async () => ({ ok: true })); + const notifies: RssCloudEventMap['notify'][] = []; + const changes: RssCloudEventMap['resourceChanged'][] = []; + + const core = createRssCloudCore({ + store, + plugins: [deliverPlugin(deliver)], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + core.events.on('notify', event => notifies.push(event)); + core.events.on('resourceChanged', event => changes.push(event)); + + await core.ping({ resourceUrl: FEED }); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(notifies).toHaveLength(1); + expect(changes[0]).toMatchObject({ subscriberCount: 1 }); + + const subs = await store.getSubscriptions(FEED); + expect(subs[0]?.ctUpdates).toBe(1); + expect(subs[0]?.ctConsecutiveErrors).toBe(0); + expect(subs[0]?.whenLastUpdate).not.toBeNull(); + }); + + it('records a delivery failure with the error message', async () => { + const store = createInMemoryStore(); + await store.putSubscriptions(FEED, [subscription()]); + const deliver = vi.fn(async () => ({ + ok: false, + error: new Error('boom') + })); + const failures: RssCloudEventMap['notifyFailed'][] = []; + + const core = createRssCloudCore({ + store, + plugins: [deliverPlugin(deliver)], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + core.events.on('notifyFailed', event => failures.push(event)); + + await core.ping({ resourceUrl: FEED }); + + expect(failures[0]).toMatchObject({ error: 'boom' }); + const subs = await store.getSubscriptions(FEED); + expect(subs[0]?.ctErrors).toBe(1); + expect(subs[0]?.ctConsecutiveErrors).toBe(1); + expect(subs[0]?.whenLastError).not.toBeNull(); + }); + + it('falls back to a default message when a failed delivery has no error', async () => { + const store = createInMemoryStore(); + await store.putSubscriptions(FEED, [subscription()]); + const deliver = vi.fn(async () => ({ ok: false })); + const failures: RssCloudEventMap['notifyFailed'][] = []; + + const core = createRssCloudCore({ + store, + plugins: [deliverPlugin(deliver)], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + core.events.on('notifyFailed', event => failures.push(event)); + + await core.ping({ resourceUrl: FEED }); + + expect(failures[0]?.error).toBe('Notification failed'); + }); + + it('records a failure when no plugin handles the subscription protocol', async () => { + const store = createInMemoryStore(); + await store.putSubscriptions(FEED, [ + subscription({ protocol: 'xml-rpc' }) + ]); + const failures: RssCloudEventMap['notifyFailed'][] = []; + + const core = createRssCloudCore({ + store, + plugins: [], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + core.events.on('notifyFailed', event => failures.push(event)); + + await core.ping({ resourceUrl: FEED }); + + expect(failures).toHaveLength(1); + const subs = await store.getSubscriptions(FEED); + expect(subs[0]?.ctConsecutiveErrors).toBe(1); + }); + + it('skips expired and error-exhausted subscribers during fan-out', async () => { + const store = createInMemoryStore(); + await store.putSubscriptions(FEED, [ + subscription({ url: 'https://active.example/notify' }), + subscription({ + url: 'https://expired.example/notify', + whenExpires: new Date('2000-01-01T00:00:00Z') + }), + subscription({ + url: 'https://errored.example/notify', + ctConsecutiveErrors: 3 + }) + ]); + const deliver = vi.fn(async () => ({ ok: true })); + const changes: RssCloudEventMap['resourceChanged'][] = []; + + const core = createRssCloudCore({ + store, + plugins: [deliverPlugin(deliver)], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + core.events.on('resourceChanged', event => changes.push(event)); + + await core.ping({ resourceUrl: FEED }); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(changes[0]?.subscriberCount).toBe(1); + }); + + it('passes the changed resource and payload to the plugin', async () => { + const store = createInMemoryStore(); + await store.putSubscriptions(FEED, [subscription()]); + const deliver = vi.fn(async () => ({ + ok: true + })); + + const core = createRssCloudCore({ + store, + plugins: [deliverPlugin(deliver)], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + + await core.ping({ resourceUrl: FEED }); + + const ctx = deliver.mock.calls[0]?.[0]; + expect(ctx?.resource.url).toBe(FEED); + expect(ctx?.payload.body).toBe(RSS); + }); +}); + +describe('createRssCloudCore subscribe', () => { + it('rejects a request with no resources', async () => { + const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [makePlugin()], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + + await expect( + core.subscribe({ + resourceUrls: [], + callbackUrl: 'https://sub.example/notify', + protocol: 'http-post' + }) + ).rejects.toMatchObject({ code: 'NO_RESOURCES' }); + }); + + it('rejects a protocol with no registered plugin', async () => { + const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [makePlugin()], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + + await expect( + core.subscribe({ + resourceUrls: [FEED], + callbackUrl: 'https://sub.example/notify', + protocol: 'xml-rpc' + }) + ).rejects.toMatchObject({ code: 'UNSUPPORTED_PROTOCOL' }); + }); + + it('seeds the resource, verifies, and stores a minimal subscription', async () => { + const store = createInMemoryStore(); + const verify = vi.fn(async () => undefined); + const events: RssCloudEventMap['subscribe'][] = []; + + const core = createRssCloudCore({ + store, + plugins: [makePlugin({ verify })], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + core.events.on('subscribe', event => events.push(event)); + + const response = await core.subscribe({ + resourceUrls: [FEED], + callbackUrl: 'https://sub.example/notify', + protocol: 'http-post' + }); + + expect(response.success).toBe(true); + expect(response.results).toEqual([ + { resourceUrl: FEED, success: true } + ]); + expect(verify).toHaveBeenCalledWith( + expect.objectContaining({ resourceUrl: FEED, diffDomain: false }) + ); + expect(events[0]).toMatchObject({ resourceUrl: FEED }); + + const subs = await store.getSubscriptions(FEED); + expect(subs).toHaveLength(1); + expect(subs[0]).toMatchObject({ + url: 'https://sub.example/notify', + protocol: 'http-post', + ctUpdates: 1 + }); + expect(subs[0]?.notifyProcedure).toBeUndefined(); + expect(subs[0]?.whenExpires.getTime()).toBeGreaterThan(Date.now()); + }); + + it('stores notifyProcedure and details when provided', async () => { + const store = createInMemoryStore(); + const core = createRssCloudCore({ + store, + plugins: [makePlugin()], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + + await core.subscribe({ + resourceUrls: [FEED], + callbackUrl: 'https://sub.example/notify', + protocol: 'http-post', + notifyProcedure: 'river.feedUpdated', + details: { secret: 's3cret' } + }); + + const subs = await store.getSubscriptions(FEED); + expect(subs[0]?.notifyProcedure).toBe('river.feedUpdated'); + expect(subs[0]?.details).toEqual({ secret: 's3cret' }); + }); + + it('passes diffDomain through to plugin verification', async () => { + const verify = vi.fn(async () => undefined); + const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [makePlugin({ verify })], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + + await core.subscribe({ + resourceUrls: [FEED], + callbackUrl: 'https://sub.example/notify', + protocol: 'http-post', + diffDomain: true + }); + + expect(verify).toHaveBeenCalledWith( + expect.objectContaining({ diffDomain: true }) + ); + }); + + it('reports a per-resource failure when verification fails', async () => { + const store = createInMemoryStore(); + const verify = vi.fn(async () => { + throw new Error('challenge mismatch'); + }); + + const core = createRssCloudCore({ + store, + plugins: [makePlugin({ verify })], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + + const response = await core.subscribe({ + resourceUrls: [FEED], + callbackUrl: 'https://sub.example/notify', + protocol: 'http-post' + }); + + expect(response.success).toBe(false); + expect(response.results?.[0]).toMatchObject({ + success: false, + error: 'Subscription verification failed.' + }); + expect(await store.getSubscriptions(FEED)).toHaveLength(0); + }); + + it('reports a per-resource failure when the resource cannot be read', async () => { + const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [makePlugin()], + config: resolveConfig(), + fetch: fetchReturning('nope', 500) + }); + + const response = await core.subscribe({ + resourceUrls: [FEED], + callbackUrl: 'https://sub.example/notify', + protocol: 'http-post' + }); + + expect(response.success).toBe(false); + expect(response.results?.[0]?.error).toContain('could not be read'); + }); + + it('reports a failure when seeding the resource throws unexpectedly', async () => { + const store: Store = { + ...createInMemoryStore(), + getResource: async () => { + throw new Error('store down'); + } + }; + + const core = createRssCloudCore({ + store, + plugins: [makePlugin()], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + + const response = await core.subscribe({ + resourceUrls: [FEED], + callbackUrl: 'https://sub.example/notify', + protocol: 'http-post' + }); + + expect(response.success).toBe(false); + expect(response.results?.[0]?.success).toBe(false); + }); + + it('tolerates a too-recent seed ping and still subscribes', async () => { + const store = createInMemoryStore(); + const fixedNow = new Date('2026-01-01T00:00:00Z'); + await store.putResource(FEED, { + url: FEED, + lastHash: 'h', + lastSize: 1, + ctChecks: 1, + whenLastCheck: fixedNow, + ctUpdates: 0, + whenLastUpdate: fixedNow + }); + + const core = createRssCloudCore({ + store, + plugins: [makePlugin()], + config: resolveConfig({ minSecsBetweenPings: 60 }), + fetch: fetchReturning(RSS), + now: () => fixedNow + }); + + const response = await core.subscribe({ + resourceUrls: [FEED], + callbackUrl: 'https://sub.example/notify', + protocol: 'http-post' + }); + + expect(response.success).toBe(true); + expect(await store.getSubscriptions(FEED)).toHaveLength(1); + }); + + it('renews an existing subscription instead of duplicating it', async () => { + const store = createInMemoryStore(); + const core = createRssCloudCore({ + store, + plugins: [makePlugin()], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + const request = { + resourceUrls: [FEED], + callbackUrl: 'https://sub.example/notify', + protocol: 'http-post' as const + }; + + await core.subscribe(request); + await core.subscribe(request); + + const subs = await store.getSubscriptions(FEED); + expect(subs).toHaveLength(1); + expect(subs[0]?.ctUpdates).toBe(2); + }); + + it('updates procedure and details when renewing', async () => { + const store = createInMemoryStore(); + const core = createRssCloudCore({ + store, + plugins: [makePlugin()], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + + await core.subscribe({ + resourceUrls: [FEED], + callbackUrl: 'https://sub.example/notify', + protocol: 'http-post' + }); + await core.subscribe({ + resourceUrls: [FEED], + callbackUrl: 'https://sub.example/notify', + protocol: 'http-post', + notifyProcedure: 'river.feedUpdated', + details: { secret: 's3cret' } + }); + + const subs = await store.getSubscriptions(FEED); + expect(subs).toHaveLength(1); + expect(subs[0]?.notifyProcedure).toBe('river.feedUpdated'); + expect(subs[0]?.details).toEqual({ secret: 's3cret' }); + }); + + it('succeeds overall when at least one resource subscribes', async () => { + const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [makePlugin()], + config: resolveConfig(), + fetch: vi.fn(async (url: string | URL) => + String(url).includes('good') + ? new Response(RSS, { status: 200 }) + : new Response('nope', { status: 500 }) + ) as unknown as typeof fetch + }); + + const response = await core.subscribe({ + resourceUrls: [ + 'https://good.example/rss', + 'https://bad.example/rss' + ], + callbackUrl: 'https://sub.example/notify', + protocol: 'http-post' + }); + + expect(response.success).toBe(true); + expect(response.results).toHaveLength(2); + expect(response.results?.filter(r => r.success)).toHaveLength(1); + }); +}); + +describe('createRssCloudCore unsubscribe', () => { + it('removes the matching subscription', async () => { + const store = createInMemoryStore(); + await store.putSubscriptions(FEED, [ + subscription({ url: 'https://sub.example/notify' }), + subscription({ url: 'https://other.example/notify' }) + ]); + + const core = createRssCloudCore({ + store, + plugins: [makePlugin()], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + + const response = await core.unsubscribe({ + resourceUrls: [FEED], + callbackUrl: 'https://sub.example/notify', + protocol: 'http-post' + }); + + expect(response.success).toBe(true); + const subs = await store.getSubscriptions(FEED); + expect(subs.map(s => s.url)).toEqual(['https://other.example/notify']); + }); + + it('is a no-op when nothing matches', async () => { + const store = createInMemoryStore(); + await store.putSubscriptions(FEED, [ + subscription({ url: 'https://sub.example/notify' }) + ]); + + const core = createRssCloudCore({ + store, + plugins: [makePlugin()], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + + const response = await core.unsubscribe({ + resourceUrls: [FEED], + callbackUrl: 'https://sub.example/notify', + protocol: 'xml-rpc' + }); + + expect(response.success).toBe(true); + expect(await store.getSubscriptions(FEED)).toHaveLength(1); + }); +}); + +describe('createRssCloudCore initialization', () => { + it('runs each plugin init hook once', () => { + const init = vi.fn(); + createRssCloudCore({ + store: createInMemoryStore(), + plugins: [makePlugin({ init })], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + + expect(init).toHaveBeenCalledTimes(1); + }); + + it('defaults to the global fetch and accepts a provided event bus', async () => { + const events = createEventBus(); + const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [], + config: resolveConfig(), + events + }); + + expect(core.events).toBe(events); + const stats = await core.generateStats(); + expect(stats.feedsWithSubscribers).toBe(0); + }); +}); + +const NOW = new Date('2026-06-01T00:00:00Z'); +const RECENT = new Date('2026-05-30T00:00:00Z'); +const OLD = new Date('2026-01-01T00:00:00Z'); +const FUTURE = new Date('2099-01-01T00:00:00Z'); +const PAST = new Date('2000-01-01T00:00:00Z'); + +function maintenanceCore(store: Store, at: Date = NOW) { + return createRssCloudCore({ + store, + plugins: [], + config: resolveConfig(), + fetch: fetchReturning(''), + now: () => at + }); +} + +describe('createRssCloudCore generateStats', () => { + it('returns an empty snapshot for an empty store', async () => { + const stats = await maintenanceCore(createInMemoryStore()).generateStats(); + + expect(stats).toMatchObject({ + feedsChangedLastWindow: 0, + feedsWithSubscribers: 0, + uniqueAggregators: 0, + totalActiveSubscriptions: 0, + topFeeds: [], + moreFeeds: [], + protocolBreakdown: {} + }); + expect(stats.generatedAt).toBe('2026-06-01T00:00:00.000Z'); + }); + + it('computes an activity snapshot across feed states', async () => { + const store = createInMemoryStore(); + + await store.putResource( + 'https://a.example/rss', + resource({ + whenLastUpdate: RECENT, + feed: { type: 'rss', title: 'Feed A' } + }) + ); + await store.putSubscriptions('https://a.example/rss', [ + subscription({ + url: 'https://host1.example/notify', + whenExpires: FUTURE + }), + subscription({ + url: 'https://host2.example/notify', + whenExpires: FUTURE + }) + ]); + + await store.putResource( + 'https://b.example/rss', + resource({ whenLastUpdate: new Date(0) }) + ); + await store.putSubscriptions('https://b.example/rss', [ + subscription({ + url: 'https://host3.example/notify', + protocol: 'https-post', + whenExpires: FUTURE + }) + ]); + + await store.putResource( + 'https://c.example/rss', + resource({ whenLastUpdate: OLD }) + ); + await store.putSubscriptions('https://c.example/rss', [ + subscription({ url: 'not a url', whenExpires: FUTURE }) + ]); + + await store.putSubscriptions('https://d.example/rss', [ + subscription({ + url: 'https://host4.example/notify', + whenExpires: FUTURE + }) + ]); + + await store.putResource( + 'https://e.example/rss', + resource({ whenLastUpdate: RECENT }) + ); + await store.putSubscriptions('https://e.example/rss', [ + subscription({ + url: 'https://host5.example/notify', + whenExpires: PAST + }) + ]); + + const stats = await maintenanceCore(store).generateStats(); + + expect(stats.feedsChangedLastWindow).toBe(2); + expect(stats.feedsWithSubscribers).toBe(4); + expect(stats.totalActiveSubscriptions).toBe(5); + expect(stats.uniqueAggregators).toBe(4); + expect(stats.protocolBreakdown).toEqual({ + 'http-post': 4, + 'https-post': 1 + }); + + const byUrl = Object.fromEntries( + [...stats.topFeeds, ...stats.moreFeeds].map(feed => [feed.url, feed]) + ); + expect(byUrl['https://a.example/rss']).toMatchObject({ + feedTitle: 'Feed A', + whenLastUpdate: '2026-05-30T00:00:00.000Z' + }); + expect(byUrl['https://b.example/rss']).toMatchObject({ + feedTitle: null, + whenLastUpdate: null + }); + expect(byUrl['https://c.example/rss']).toMatchObject({ + whenLastUpdate: '2026-01-01T00:00:00.000Z' + }); + expect(byUrl['https://d.example/rss']).toMatchObject({ + feedTitle: null, + whenLastUpdate: null + }); + }); + + it('caps the top feeds at a ten-deep cut, keeping boundary ties', async () => { + const store = createInMemoryStore(); + const manySubs = (prefix: string, count: number): Subscription[] => + Array.from({ length: count }, (_unused, i) => + subscription({ + url: `https://${prefix}-h${i}.example/notify`, + whenExpires: FUTURE + }) + ); + + for (let i = 0; i < 11; i++) { + await store.putSubscriptions( + `https://big${i}.example/rss`, + manySubs(`big${i}`, 5) + ); + } + for (let i = 0; i < 2; i++) { + await store.putSubscriptions( + `https://small${i}.example/rss`, + manySubs(`small${i}`, 1) + ); + } + + const stats = await maintenanceCore(store).generateStats(); + + expect(stats.feedsWithSubscribers).toBe(13); + expect(stats.topFeeds).toHaveLength(11); + expect(stats.moreFeeds).toHaveLength(2); + expect(stats.topFeeds.every(f => f.subscriberCount === 5)).toBe(true); + }); +}); + +describe('createRssCloudCore removeExpired', () => { + it('drops expired and error-exhausted subscriptions', async () => { + const store = createInMemoryStore(); + await store.putResource(FEED, resource({ whenLastUpdate: RECENT })); + await store.putSubscriptions(FEED, [ + subscription({ + url: 'https://valid.example/notify', + whenExpires: FUTURE + }), + subscription({ + url: 'https://expired.example/notify', + whenExpires: PAST + }), + subscription({ + url: 'https://exhausted.example/notify', + whenExpires: FUTURE, + ctConsecutiveErrors: 3 + }) + ]); + + const result = await maintenanceCore(store).removeExpired(); + + expect(result.subscriptionsRemoved).toBe(2); + expect(result.feedsProcessed).toBe(1); + expect(result.feedsDeleted).toBe(0); + expect(result.orphanedResourcesRemoved).toBe(0); + const subs = await store.getSubscriptions(FEED); + expect(subs.map(s => s.url)).toEqual(['https://valid.example/notify']); + }); + + it('empties but retains a recently updated feed when all subs expire', async () => { + const store = createInMemoryStore(); + await store.putResource(FEED, resource({ whenLastUpdate: RECENT })); + await store.putSubscriptions(FEED, [ + subscription({ whenExpires: PAST }) + ]); + + const result = await maintenanceCore(store).removeExpired(); + + expect(result.subscriptionsRemoved).toBe(1); + expect(result.feedsDeleted).toBe(0); + expect(await store.list()).toHaveLength(1); + expect(await store.getSubscriptions(FEED)).toEqual([]); + }); + + it('deletes a stale feed when all subs expire', async () => { + const store = createInMemoryStore(); + await store.putResource(FEED, resource({ whenLastUpdate: OLD })); + await store.putSubscriptions(FEED, [ + subscription({ whenExpires: PAST }) + ]); + + const result = await maintenanceCore(store).removeExpired(); + + expect(result.feedsDeleted).toBe(1); + expect(await store.list()).toHaveLength(0); + }); + + it('leaves a healthy feed untouched', async () => { + const store = createInMemoryStore(); + await store.putResource(FEED, resource({ whenLastUpdate: RECENT })); + await store.putSubscriptions(FEED, [ + subscription({ whenExpires: FUTURE }) + ]); + + const result = await maintenanceCore(store).removeExpired(); + + expect(result.subscriptionsRemoved).toBe(0); + expect(result.feedsProcessed).toBe(1); + expect(await store.getSubscriptions(FEED)).toHaveLength(1); + }); + + it('removes an orphaned resource outside the retain window', async () => { + const store = createInMemoryStore(); + await store.putResource(FEED, resource({ whenLastUpdate: OLD })); + + const result = await maintenanceCore(store).removeExpired(); + + expect(result.orphanedResourcesRemoved).toBe(1); + expect(await store.list()).toHaveLength(0); + }); + + it('keeps an orphaned resource that was recently updated', async () => { + const store = createInMemoryStore(); + await store.putResource(FEED, resource({ whenLastUpdate: RECENT })); + + const result = await maintenanceCore(store).removeExpired(); + + expect(result.orphanedResourcesRemoved).toBe(0); + expect(await store.list()).toHaveLength(1); + }); + + it('removes an empty entry that has no resource', async () => { + const store = createInMemoryStore(); + await store.putSubscriptions(FEED, []); + + const result = await maintenanceCore(store).removeExpired(); + + expect(result.orphanedResourcesRemoved).toBe(1); + expect(await store.list()).toHaveLength(0); + }); + + it('removes an orphaned resource that was never updated', async () => { + const store = createInMemoryStore(); + await store.putResource(FEED, resource({ whenLastUpdate: new Date(0) })); + + const result = await maintenanceCore(store).removeExpired(); + + expect(result.orphanedResourcesRemoved).toBe(1); + expect(await store.list()).toHaveLength(0); + }); +}); diff --git a/packages/core/src/create-core.ts b/packages/core/src/create-core.ts new file mode 100644 index 0000000..d9276b7 --- /dev/null +++ b/packages/core/src/create-core.ts @@ -0,0 +1,550 @@ +import { createHash } from 'node:crypto'; +import type { + PingRequest, + PingResponse, + SubscribeRequest, + SubscribeResponse, + SubscribeResult, + UnsubscribeRequest, + UnsubscribeResponse +} from './dto.js'; +import { RssCloudError } from './errors.js'; +import { createEventBus } from './events.js'; +import { createDefaultFeedParser } from './feed-parser.js'; +import type { ResourcePayload, ProtocolPlugin } from './plugin.js'; +import type { Protocol } from './protocol.js'; +import type { Resource } from './resource.js'; +import type { FeedStat, MaintenanceResult, Stats } from './stats.js'; +import type { Subscription } from './subscription.js'; +import type { + RssCloudCore, + RssCloudCoreOptions +} from './core.js'; + +const EPOCH = new Date(0); + +function md5(value: string): string { + return createHash('md5').update(value).digest('hex'); +} + +/** + * The protocol-neutral rssCloud engine. Owns change detection and fan-out and + * exposes the housekeeping jobs the host schedules; transports are supplied as + * plugins and persistence as a {@link RssCloudCoreOptions.store}. + */ +export function createRssCloudCore( + options: RssCloudCoreOptions +): RssCloudCore { + const { store, plugins, config } = options; + const events = options.events ?? createEventBus(); + const doFetch = options.fetch ?? fetch; + const now = options.now ?? (() => new Date()); + const feedParser = + options.feedParser ?? + createDefaultFeedParser({ maxResourceSize: config.maxResourceSize }); + + const pluginByProtocol = new Map(); + for (const plugin of plugins) { + for (const protocol of plugin.protocols) { + pluginByProtocol.set(protocol, plugin); + } + void plugin.init?.(); + } + + function expiryFrom(base: Date): Date { + return new Date(base.getTime() + config.ctSecsResourceExpire * 1000); + } + + async function fetchWithTimeout( + url: string, + init: RequestInit + ): Promise { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + config.requestTimeoutMs + ); + try { + return await doFetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } + } + + function newResource(url: string): Resource { + return { + url, + lastHash: '', + lastSize: 0, + ctChecks: 0, + whenLastCheck: EPOCH, + ctUpdates: 0, + whenLastUpdate: EPOCH + }; + } + + function ensurePingAllowed(resource: Resource): void { + const minSecs = config.minSecsBetweenPings; + if (minSecs <= 0) { + return; + } + const elapsedSecs = + (now().getTime() - resource.whenLastCheck.getTime()) / 1000; + if (elapsedSecs < minSecs) { + throw new RssCloudError( + 'PING_TOO_RECENT', + `A ping for this resource was received less than ${minSecs} seconds ago.` + ); + } + } + + interface ChangeResult { + changed: boolean; + payload: ResourcePayload; + } + + async function detectChange( + resource: Resource, + resourceUrl: string + ): Promise { + let body = ''; + let contentType: string | null = null; + let ok = false; + + try { + const res = await fetchWithTimeout(resourceUrl, { method: 'GET' }); + ok = res.ok; + if (ok) { + body = await res.text(); + contentType = res.headers.get('content-type'); + } + } catch { + ok = false; + } + + resource.ctChecks += 1; + resource.whenLastCheck = now(); + + if (!ok) { + throw new RssCloudError( + 'RESOURCE_READ_FAILED', + `The resource at ${resourceUrl} could not be read.` + ); + } + + const hash = md5(body); + const changed = + resource.lastHash !== hash || resource.lastSize !== body.length; + resource.lastHash = hash; + resource.lastSize = body.length; + + if (body && (changed || resource.feed === undefined)) { + const meta = await feedParser.parse(body); + if (meta) { + resource.feed = meta; + } + } + + return { changed, payload: { body, contentType } }; + } + + function isActive(subscription: Subscription): boolean { + if (subscription.whenExpires.getTime() <= now().getTime()) { + return false; + } + if (subscription.ctConsecutiveErrors >= config.maxConsecutiveErrors) { + return false; + } + return true; + } + + async function deliverTo( + resourceUrl: string, + resource: Resource, + payload: ResourcePayload, + subscription: Subscription + ): Promise { + const plugin = pluginByProtocol.get(subscription.protocol); + const result = plugin + ? await plugin.deliver({ subscription, resource, payload }) + : { + ok: false, + error: new Error( + `No plugin registered for protocol "${subscription.protocol}".` + ) + }; + + if (result.ok) { + subscription.ctUpdates += 1; + subscription.ctConsecutiveErrors = 0; + subscription.whenLastUpdate = now(); + events.emit('notify', { + callbackUrl: subscription.url, + protocol: subscription.protocol, + resourceUrl + }); + return; + } + + subscription.ctErrors += 1; + subscription.ctConsecutiveErrors += 1; + subscription.whenLastError = now(); + events.emit('notifyFailed', { + callbackUrl: subscription.url, + protocol: subscription.protocol, + resourceUrl, + error: result.error?.message ?? 'Notification failed' + }); + } + + async function fanOut( + resourceUrl: string, + resource: Resource, + payload: ResourcePayload + ): Promise { + const subscriptions = await store.getSubscriptions(resourceUrl); + const active = subscriptions.filter(isActive); + + events.emit('resourceChanged', { + resourceUrl, + subscriberCount: active.length + }); + + await Promise.all( + active.map(subscription => + deliverTo(resourceUrl, resource, payload, subscription) + ) + ); + + await store.putSubscriptions(resourceUrl, subscriptions); + } + + async function ping(req: PingRequest): Promise { + const start = now(); + const resource = + (await store.getResource(req.resourceUrl)) ?? + newResource(req.resourceUrl); + + ensurePingAllowed(resource); + + const { changed, payload } = await detectChange( + resource, + req.resourceUrl + ); + + if (changed) { + resource.ctUpdates += 1; + resource.whenLastUpdate = now(); + await fanOut(req.resourceUrl, resource, payload); + } + + await store.putResource(req.resourceUrl, resource); + + events.emit('ping', { + resourceUrl: req.resourceUrl, + changed, + hash: resource.lastHash, + size: resource.lastSize, + durationMs: now().getTime() - start.getTime() + }); + + return { success: true, message: 'Thanks for the ping.' }; + } + + function buildSubscription(req: SubscribeRequest): Subscription { + const subscription: Subscription = { + url: req.callbackUrl, + protocol: req.protocol, + ctUpdates: 0, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: now(), + whenLastUpdate: null, + whenLastError: null, + whenExpires: expiryFrom(now()) + }; + if (req.notifyProcedure !== undefined) { + subscription.notifyProcedure = req.notifyProcedure; + } + if (req.details !== undefined) { + subscription.details = req.details; + } + return subscription; + } + + function upsertSubscription( + subscriptions: Subscription[], + req: SubscribeRequest + ): Subscription { + const existing = subscriptions.find(s => s.url === req.callbackUrl); + if (existing === undefined) { + const created = buildSubscription(req); + subscriptions.push(created); + return created; + } + existing.protocol = req.protocol; + if (req.notifyProcedure !== undefined) { + existing.notifyProcedure = req.notifyProcedure; + } + if (req.details !== undefined) { + existing.details = req.details; + } + return existing; + } + + async function subscribeOne( + plugin: ProtocolPlugin, + req: SubscribeRequest, + resourceUrl: string, + diffDomain: boolean + ): Promise { + try { + await ping({ resourceUrl }); + } catch (err) { + if ( + !(err instanceof RssCloudError) || + err.code !== 'PING_TOO_RECENT' + ) { + return { + resourceUrl, + success: false, + error: `The resource at ${resourceUrl} could not be read.` + }; + } + } + + const subscriptions = ( + await store.getSubscriptions(resourceUrl) + ).slice(); + const subscription = upsertSubscription(subscriptions, req); + + try { + await plugin.verify({ subscription, resourceUrl, diffDomain }); + } catch { + return { + resourceUrl, + success: false, + error: 'Subscription verification failed.' + }; + } + + subscription.ctUpdates += 1; + subscription.ctConsecutiveErrors = 0; + subscription.whenLastUpdate = now(); + subscription.whenExpires = expiryFrom(now()); + await store.putSubscriptions(resourceUrl, subscriptions); + + events.emit('subscribe', { + callbackUrl: req.callbackUrl, + protocol: req.protocol, + resourceUrl, + diffDomain + }); + + return { resourceUrl, success: true }; + } + + async function subscribe( + req: SubscribeRequest + ): Promise { + if (req.resourceUrls.length === 0) { + throw new RssCloudError( + 'NO_RESOURCES', + 'No resources were supplied to subscribe to.' + ); + } + + const plugin = pluginByProtocol.get(req.protocol); + if (plugin === undefined) { + throw new RssCloudError( + 'UNSUPPORTED_PROTOCOL', + `No plugin is registered for protocol "${req.protocol}".` + ); + } + + const diffDomain = req.diffDomain ?? false; + const results = await Promise.all( + req.resourceUrls.map(resourceUrl => + subscribeOne(plugin, req, resourceUrl, diffDomain) + ) + ); + + const succeeded = results.some(result => result.success); + return { + success: succeeded, + message: succeeded + ? 'Subscription confirmed.' + : 'Subscription could not be confirmed for any resource.', + results + }; + } + + async function unsubscribe( + req: UnsubscribeRequest + ): Promise { + for (const resourceUrl of req.resourceUrls) { + const subscriptions = await store.getSubscriptions(resourceUrl); + const remaining = subscriptions.filter( + s => + !( + s.url === req.callbackUrl && + s.protocol === req.protocol + ) + ); + if (remaining.length !== subscriptions.length) { + await store.putSubscriptions(resourceUrl, remaining); + } + } + + return { success: true, message: 'Unsubscribed.' }; + } + + function windowCutoff(from: Date): Date { + return new Date( + from.getTime() - config.feedsChangedWindowDays * 86400 * 1000 + ); + } + + async function removeExpired(): Promise { + const current = now(); + const cutoff = windowCutoff(current); + const entries = await store.list(); + + let subscriptionsRemoved = 0; + let feedsDeleted = 0; + let orphanedResourcesRemoved = 0; + + const recentlyUpdated = (resource: Resource | null): boolean => + resource !== null && + resource.whenLastUpdate.getTime() > 0 && + resource.whenLastUpdate >= cutoff; + + for (const entry of entries) { + if (entry.subscriptions.length === 0) { + if (recentlyUpdated(entry.resource)) { + continue; + } + await store.remove(entry.feedUrl); + orphanedResourcesRemoved += 1; + continue; + } + + const valid = entry.subscriptions.filter(subscription => { + const expired = + subscription.whenExpires.getTime() <= current.getTime(); + const exhausted = + subscription.ctConsecutiveErrors >= + config.maxConsecutiveErrors; + if (expired || exhausted) { + subscriptionsRemoved += 1; + return false; + } + return true; + }); + + if (valid.length === entry.subscriptions.length) { + continue; + } + + if (valid.length === 0) { + if (recentlyUpdated(entry.resource)) { + await store.putSubscriptions(entry.feedUrl, []); + } else { + await store.remove(entry.feedUrl); + feedsDeleted += 1; + } + } else { + await store.putSubscriptions(entry.feedUrl, valid); + } + } + + return { + subscriptionsRemoved, + feedsProcessed: entries.length, + feedsDeleted, + orphanedResourcesRemoved + }; + } + + async function generateStats(): Promise { + const current = now(); + const cutoff = windowCutoff(current); + const entries = await store.list(); + + let feedsChangedLastWindow = 0; + let totalActiveSubscriptions = 0; + const hostnames = new Set(); + const protocolBreakdown: Record = {}; + const feedStats: FeedStat[] = []; + + for (const entry of entries) { + const lastUpdate = entry.resource?.whenLastUpdate ?? null; + const hasRealUpdate = + lastUpdate !== null && lastUpdate.getTime() > 0; + if (hasRealUpdate && lastUpdate >= cutoff) { + feedsChangedLastWindow += 1; + } + + let activeCount = 0; + for (const subscription of entry.subscriptions) { + if (subscription.whenExpires.getTime() <= current.getTime()) { + continue; + } + activeCount += 1; + totalActiveSubscriptions += 1; + try { + hostnames.add(new URL(subscription.url).hostname); + } catch { + // ignore unparseable callback URLs + } + protocolBreakdown[subscription.protocol] = + (protocolBreakdown[subscription.protocol] ?? 0) + 1; + } + + if (activeCount > 0) { + feedStats.push({ + url: entry.feedUrl, + subscriberCount: activeCount, + whenLastUpdate: hasRealUpdate + ? lastUpdate.toISOString() + : null, + feedTitle: entry.resource?.feed?.title ?? null + }); + } + } + + const sorted = [...feedStats].sort( + (a, b) => b.subscriberCount - a.subscriberCount + ); + const cut = sorted.slice(0, 10); + const last = cut[cut.length - 1]; + let topFeeds = cut; + let moreFeeds: FeedStat[] = []; + if (last !== undefined && sorted.length > 10) { + topFeeds = sorted.filter( + feed => feed.subscriberCount >= last.subscriberCount + ); + moreFeeds = sorted.slice(topFeeds.length); + } + + return { + generatedAt: current.toISOString(), + feedsChangedLastWindow, + feedsWithSubscribers: feedStats.length, + uniqueAggregators: hostnames.size, + totalActiveSubscriptions, + topFeeds, + moreFeeds, + protocolBreakdown + }; + } + + return { + subscribe, + unsubscribe, + ping, + events, + removeExpired, + generateStats + }; +} diff --git a/packages/core/src/errors.test.ts b/packages/core/src/errors.test.ts new file mode 100644 index 0000000..58b8868 --- /dev/null +++ b/packages/core/src/errors.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { RssCloudError } from './errors.js'; + +describe('RssCloudError', () => { + it('carries a machine-readable code alongside the message', () => { + const err = new RssCloudError('PING_TOO_RECENT', 'too soon'); + + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(RssCloudError); + expect(err.code).toBe('PING_TOO_RECENT'); + expect(err.message).toBe('too soon'); + expect(err.name).toBe('RssCloudError'); + }); +}); diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 5972f6a..0e11fee 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -8,10 +8,16 @@ export type RssCloudErrorCode = | 'SUBSCRIPTION_VERIFICATION_FAILED'; /** - * Shape of the domain error core raises. The concrete class (with `instanceof` - * support) lands in the implementation step; consumers match on `code` rather - * than on message text. + * The domain error core raises. Consumers match on `code` rather than on + * message text; `instanceof RssCloudError` distinguishes it from incidental + * failures (network errors, bugs). */ -export interface RssCloudError extends Error { - code: RssCloudErrorCode; +export class RssCloudError extends Error { + readonly code: RssCloudErrorCode; + + constructor(code: RssCloudErrorCode, message: string) { + super(message); + this.name = 'RssCloudError'; + this.code = code; + } } diff --git a/packages/core/src/events.test.ts b/packages/core/src/events.test.ts new file mode 100644 index 0000000..d07a3d1 --- /dev/null +++ b/packages/core/src/events.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createEventBus } from './events.js'; + +describe('createEventBus', () => { + it('delivers an emitted payload to a registered listener', () => { + const bus = createEventBus(); + const listener = vi.fn(); + + bus.on('ping', listener); + bus.emit('ping', { + resourceUrl: 'https://feed.example/rss', + changed: true, + hash: 'abc', + size: 10, + durationMs: 5 + }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0]?.[0]).toMatchObject({ changed: true }); + }); + + it('stops delivery after the returned unsubscribe is called', () => { + const bus = createEventBus(); + const listener = vi.fn(); + + const off = bus.on('notify', listener); + off(); + bus.emit('notify', { + callbackUrl: 'https://sub.example/notify', + protocol: 'http-post', + resourceUrl: 'https://feed.example/rss' + }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('fans an event out to every registered listener', () => { + const bus = createEventBus(); + const first = vi.fn(); + const second = vi.fn(); + + bus.on('ping', first); + bus.on('ping', second); + bus.emit('ping', { + resourceUrl: 'https://feed.example/rss', + changed: false, + hash: 'abc', + size: 10, + durationMs: 5 + }); + + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('ignores emits for events with no listeners', () => { + const bus = createEventBus(); + expect(() => + bus.emit('error', { scope: 'test', error: new Error('x') }) + ).not.toThrow(); + }); +}); diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 0779d77..d56ab7d 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -60,3 +60,38 @@ export interface EventBus { /** Signature of the default event-bus factory core will provide. */ export type CreateEventBus = () => EventBus; + +/** Listener with its payload type erased, for heterogeneous storage. */ +type AnyListener = (payload: never) => void; + +/** A minimal, dependency-free {@link EventBus} over an in-memory listener map. */ +export const createEventBus: CreateEventBus = () => { + const listeners = new Map>(); + + function on( + event: K, + listener: (payload: RssCloudEventMap[K]) => void + ): () => void { + const set = listeners.get(event) ?? new Set(); + listeners.set(event, set); + set.add(listener as AnyListener); + return () => { + set.delete(listener as AnyListener); + }; + } + + function emit( + event: K, + payload: RssCloudEventMap[K] + ): void { + const set = listeners.get(event); + if (set === undefined) { + return; + } + for (const listener of set) { + (listener as (payload: RssCloudEventMap[K]) => void)(payload); + } + } + + return { on, emit }; +}; diff --git a/packages/core/src/feed-parser.test.ts b/packages/core/src/feed-parser.test.ts new file mode 100644 index 0000000..5a0e18c --- /dev/null +++ b/packages/core/src/feed-parser.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; +import { createDefaultFeedParser } from './feed-parser.js'; + +const parser = createDefaultFeedParser(); + +describe('createDefaultFeedParser — RSS', () => { + it('extracts metadata from a full RSS feed', async () => { + const meta = await parser.parse( + ` + Example + An example feed + https://site.example/ + en-us + ` + ); + + expect(meta).toEqual({ + type: 'rss', + title: 'Example', + description: 'An example feed', + htmlUrl: 'https://site.example/', + language: 'en-us' + }); + }); + + it('omits fields that are absent', async () => { + const meta = await parser.parse( + `Only Title` + ); + + expect(meta).toEqual({ type: 'rss', title: 'Only Title' }); + }); + + it('returns null for an without a channel', async () => { + expect(await parser.parse(``)).toBeNull(); + }); +}); + +describe('createDefaultFeedParser — RDF (RSS 1.0)', () => { + it('extracts metadata, preferring ', async () => { + const meta = await parser.parse( + ` + RDF Feed + https://rdf.example/ + fr + ` + ); + + expect(meta).toEqual({ + type: 'rss', + title: 'RDF Feed', + htmlUrl: 'https://rdf.example/', + language: 'fr' + }); + }); + + it('falls back to ', async () => { + const meta = await parser.parse( + ` + RDF + de + ` + ); + + expect(meta).toMatchObject({ type: 'rss', language: 'de' }); + }); + + it('returns null for an without a channel', async () => { + expect(await parser.parse(``)).toBeNull(); + }); +}); + +describe('createDefaultFeedParser — Atom', () => { + it('extracts metadata and the alternate HTML link', async () => { + const meta = await parser.parse( + ` + Atom Example + Sub + + + ` + ); + + expect(meta).toEqual({ + type: 'atom', + title: 'Atom Example', + description: 'Sub', + htmlUrl: 'https://atom.example/', + language: 'en' + }); + }); + + it('handles a single link with no rel or type and no language', async () => { + const meta = await parser.parse( + `A` + ); + + expect(meta).toEqual({ + type: 'atom', + title: 'A', + htmlUrl: 'https://only.example/' + }); + }); + + it('uses the lang attribute when xml:lang is absent and has no link', async () => { + const meta = await parser.parse( + `A` + ); + + expect(meta).toEqual({ type: 'atom', title: 'A', language: 'es' }); + }); + + it('falls back to for older Atom feeds', async () => { + const meta = await parser.parse( + `AOld sub` + ); + + expect(meta).toMatchObject({ type: 'atom', description: 'Old sub' }); + }); + + it('accepts an xhtml alternate link', async () => { + const meta = await parser.parse( + `A` + ); + + expect(meta).toMatchObject({ htmlUrl: 'https://xhtml.example/' }); + }); + + it('keeps the first alternate link as a fallback when none are html', async () => { + const meta = await parser.parse( + `A + + + ` + ); + + expect(meta).toMatchObject({ htmlUrl: 'https://json.example/' }); + }); + + it('skips links with an empty or missing href', async () => { + const meta = await parser.parse( + `A + + + ` + ); + + expect(meta).toMatchObject({ htmlUrl: 'https://html.example/' }); + }); + + it('reads a link given as element text', async () => { + const meta = await parser.parse( + `Ahttps://text.example/` + ); + + expect(meta).toMatchObject({ htmlUrl: 'https://text.example/' }); + }); + + it('treats an empty attributed element as no value', async () => { + const meta = await parser.parse( + `Sub` + ); + + expect(meta).not.toHaveProperty('title'); + expect(meta).toMatchObject({ type: 'atom', description: 'Sub' }); + }); +}); + +describe('createDefaultFeedParser — rejection cases', () => { + it('returns null for an empty body', async () => { + expect(await parser.parse('')).toBeNull(); + }); + + it('returns null for a body larger than the limit', async () => { + const small = createDefaultFeedParser({ maxResourceSize: 10 }); + expect( + await small.parse(`Too big`) + ).toBeNull(); + }); + + it('returns null for invalid XML', async () => { + expect(await parser.parse(' { + expect(await parser.parse('')).toBeNull(); + }); +}); diff --git a/packages/core/src/feed-parser.ts b/packages/core/src/feed-parser.ts new file mode 100644 index 0000000..b1e8793 --- /dev/null +++ b/packages/core/src/feed-parser.ts @@ -0,0 +1,187 @@ +import { Parser } from 'xml2js'; +import { DEFAULT_CONFIG } from './config.js'; +import type { FeedMetadata, FeedParser } from './feed.js'; + +/** Construction-time options for the built-in feed parser. */ +export interface DefaultFeedParserOptions { + /** Largest body (bytes) to attempt to parse; larger bodies resolve to null. */ + maxResourceSize?: number; +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' + ? (value as Record | null) + : null; +} + +/** Trimmed text content of an element node (xml2js: a string, or `{ _, $ }`). */ +function textContent(node: unknown): string { + if (typeof node === 'string') { + return node.trim(); + } + const rec = asRecord(node); + if (rec !== null && typeof rec['_'] === 'string') { + return rec['_'].trim(); + } + return ''; +} + +/** A non-empty string attribute value, or null. */ +function attr(attrs: Record | null, key: string): string | null { + if (attrs === null) { + return null; + } + const value = attrs[key]; + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +/** Pick the HTML alternate link from an Atom `link` node (string, object, or array). */ +function atomHtmlLink(linkNode: unknown): string { + if (typeof linkNode === 'string') { + return linkNode.trim(); + } + + const candidates = Array.isArray(linkNode) ? linkNode : [linkNode]; + let fallback = ''; + + for (const candidate of candidates) { + const attrs = asRecord(asRecord(candidate)?.['$']); + const href = attr(attrs, 'href'); + if (href === null) { + continue; + } + + const rel = attr(attrs, 'rel'); + const type = attr(attrs, 'type'); + const isAlternate = rel === null || rel === 'alternate'; + const isHtml = + type === null || + type.startsWith('text/html') || + type === 'application/xhtml+xml'; + + if (isAlternate && isHtml) { + return href; + } + if (isAlternate && fallback === '') { + fallback = href; + } + } + + return fallback; +} + +function compact( + type: string, + fields: { + title: string; + description: string; + htmlUrl: string; + language: string; + } +): FeedMetadata { + const meta: FeedMetadata = { type }; + if (fields.title) { + meta.title = fields.title; + } + if (fields.description) { + meta.description = fields.description; + } + if (fields.htmlUrl) { + meta.htmlUrl = fields.htmlUrl; + } + if (fields.language) { + meta.language = fields.language; + } + return meta; +} + +function fromRss(channel: Record): FeedMetadata { + return compact('rss', { + title: textContent(channel['title']), + description: textContent(channel['description']), + htmlUrl: textContent(channel['link']), + language: textContent(channel['language']) + }); +} + +function fromRdf(channel: Record): FeedMetadata { + return compact('rss', { + title: textContent(channel['title']), + description: textContent(channel['description']), + htmlUrl: textContent(channel['link']), + language: + textContent(channel['language']) || + textContent(channel['dc:language']) + }); +} + +function fromAtom(feed: Record): FeedMetadata { + const attrs = asRecord(feed['$']); + return compact('atom', { + title: textContent(feed['title']), + description: + textContent(feed['subtitle']) || textContent(feed['tagline']), + htmlUrl: atomHtmlLink(feed['link']), + language: attr(attrs, 'xml:lang') ?? attr(attrs, 'lang') ?? '' + }); +} + +/** + * The built-in {@link FeedParser}, an xml2js port of the server's parser. + * Recognises RSS 2.0, RSS 1.0 (RDF), and Atom; resolves to null for anything + * unrecognised, malformed, or larger than the configured limit. + */ +export function createDefaultFeedParser( + options: DefaultFeedParserOptions = {} +): FeedParser { + const maxResourceSize = + options.maxResourceSize ?? DEFAULT_CONFIG.maxResourceSize; + const parser = new Parser({ + explicitArray: false, + mergeAttrs: false, + trim: true + }); + + return { + async parse(body: string): Promise { + if (body.length === 0 || body.length > maxResourceSize) { + return null; + } + + try { + const parsed = (await parser.parseStringPromise( + body + )) as Record; + + const rss = asRecord(parsed['rss']); + if (rss !== null) { + const channel = asRecord(rss['channel']); + if (channel !== null) { + return fromRss(channel); + } + } + + const rdf = asRecord(parsed['rdf:RDF']); + if (rdf !== null) { + const channel = asRecord(rdf['channel']); + if (channel !== null) { + return fromRdf(channel); + } + } + + const feed = asRecord(parsed['feed']); + if (feed !== null) { + return fromAtom(feed); + } + + return null; + } catch { + return null; + } + } + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 60f65f3..043a7b6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,21 @@ export const version = '0.0.0'; +// Implementations +export { createRssCloudCore } from './create-core.js'; +export { DEFAULT_CONFIG, resolveConfig } from './config.js'; +export { createEventBus } from './events.js'; +export { RssCloudError } from './errors.js'; +export { + createRestProtocolPlugin, + type RestProtocolPluginOptions +} from './rest-plugin.js'; +export { + createDefaultFeedParser, + type DefaultFeedParserOptions +} from './feed-parser.js'; +export { createInMemoryStore } from './memory-store.js'; + +// Contracts export type { BuiltInProtocol, Protocol } from './protocol.js'; export type { FeedMetadata, FeedParser } from './feed.js'; export type { Resource } from './resource.js'; @@ -23,7 +39,7 @@ export type { } from './plugin.js'; export type { RssCloudEventMap, EventBus, CreateEventBus } from './events.js'; export type { RssCloudConfig, ResolveConfig } from './config.js'; -export type { RssCloudErrorCode, RssCloudError } from './errors.js'; +export type { RssCloudErrorCode } from './errors.js'; export type { FeedStat, Stats, MaintenanceResult } from './stats.js'; export type { RssCloudCoreOptions, diff --git a/packages/core/src/memory-store.test.ts b/packages/core/src/memory-store.test.ts new file mode 100644 index 0000000..56a4c62 --- /dev/null +++ b/packages/core/src/memory-store.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { createInMemoryStore } from './memory-store.js'; +import type { Resource } from './resource.js'; +import type { Subscription } from './subscription.js'; + +function resource(url: string): Resource { + return { + url, + lastHash: 'hash', + lastSize: 1, + ctChecks: 1, + whenLastCheck: new Date(0), + ctUpdates: 0, + whenLastUpdate: new Date(0) + }; +} + +function subscription(url: string): Subscription { + return { + url, + protocol: 'http-post', + ctUpdates: 0, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: new Date(0), + whenLastUpdate: null, + whenLastError: null, + whenExpires: new Date('2099-01-01T00:00:00Z') + }; +} + +describe('createInMemoryStore', () => { + it('returns null for a resource that was never stored', async () => { + const store = createInMemoryStore(); + expect(await store.getResource('https://feed.example/rss')).toBeNull(); + }); + + it('round-trips a resource', async () => { + const store = createInMemoryStore(); + const res = resource('https://feed.example/rss'); + + await store.putResource('https://feed.example/rss', res); + + expect(await store.getResource('https://feed.example/rss')).toBe(res); + }); + + it('returns an empty list for subscriptions that were never stored', async () => { + const store = createInMemoryStore(); + expect( + await store.getSubscriptions('https://feed.example/rss') + ).toEqual([]); + }); + + it('round-trips subscriptions', async () => { + const store = createInMemoryStore(); + const subs = [subscription('https://sub.example/notify')]; + + await store.putSubscriptions('https://feed.example/rss', subs); + + expect(await store.getSubscriptions('https://feed.example/rss')).toBe( + subs + ); + }); + + it('keeps subscriptions when a resource is added later', async () => { + const store = createInMemoryStore(); + const subs = [subscription('https://sub.example/notify')]; + + await store.putSubscriptions('https://feed.example/rss', subs); + await store.putResource( + 'https://feed.example/rss', + resource('https://feed.example/rss') + ); + + expect(await store.getSubscriptions('https://feed.example/rss')).toBe( + subs + ); + }); + + it('keeps a resource when subscriptions are added later', async () => { + const store = createInMemoryStore(); + const res = resource('https://feed.example/rss'); + + await store.putResource('https://feed.example/rss', res); + await store.putSubscriptions('https://feed.example/rss', [ + subscription('https://sub.example/notify') + ]); + + expect(await store.getResource('https://feed.example/rss')).toBe(res); + }); + + it('reports null for a resource on a feed that only has subscriptions', async () => { + const store = createInMemoryStore(); + await store.putSubscriptions('https://feed.example/rss', [ + subscription('https://sub.example/notify') + ]); + + expect(await store.getResource('https://feed.example/rss')).toBeNull(); + }); + + it('lists nothing before anything is stored', async () => { + const store = createInMemoryStore(); + expect(await store.list()).toEqual([]); + }); + + it('lists every tracked feed with its resource and subscriptions', async () => { + const store = createInMemoryStore(); + const res = resource('https://feed.example/rss'); + const subs = [subscription('https://sub.example/notify')]; + + await store.putResource('https://feed.example/rss', res); + await store.putSubscriptions('https://feed.example/rss', subs); + + expect(await store.list()).toEqual([ + { + feedUrl: 'https://feed.example/rss', + resource: res, + subscriptions: subs + } + ]); + }); + + it('removes a feed entirely', async () => { + const store = createInMemoryStore(); + await store.putResource( + 'https://feed.example/rss', + resource('https://feed.example/rss') + ); + + await store.remove('https://feed.example/rss'); + + expect(await store.getResource('https://feed.example/rss')).toBeNull(); + expect(await store.list()).toEqual([]); + }); +}); diff --git a/packages/core/src/memory-store.ts b/packages/core/src/memory-store.ts new file mode 100644 index 0000000..2b35a4d --- /dev/null +++ b/packages/core/src/memory-store.ts @@ -0,0 +1,60 @@ +import type { Resource } from './resource.js'; +import type { FeedEntry, Store } from './store.js'; +import type { Subscription } from './subscription.js'; + +interface Entry { + resource: Resource | null; + subscriptions: Subscription[]; +} + +/** + * A Map-backed {@link Store} reference implementation. Suitable for tests, dev, + * and small deployments; hosts that need durability supply their own Store + * (e.g. file- or database-backed) implementing the same port. + */ +export function createInMemoryStore(): Store { + const feeds = new Map(); + + function upsert(feedUrl: string): Entry { + const existing = feeds.get(feedUrl); + if (existing !== undefined) { + return existing; + } + const created: Entry = { resource: null, subscriptions: [] }; + feeds.set(feedUrl, created); + return created; + } + + return { + async getResource(feedUrl: string): Promise { + return feeds.get(feedUrl)?.resource ?? null; + }, + + async putResource(feedUrl: string, resource: Resource): Promise { + upsert(feedUrl).resource = resource; + }, + + async getSubscriptions(feedUrl: string): Promise { + return feeds.get(feedUrl)?.subscriptions ?? []; + }, + + async putSubscriptions( + feedUrl: string, + subscriptions: Subscription[] + ): Promise { + upsert(feedUrl).subscriptions = subscriptions; + }, + + async list(): Promise { + return Array.from(feeds, ([feedUrl, entry]) => ({ + feedUrl, + resource: entry.resource, + subscriptions: entry.subscriptions + })); + }, + + async remove(feedUrl: string): Promise { + feeds.delete(feedUrl); + } + }; +} diff --git a/packages/core/src/rest-plugin.test.ts b/packages/core/src/rest-plugin.test.ts new file mode 100644 index 0000000..59340a7 --- /dev/null +++ b/packages/core/src/rest-plugin.test.ts @@ -0,0 +1,349 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { DeliveryContext, VerifyContext } from './plugin.js'; +import type { Resource } from './resource.js'; +import type { Subscription } from './subscription.js'; +import { createRestProtocolPlugin } from './rest-plugin.js'; + +const epoch = new Date(0); + +function subscription(url: string): Subscription { + return { + url, + protocol: 'http-post', + ctUpdates: 0, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: epoch, + whenLastUpdate: null, + whenLastError: null, + whenExpires: new Date('2099-01-01T00:00:00Z') + }; +} + +function resource(url: string): Resource { + return { + url, + lastHash: '', + lastSize: 0, + ctChecks: 0, + whenLastCheck: epoch, + ctUpdates: 0, + whenLastUpdate: epoch + }; +} + +function deliveryContext( + callbackUrl: string, + resourceUrl: string +): DeliveryContext { + return { + subscription: subscription(callbackUrl), + resource: resource(resourceUrl), + payload: { body: '', contentType: null } + }; +} + +function verifyContext( + callbackUrl: string, + resourceUrl: string, + diffDomain: boolean +): VerifyContext { + return { + subscription: subscription(callbackUrl), + resourceUrl, + diffDomain + }; +} + +describe('createRestProtocolPlugin deliver', () => { + it('POSTs the resource url form-encoded and reports ok on 2xx', async () => { + const calls: { url: string; init: RequestInit }[] = []; + const fakeFetch = (async (url: string | URL, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }); + return new Response('', { status: 200 }); + }) as typeof fetch; + + const plugin = createRestProtocolPlugin({ fetch: fakeFetch }); + + const result = await plugin.deliver( + deliveryContext( + 'https://subscriber.example/notify', + 'https://feed.example/rss' + ) + ); + + expect(result).toEqual({ ok: true }); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe('https://subscriber.example/notify'); + expect(calls[0]?.init.method).toBe('POST'); + const body = calls[0]?.init.body as URLSearchParams; + expect(body.get('url')).toBe('https://feed.example/rss'); + }); + + it('reports failure when the callback responds non-2xx', async () => { + const fakeFetch = (async () => + new Response('nope', { status: 500 })) as typeof fetch; + + const plugin = createRestProtocolPlugin({ fetch: fakeFetch }); + + const result = await plugin.deliver( + deliveryContext( + 'https://subscriber.example/notify', + 'https://feed.example/rss' + ) + ); + + expect(result.ok).toBe(false); + expect(result.error).toBeInstanceOf(Error); + }); + + it('follows a 3xx redirect by re-POSTing to the resolved Location', async () => { + const calls: string[] = []; + const fakeFetch = (async (url: string | URL) => { + calls.push(String(url)); + if (calls.length === 1) { + return new Response('', { + status: 302, + headers: { location: '/moved' } + }); + } + return new Response('', { status: 200 }); + }) as typeof fetch; + + const plugin = createRestProtocolPlugin({ fetch: fakeFetch }); + + const result = await plugin.deliver( + deliveryContext( + 'https://subscriber.example/notify', + 'https://feed.example/rss' + ) + ); + + expect(result).toEqual({ ok: true }); + expect(calls).toEqual([ + 'https://subscriber.example/notify', + 'https://subscriber.example/moved' + ]); + }); + + it('treats a 3xx without a Location header as a failure', async () => { + const fakeFetch = (async () => + new Response('', { status: 302 })) as typeof fetch; + + const plugin = createRestProtocolPlugin({ fetch: fakeFetch }); + + const result = await plugin.deliver( + deliveryContext( + 'https://subscriber.example/notify', + 'https://feed.example/rss' + ) + ); + + expect(result.ok).toBe(false); + expect(result.error).toBeInstanceOf(Error); + }); + + it('reports failure when the request throws', async () => { + const fakeFetch = (async () => { + throw new Error('boom'); + }) as typeof fetch; + + const plugin = createRestProtocolPlugin({ fetch: fakeFetch }); + + const result = await plugin.deliver( + deliveryContext( + 'https://subscriber.example/notify', + 'https://feed.example/rss' + ) + ); + + expect(result.ok).toBe(false); + expect(result.error).toBeInstanceOf(Error); + }); + + it('aborts and fails when the callback exceeds the timeout', async () => { + vi.useFakeTimers(); + let abortedWith: unknown; + const fakeFetch = ((_url: string | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + abortedWith = init.signal?.reason; + reject( + Object.assign(new Error('aborted'), { + name: 'AbortError' + }) + ); + }); + })) as typeof fetch; + + const plugin = createRestProtocolPlugin({ + fetch: fakeFetch, + requestTimeoutMs: 50 + }); + + const promise = plugin.deliver( + deliveryContext( + 'https://subscriber.example/notify', + 'https://feed.example/rss' + ) + ); + + await vi.advanceTimersByTimeAsync(50); + const result = await promise; + + expect(result.ok).toBe(false); + expect(result.error).toBeInstanceOf(Error); + expect(abortedWith).toBeDefined(); + }); +}); + +describe('createRestProtocolPlugin verify', () => { + it('passes the cross-domain challenge handshake', async () => { + const calls: string[] = []; + const fakeFetch = (async (url: string | URL) => { + calls.push(String(url)); + const challenge = new URL(String(url)).searchParams.get('challenge'); + return new Response(challenge, { status: 200 }); + }) as typeof fetch; + + const plugin = createRestProtocolPlugin({ + fetch: fakeFetch, + createChallenge: () => 'abc123' + }); + + await expect( + plugin.verify( + verifyContext( + 'https://subscriber.example/notify', + 'https://feed.example/rss', + true + ) + ) + ).resolves.toBeUndefined(); + + const url = new URL(calls[0] as string); + expect(url.origin + url.pathname).toBe( + 'https://subscriber.example/notify' + ); + expect(url.searchParams.get('url')).toBe('https://feed.example/rss'); + expect(url.searchParams.get('challenge')).toBe('abc123'); + }); + + it('rejects when the challenge response does not echo the token', async () => { + const fakeFetch = (async () => + new Response('mismatch', { status: 200 })) as typeof fetch; + + const plugin = createRestProtocolPlugin({ + fetch: fakeFetch, + createChallenge: () => 'abc123' + }); + + await expect( + plugin.verify( + verifyContext( + 'https://subscriber.example/notify', + 'https://feed.example/rss', + true + ) + ) + ).rejects.toThrow(); + }); + + it('rejects when the challenge response is non-2xx', async () => { + const fakeFetch = (async (url: string | URL) => { + const challenge = new URL(String(url)).searchParams.get('challenge'); + return new Response(challenge, { status: 404 }); + }) as typeof fetch; + + const plugin = createRestProtocolPlugin({ + fetch: fakeFetch, + createChallenge: () => 'abc123' + }); + + await expect( + plugin.verify( + verifyContext( + 'https://subscriber.example/notify', + 'https://feed.example/rss', + true + ) + ) + ).rejects.toThrow(); + }); + + it('confirms a same-domain subscription with a test notification', async () => { + const calls: { url: string; init: RequestInit }[] = []; + const fakeFetch = (async (url: string | URL, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }); + return new Response('', { status: 200 }); + }) as typeof fetch; + + const plugin = createRestProtocolPlugin({ fetch: fakeFetch }); + + await expect( + plugin.verify( + verifyContext( + 'https://subscriber.example/notify', + 'https://feed.example/rss', + false + ) + ) + ).resolves.toBeUndefined(); + + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe('https://subscriber.example/notify'); + expect(calls[0]?.init.method).toBe('POST'); + const body = calls[0]?.init.body as URLSearchParams; + expect(body.get('url')).toBe('https://feed.example/rss'); + }); + + it('rejects a same-domain subscription when the test notify fails', async () => { + const fakeFetch = (async () => + new Response('', { status: 500 })) as typeof fetch; + + const plugin = createRestProtocolPlugin({ fetch: fakeFetch }); + + await expect( + plugin.verify( + verifyContext( + 'https://subscriber.example/notify', + 'https://feed.example/rss', + false + ) + ) + ).rejects.toThrow(); + }); + + it('generates its own challenge token when none is injected', async () => { + let sentChallenge: string | null = null; + const fakeFetch = (async (url: string | URL) => { + sentChallenge = new URL(String(url)).searchParams.get('challenge'); + return new Response(sentChallenge, { status: 200 }); + }) as typeof fetch; + + const plugin = createRestProtocolPlugin({ fetch: fakeFetch }); + + await expect( + plugin.verify( + verifyContext( + 'https://subscriber.example/notify', + 'https://feed.example/rss', + true + ) + ) + ).resolves.toBeUndefined(); + + expect(sentChallenge).toMatch(/^[0-9a-f]+$/); + }); +}); + +describe('createRestProtocolPlugin protocols', () => { + it('owns the rssCloud REST protocol values', () => { + const plugin = createRestProtocolPlugin(); + expect(plugin.protocols).toEqual(['http-post', 'https-post']); + }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); diff --git a/packages/core/src/rest-plugin.ts b/packages/core/src/rest-plugin.ts new file mode 100644 index 0000000..b5f5655 --- /dev/null +++ b/packages/core/src/rest-plugin.ts @@ -0,0 +1,132 @@ +import type { + DeliveryContext, + DeliveryResult, + ProtocolPlugin, + VerifyContext +} from './plugin.js'; +import type { Protocol } from './protocol.js'; + +/** Construction-time dependencies for the rssCloud REST protocol plugin. */ +export interface RestProtocolPluginOptions { + /** Injectable fetch (tests, edge runtimes); defaults to global fetch. */ + fetch?: typeof fetch; + /** Per-request timeout (ms) for outbound calls. */ + requestTimeoutMs?: number; + /** Challenge generator for the cross-domain handshake (injectable for tests). */ + createChallenge?: () => string; +} + +const REST_PROTOCOLS: Protocol[] = ['http-post', 'https-post']; + +/** Fallback request timeout when none is supplied (mirrors the server default). */ +const DEFAULT_REQUEST_TIMEOUT_MS = 4000; + +/** Portable, hard-to-guess token for the cross-domain challenge handshake. */ +function defaultCreateChallenge(): string { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join( + '' + ); +} + +/** + * The rssCloud REST delivery protocol (`http-post` / `https-post`). Subscribers + * are notified with a form-encoded POST carrying the changed resource URL; + * cross-domain subscriptions are confirmed with a challenge GET handshake. + */ +export function createRestProtocolPlugin( + options: RestProtocolPluginOptions = {} +): ProtocolPlugin { + const doFetch = options.fetch ?? fetch; + const requestTimeoutMs = + options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const createChallenge = options.createChallenge ?? defaultCreateChallenge; + + function notifyBody(resourceUrl: string): URLSearchParams { + const body = new URLSearchParams(); + body.append('url', resourceUrl); + return body; + } + + /** Fetch with the configured timeout enforced via an abort signal. */ + async function fetchWithTimeout( + url: string, + init: RequestInit + ): Promise { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + requestTimeoutMs + ); + + try { + return await doFetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } + } + + /** POST the notification, following redirects; throws on timeout or non-2xx. */ + async function sendNotify( + targetUrl: string, + body: URLSearchParams + ): Promise { + const res = await fetchWithTimeout(targetUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + redirect: 'manual' + }); + + if (res.status >= 300 && res.status < 400) { + const location = res.headers.get('location'); + if (location) { + await sendNotify( + new URL(location, targetUrl).toString(), + body + ); + return; + } + } + + if (!res.ok) { + throw new Error('Notification Failed'); + } + } + + async function deliver(ctx: DeliveryContext): Promise { + try { + await sendNotify(ctx.subscription.url, notifyBody(ctx.resource.url)); + return { ok: true }; + } catch (err) { + return { ok: false, error: err as Error }; + } + } + + async function verifyChallenge( + apiurl: string, + resourceUrl: string + ): Promise { + const challenge = createChallenge(); + const query = new URLSearchParams({ url: resourceUrl, challenge }); + const testUrl = apiurl + '?' + query.toString(); + + const res = await fetchWithTimeout(testUrl, { method: 'GET' }); + const body = await res.text(); + + if (!res.ok || body !== challenge) { + throw new Error('Notification Failed'); + } + } + + async function verify(ctx: VerifyContext): Promise { + if (ctx.diffDomain) { + await verifyChallenge(ctx.subscription.url, ctx.resourceUrl); + return; + } + await sendNotify(ctx.subscription.url, notifyBody(ctx.resourceUrl)); + } + + return { protocols: REST_PROTOCOLS, verify, deliver }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc2f2cc..a5e545a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,10 @@ importers: version: 3.1.14 packages/core: + dependencies: + xml2js: + specifier: ^0.6.2 + version: 0.6.2 devDependencies: '@eslint/js': specifier: ^9.18.0 @@ -128,6 +132,9 @@ importers: '@types/node': specifier: ^22.10.0 version: 22.19.19 + '@types/xml2js': + specifier: ^0.4.14 + version: 0.4.14 '@vitest/coverage-v8': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1)) @@ -843,6 +850,9 @@ packages: '@types/superagent@4.1.13': resolution: {integrity: sha512-YIGelp3ZyMiH0/A09PMAORO0EBGlF5xIKfDpK74wdYvWUs2o96b5CItJcWPdH409b7SAXIIG6p8NdU/4U2Maww==} + '@types/xml2js@0.4.14': + resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} + '@typescript-eslint/eslint-plugin@8.59.4': resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3257,6 +3267,10 @@ snapshots: '@types/cookiejar': 2.1.5 '@types/node': 22.19.19 + '@types/xml2js@0.4.14': + dependencies: + '@types/node': 22.19.19 + '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 From ea7060bacc08fdda359e483168f912056288b56b Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Tue, 9 Jun 2026 19:28:00 -0500 Subject: [PATCH 19/90] feat(core): add xml-rpc rssCloud plugin and dispatcher Add XML-RPC support to @rsscloud/core, additive to the core package: - xml-rpc-codec: network-free wire codec (parseMethodCall, serializeSuccess, serializeFault, buildNotifyCall) ported from the legacy server services using xml2js, decoding dateTime.iso8601 with native Date. - xml-rpc-plugin: createXmlRpcProtocolPlugin owning ['xml-rpc']; deliver POSTs a text/xml methodCall with an AbortController timeout; verify is a plain test notify with no challenge handshake (diffDomain ignored), per legacy behavior. - xml-rpc-dispatcher: createXmlRpcDispatcher, raw-XML-in/raw-XML-out, never throws. Maps hello/pleaseNotify/ping to core, preserving legacy param semantics (arity, protocol validation, callback URL glue, https inference, ::ffff strip, IPv6 bracketing) and Dave's ping-always-succeeds quirk. 100% line/branch/function/statement coverage on all three new modules. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/index.ts | 10 + packages/core/src/xml-rpc-codec.test.ts | 300 ++++++++++++++ packages/core/src/xml-rpc-codec.ts | 160 ++++++++ packages/core/src/xml-rpc-dispatcher.test.ts | 402 +++++++++++++++++++ packages/core/src/xml-rpc-dispatcher.ts | 209 ++++++++++ packages/core/src/xml-rpc-plugin.test.ts | 245 +++++++++++ packages/core/src/xml-rpc-plugin.ts | 90 +++++ 7 files changed, 1416 insertions(+) create mode 100644 packages/core/src/xml-rpc-codec.test.ts create mode 100644 packages/core/src/xml-rpc-codec.ts create mode 100644 packages/core/src/xml-rpc-dispatcher.test.ts create mode 100644 packages/core/src/xml-rpc-dispatcher.ts create mode 100644 packages/core/src/xml-rpc-plugin.test.ts create mode 100644 packages/core/src/xml-rpc-plugin.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 043a7b6..58318ba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,6 +9,16 @@ export { createRestProtocolPlugin, type RestProtocolPluginOptions } from './rest-plugin.js'; +export { + createXmlRpcProtocolPlugin, + type XmlRpcProtocolPluginOptions +} from './xml-rpc-plugin.js'; +export { + createXmlRpcDispatcher, + type XmlRpcDispatcher, + type XmlRpcDispatcherOptions, + type XmlRpcDispatchContext +} from './xml-rpc-dispatcher.js'; export { createDefaultFeedParser, type DefaultFeedParserOptions diff --git a/packages/core/src/xml-rpc-codec.test.ts b/packages/core/src/xml-rpc-codec.test.ts new file mode 100644 index 0000000..46fd7dc --- /dev/null +++ b/packages/core/src/xml-rpc-codec.test.ts @@ -0,0 +1,300 @@ +import { Parser } from 'xml2js'; +import { describe, expect, it } from 'vitest'; +import { + buildNotifyCall, + parseMethodCall, + serializeFault, + serializeSuccess +} from './xml-rpc-codec.js'; + +function reparse(xml: string): Promise { + return new Parser({ explicitArray: false }).parseStringPromise(xml); +} + +describe('parseMethodCall', () => { + it('decodes the method name and a single string param', async () => { + const xml = ` + + rssCloud.ping + + http://feed.example/rss + + `; + + const call = await parseMethodCall(xml); + + expect(call.methodName).toBe('rssCloud.ping'); + expect(call.params).toEqual(['http://feed.example/rss']); + }); + + it('decodes an untyped (bare string) value', async () => { + const xml = + 'm' + + 'bare text' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual(['bare text']); + }); + + it('decodes several positional params in order', async () => { + const xml = + 'm' + + 'first' + + 'second' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual(['first', 'second']); + }); + + it('throws when the methodCall element is missing', async () => { + await expect(parseMethodCall('')).rejects.toThrow( + 'missing "methodCall" element' + ); + }); + + it('throws when the methodName element is missing', async () => { + const xml = ''; + + await expect(parseMethodCall(xml)).rejects.toThrow( + 'missing "methodName" element' + ); + }); + + it('rejects malformed XML', async () => { + await expect(parseMethodCall('')).rejects.toThrow(); + }); + + it('decodes a methodCall with no params into an empty list', async () => { + const xml = 'rssCloud.hello'; + + const call = await parseMethodCall(xml); + + expect(call.methodName).toBe('rssCloud.hello'); + expect(call.params).toEqual([]); + }); + + it('decodes numeric types (i4, int, double) as numbers', async () => { + const xml = + 'm' + + '7' + + '5' + + '1.5' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([7, 5, 1.5]); + }); + + it('decodes booleans from textual "true" and numeric "1"', async () => { + const xml = + 'm' + + 'true' + + '1' + + '0' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([true, true, false]); + }); + + it('decodes a valid dateTime.iso8601 into a Date', async () => { + const xml = + 'm' + + '2013-01-02T03:04:05Z' + + '' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params[0]).toBeInstanceOf(Date); + expect((call.params[0] as Date).toISOString()).toBe( + '2013-01-02T03:04:05.000Z' + ); + }); + + it('keeps an unparseable dateTime.iso8601 as the raw string', async () => { + const xml = + 'm' + + 'not-a-date' + + '' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual(['not-a-date']); + }); + + it('decodes base64 into a UTF-8 string', async () => { + const encoded = Buffer.from('héllo', 'utf8').toString('base64'); + const xml = + 'm' + + `${encoded}` + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual(['héllo']); + }); + + it('decodes a struct with several members into an object', async () => { + const xml = + 'm' + + '' + + 'hostrpc.example' + + '' + + 'port80' + + '' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([{ host: 'rpc.example', port: 80 }]); + }); + + it('decodes a single-member struct into an object', async () => { + const xml = + 'm' + + 'onlyone' + + '' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([{ only: 'one' }]); + }); + + it('decodes an empty struct into an empty object', async () => { + const xml = + 'm' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([{}]); + }); + + it('decodes an array with several elements', async () => { + const xml = + 'm' + + '' + + 'a' + + 'b' + + '' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([['a', 'b']]); + }); + + it('coerces a single-element array', async () => { + const xml = + 'm' + + 'only' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([['only']]); + }); + + it('decodes an empty array into an empty list', async () => { + const xml = + 'm' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([[]]); + }); + + it('decodes an array node with no data into an empty list', async () => { + const xml = + 'm' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([[]]); + }); + + it('returns the raw node for an unknown value type', async () => { + const xml = + 'm' + + 'x'; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([{ unknownType: 'x' }]); + }); +}); + +describe('serializeSuccess', () => { + it('emits a methodResponse boolean of 1 for true', async () => { + const parsed = (await reparse(serializeSuccess(true))) as { + methodResponse: { params: { param: { value: { boolean: string } } } }; + }; + + expect(parsed.methodResponse.params.param.value.boolean).toBe('1'); + }); + + it('emits a methodResponse boolean of 0 for false', async () => { + const parsed = (await reparse(serializeSuccess(false))) as { + methodResponse: { params: { param: { value: { boolean: string } } } }; + }; + + expect(parsed.methodResponse.params.param.value.boolean).toBe('0'); + }); +}); + +describe('serializeFault', () => { + it('emits the faultCode/faultString struct, entities surviving', async () => { + const message = 'Bad protocol & stuff'; + const parsed = (await reparse(serializeFault(4, message))) as { + methodResponse: { + fault: { + value: { + struct: { + member: { name: string; value: Record }[]; + }; + }; + }; + }; + }; + + const members = parsed.methodResponse.fault.value.struct.member; + expect(members[0]?.name).toBe('faultCode'); + expect(members[0]?.value['int']).toBe('4'); + expect(members[1]?.name).toBe('faultString'); + expect(members[1]?.value['string']).toBe(message); + }); +}); + +describe('buildNotifyCall', () => { + it('builds a methodCall that round-trips back to its procedure and url', async () => { + const xml = buildNotifyCall( + 'myCloud.notify', + 'https://feed.example/rss' + ); + + const call = await parseMethodCall(xml); + + expect(call.methodName).toBe('myCloud.notify'); + expect(call.params).toEqual(['https://feed.example/rss']); + }); + + it('emits an empty methodName when the procedure is blank', async () => { + const call = await parseMethodCall( + buildNotifyCall('', 'https://feed.example/rss') + ); + + expect(call.methodName).toBe(''); + expect(call.params).toEqual(['https://feed.example/rss']); + }); +}); diff --git a/packages/core/src/xml-rpc-codec.ts b/packages/core/src/xml-rpc-codec.ts new file mode 100644 index 0000000..5d561eb --- /dev/null +++ b/packages/core/src/xml-rpc-codec.ts @@ -0,0 +1,160 @@ +import { Builder, Parser } from 'xml2js'; + +/** A decoded XML-RPC `methodCall`: its method name and positional params. */ +export interface MethodCall { + methodName: string; + params: unknown[]; +} + +/** xml2js node as a record, or null for primitives (the `explicitArray:false` shape). */ +function asRecord(value: unknown): Record | null { + return typeof value === 'object' + ? (value as Record | null) + : null; +} + +/** Normalise xml2js's "scalar | single | array" into an array. */ +function toArray(value: unknown): unknown[] { + if (value === undefined) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +/** Decode `dateTime.iso8601` with native `Date`; keep the raw text if unparseable. */ +function decodeDate(raw: unknown): Date | string { + const text = String(raw); + const date = new Date(text); + return Number.isNaN(date.getTime()) ? text : date; +} + +/** Decode a `` node into a plain object keyed by member name. */ +function decodeStruct(node: unknown): Record { + const rec = asRecord(node); + const members = toArray(rec === null ? undefined : rec['member']); + const out: Record = {}; + for (const member of members) { + const m = member as Record; + out[String(m['name'])] = decode(member); + } + return out; +} + +/** Decode an `` node into a list, recursively decoding each ``. */ +function decodeArray(node: unknown): unknown[] { + const rec = asRecord(node); + const data = rec === null ? null : asRecord(rec['data']); + const values = toArray(data === null ? undefined : data['value']); + return values.map(decode); +} + +/** Decode a typed `` body (`{ : ... }`) by its single type tag. */ +function decodeTyped(typed: Record): unknown { + for (const tag of Object.keys(typed)) { + switch (tag) { + case 'i4': + case 'int': + case 'double': + return Number(typed[tag]); + case 'string': + return typed[tag]; + case 'boolean': + return typed[tag] === 'true' || Boolean(Number(typed[tag])); + case 'dateTime.iso8601': + return decodeDate(typed[tag]); + case 'base64': + return Buffer.from(String(typed[tag]), 'base64').toString( + 'utf8' + ); + case 'struct': + return decodeStruct(typed[tag]); + case 'array': + return decodeArray(typed[tag]); + } + } + return typed; +} + +/** Decode one `` (or its wrapping ``/``) into a JS value. */ +function decode(node: unknown): unknown { + const wrapper = asRecord(node); + const value = + wrapper !== null && 'value' in wrapper ? wrapper['value'] : node; + + const typed = asRecord(value); + if (typed === null) { + return value; + } + + return decodeTyped(typed); +} + +/** + * Decode an XML-RPC `methodCall` document. Throws on malformed XML or a missing + * `methodCall`/`methodName` element. + */ +export async function parseMethodCall(xml: string): Promise { + const parser = new Parser({ explicitArray: false }); + const parsed = (await parser.parseStringPromise(xml)) as Record< + string, + unknown + >; + + const methodCall = asRecord(parsed['methodCall']); + if (methodCall === null) { + throw new Error('Bad XML-RPC call, missing "methodCall" element.'); + } + + const methodName = methodCall['methodName']; + if (methodName === undefined) { + throw new Error('Bad XML-RPC call, missing "methodName" element.'); + } + + const paramsNode = asRecord(methodCall['params']); + const paramRaw = paramsNode === null ? undefined : paramsNode['param']; + const params = toArray(paramRaw).map(decode); + + return { methodName: String(methodName), params }; +} + +/** Serialize a `methodResponse` carrying a single boolean param. */ +export function serializeSuccess(success: boolean): string { + return new Builder().buildObject({ + methodResponse: { + params: { + param: { value: { boolean: success ? 1 : 0 } } + } + } + }); +} + +/** + * Build a `methodCall` to `procedure` carrying the resource URL as a single + * untyped (string) param — the rssCloud XML-RPC notify shape. + */ +export function buildNotifyCall(procedure: string, url: string): string { + return new Builder().buildObject({ + methodCall: { + methodName: procedure, + params: { param: { value: url } } + } + }); +} + +/** Serialize a `methodResponse` fault with the standard faultCode/faultString struct. */ +export function serializeFault(code: number, str: string): string { + return new Builder().buildObject({ + methodResponse: { + fault: { + value: { + struct: { + member: [ + { name: 'faultCode', value: { int: code } }, + { name: 'faultString', value: { string: str } } + ] + } + } + } + } + }); +} diff --git a/packages/core/src/xml-rpc-dispatcher.test.ts b/packages/core/src/xml-rpc-dispatcher.test.ts new file mode 100644 index 0000000..1af72f9 --- /dev/null +++ b/packages/core/src/xml-rpc-dispatcher.test.ts @@ -0,0 +1,402 @@ +import { Builder, Parser } from 'xml2js'; +import { describe, expect, it } from 'vitest'; +import type { + PingRequest, + PingResponse, + SubscribeRequest, + SubscribeResponse +} from './dto.js'; +import { createXmlRpcDispatcher } from './xml-rpc-dispatcher.js'; + +interface FakeCore { + subscribe(req: SubscribeRequest): Promise; + ping(req: PingRequest): Promise; + subscribeCalls: SubscribeRequest[]; + pingCalls: PingRequest[]; +} + +function fakeCore(overrides: Partial = {}): FakeCore { + const core: FakeCore = { + subscribeCalls: [], + pingCalls: [], + async subscribe(req) { + core.subscribeCalls.push(req); + return { success: true, message: 'ok' }; + }, + async ping(req) { + core.pingCalls.push(req); + return { success: true, message: 'ok' }; + }, + ...overrides + }; + return core; +} + +/** Render a positional param value: arrays become ``, scalars bare strings. */ +function valueNode(value: unknown): unknown { + if (Array.isArray(value)) { + return { + array: { data: { value: value.map((item) => String(item)) } } + }; + } + return String(value); +} + +/** Build a methodCall document for `method` with the given positional params. */ +function methodCall(method: string, params: unknown[]): string { + return new Builder().buildObject({ + methodCall: { + methodName: method, + params: { param: params.map((p) => ({ value: valueNode(p) })) } + } + }); +} + +interface ParsedResponse { + methodResponse: { + params?: { param: { value: { boolean: string } } }; + fault?: { + value: { + struct: { + member: { name: string; value: Record }[]; + }; + }; + }; + }; +} + +async function parseResponse(xml: string): Promise { + return (await new Parser({ explicitArray: false }).parseStringPromise( + xml + )) as ParsedResponse; +} + +function isSuccess(parsed: ParsedResponse): boolean | undefined { + return parsed.methodResponse.params?.param.value.boolean === '1'; +} + +function faultString(parsed: ParsedResponse): string | undefined { + return parsed.methodResponse.fault?.value.struct.member[1]?.value['string']; +} + +describe('createXmlRpcDispatcher hello', () => { + it('answers rssCloud.hello with success without touching core', async () => { + const core = fakeCore(); + const dispatcher = createXmlRpcDispatcher({ core }); + + const out = await dispatcher.dispatch( + methodCall('rssCloud.hello', []), + { clientAddress: '203.0.113.5' } + ); + + expect(isSuccess(await parseResponse(out))).toBe(true); + expect(core.subscribeCalls).toHaveLength(0); + expect(core.pingCalls).toHaveLength(0); + }); + + it('faults on an unknown method', async () => { + const dispatcher = createXmlRpcDispatcher({ core: fakeCore() }); + + const out = await dispatcher.dispatch( + methodCall('rssCloud.goodbye', []), + { clientAddress: '203.0.113.5' } + ); + + expect(faultString(await parseResponse(out))).toBe( + 'Can\'t make the call because "rssCloud.goodbye" is not defined.' + ); + }); + + it('faults on a malformed body', async () => { + const dispatcher = createXmlRpcDispatcher({ core: fakeCore() }); + + const out = await dispatcher.dispatch('', { + clientAddress: '203.0.113.5' + }); + + expect(faultString(await parseResponse(out))).toBeDefined(); + }); +}); + +describe('createXmlRpcDispatcher pleaseNotify', () => { + it('maps an explicit-domain xml-rpc subscription and relays success', async () => { + const core = fakeCore(); + const dispatcher = createXmlRpcDispatcher({ core }); + + const out = await dispatcher.dispatch( + methodCall('rssCloud.pleaseNotify', [ + 'myCloud.notify', + '5337', + '/RPC2', + 'xml-rpc', + 'http://feed.example/rss', + 'sub.example.com' + ]), + { clientAddress: '203.0.113.5' } + ); + + expect(isSuccess(await parseResponse(out))).toBe(true); + expect(core.subscribeCalls).toEqual([ + { + resourceUrls: ['http://feed.example/rss'], + callbackUrl: 'http://sub.example.com:5337/RPC2', + protocol: 'xml-rpc', + notifyProcedure: 'myCloud.notify', + diffDomain: true + } + ]); + }); + + it('uses the client address when no domain is given (https-post, path gets a slash)', async () => { + const core = fakeCore(); + const dispatcher = createXmlRpcDispatcher({ core }); + + await dispatcher.dispatch( + methodCall('rssCloud.pleaseNotify', [ + '', + '8080', + 'callback', + 'https-post', + 'http://feed.example/rss' + ]), + { clientAddress: '203.0.113.5' } + ); + + expect(core.subscribeCalls).toEqual([ + { + resourceUrls: ['http://feed.example/rss'], + callbackUrl: 'https://203.0.113.5:8080/callback', + protocol: 'https-post', + diffDomain: false + } + ]); + }); + + it('infers https from port 443 and strips a ::ffff: client prefix', async () => { + const core = fakeCore(); + const dispatcher = createXmlRpcDispatcher({ core }); + + await dispatcher.dispatch( + methodCall('rssCloud.pleaseNotify', [ + '', + '443', + '/cb', + 'http-post', + 'http://feed.example/rss' + ]), + { clientAddress: '::ffff:198.51.100.7' } + ); + + expect(core.subscribeCalls[0]?.callbackUrl).toBe( + 'https://198.51.100.7:443/cb' + ); + }); + + it('brackets a bare IPv6 domain, coerces an array urlList, omits a blank xml-rpc procedure', async () => { + const core = fakeCore(); + const dispatcher = createXmlRpcDispatcher({ core }); + + await dispatcher.dispatch( + methodCall('rssCloud.pleaseNotify', [ + '', + '5337', + '/RPC2', + 'xml-rpc', + ['http://a.example/rss', 'http://b.example/rss'], + '::1' + ]), + { clientAddress: '203.0.113.5' } + ); + + expect(core.subscribeCalls).toEqual([ + { + resourceUrls: ['http://a.example/rss', 'http://b.example/rss'], + callbackUrl: 'http://[::1]:5337/RPC2', + protocol: 'xml-rpc', + diffDomain: true + } + ]); + }); + + it('relays a subscribe failure as boolean false', async () => { + const core = fakeCore({ + async subscribe() { + return { success: false, message: 'no' }; + } + }); + const dispatcher = createXmlRpcDispatcher({ core }); + + const out = await dispatcher.dispatch( + methodCall('rssCloud.pleaseNotify', [ + '', + '80', + '/cb', + 'http-post', + 'http://feed.example/rss' + ]), + { clientAddress: '203.0.113.5' } + ); + + expect(isSuccess(await parseResponse(out))).toBe(false); + }); + + it('faults when there are too few params', async () => { + const dispatcher = createXmlRpcDispatcher({ core: fakeCore() }); + + const out = await dispatcher.dispatch( + methodCall('rssCloud.pleaseNotify', ['', '80', '/cb', 'http-post']), + { clientAddress: '203.0.113.5' } + ); + + expect(faultString(await parseResponse(out))).toBe( + 'Can\'t call "pleaseNotify" because there aren\'t enough parameters.' + ); + }); + + it('faults when there are too many params', async () => { + const dispatcher = createXmlRpcDispatcher({ core: fakeCore() }); + + const out = await dispatcher.dispatch( + methodCall('rssCloud.pleaseNotify', [ + '', + '80', + '/cb', + 'http-post', + 'http://feed.example/rss', + 'sub.example.com', + 'extra' + ]), + { clientAddress: '203.0.113.5' } + ); + + expect(faultString(await parseResponse(out))).toBe( + 'Can\'t call "pleaseNotify" because there are too many parameters.' + ); + }); + + it('faults on an unsupported protocol', async () => { + const dispatcher = createXmlRpcDispatcher({ core: fakeCore() }); + + const out = await dispatcher.dispatch( + methodCall('rssCloud.pleaseNotify', [ + '', + '80', + '/cb', + 'ftp', + 'http://feed.example/rss' + ]), + { clientAddress: '203.0.113.5' } + ); + + expect(faultString(await parseResponse(out))).toBe( + 'Can\'t accept the subscription because the protocol, ftp, is unsupported.' + ); + }); + + it('faults when subscribe throws an Error', async () => { + const core = fakeCore({ + async subscribe() { + throw new Error('subscribe boom'); + } + }); + const dispatcher = createXmlRpcDispatcher({ core }); + + const out = await dispatcher.dispatch( + methodCall('rssCloud.pleaseNotify', [ + '', + '80', + '/cb', + 'http-post', + 'http://feed.example/rss' + ]), + { clientAddress: '203.0.113.5' } + ); + + expect(faultString(await parseResponse(out))).toBe('subscribe boom'); + }); + + it('faults when subscribe throws a non-Error value', async () => { + const core = fakeCore({ + async subscribe() { + throw 'plain string failure'; + } + }); + const dispatcher = createXmlRpcDispatcher({ core }); + + const out = await dispatcher.dispatch( + methodCall('rssCloud.pleaseNotify', [ + '', + '80', + '/cb', + 'http-post', + 'http://feed.example/rss' + ]), + { clientAddress: '203.0.113.5' } + ); + + expect(faultString(await parseResponse(out))).toBe( + 'plain string failure' + ); + }); +}); + +describe('createXmlRpcDispatcher ping', () => { + it('maps the resource url and returns success', async () => { + const core = fakeCore(); + const dispatcher = createXmlRpcDispatcher({ core }); + + const out = await dispatcher.dispatch( + methodCall('rssCloud.ping', ['http://feed.example/rss']), + { clientAddress: '203.0.113.5' } + ); + + expect(isSuccess(await parseResponse(out))).toBe(true); + expect(core.pingCalls).toEqual([ + { resourceUrl: 'http://feed.example/rss' } + ]); + }); + + it('returns success even when core.ping throws', async () => { + const core = fakeCore({ + async ping() { + throw new Error('ping boom'); + } + }); + const dispatcher = createXmlRpcDispatcher({ core }); + + const out = await dispatcher.dispatch( + methodCall('rssCloud.ping', ['http://feed.example/rss']), + { clientAddress: '203.0.113.5' } + ); + + expect(isSuccess(await parseResponse(out))).toBe(true); + }); + + it('faults when there are too few params', async () => { + const dispatcher = createXmlRpcDispatcher({ core: fakeCore() }); + + const out = await dispatcher.dispatch(methodCall('rssCloud.ping', []), { + clientAddress: '203.0.113.5' + }); + + expect(faultString(await parseResponse(out))).toBe( + 'Can\'t call "ping" because there aren\'t enough parameters.' + ); + }); + + it('faults when there are too many params', async () => { + const dispatcher = createXmlRpcDispatcher({ core: fakeCore() }); + + const out = await dispatcher.dispatch( + methodCall('rssCloud.ping', [ + 'http://feed.example/rss', + 'http://extra.example/rss' + ]), + { clientAddress: '203.0.113.5' } + ); + + expect(faultString(await parseResponse(out))).toBe( + 'Can\'t call "ping" because there are too many parameters.' + ); + }); +}); diff --git a/packages/core/src/xml-rpc-dispatcher.ts b/packages/core/src/xml-rpc-dispatcher.ts new file mode 100644 index 0000000..33e94ee --- /dev/null +++ b/packages/core/src/xml-rpc-dispatcher.ts @@ -0,0 +1,209 @@ +import type { RssCloudCore } from './core.js'; +import type { PingRequest, SubscribeRequest } from './dto.js'; +import type { Protocol } from './protocol.js'; +import { + parseMethodCall, + serializeFault, + serializeSuccess +} from './xml-rpc-codec.js'; + +/** Per-request context the adapter resolves before handing core the raw XML. */ +export interface XmlRpcDispatchContext { + /** Caller address (already resolved from x-forwarded-for/remote address). */ + clientAddress: string; +} + +/** Construction-time dependencies for the XML-RPC dispatcher. */ +export interface XmlRpcDispatcherOptions { + core: Pick; +} + +/** Raw-XML-in, raw-XML-out rssCloud XML-RPC front door. */ +export interface XmlRpcDispatcher { + dispatch(xmlBody: string, ctx: XmlRpcDispatchContext): Promise; +} + +/** rssCloud faults are always faultCode 4. */ +const FAULT_CODE = 4; + +/** Protocols a subscriber may register under. */ +const VALID_PROTOCOLS = ['http-post', 'https-post', 'xml-rpc']; + +/** Assemble a callback URL from its parts the way the legacy `glueUrlParts` did. */ +function glueUrlParts( + scheme: string, + client: string, + port: string, + path: string +): string { + let host = client; + if (host.startsWith('::ffff:')) { + host = host.slice(7); + } + if (host.includes(':')) { + host = `[${host}]`; + } + + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${scheme}://${host}:${port}${normalizedPath}`; +} + +/** + * Map `pleaseNotify` positional params + * (`notifyProcedure, port, path, protocol, urlList[, domain]`) into a + * `SubscribeRequest`. Throws (→ fault) on bad arity or an unsupported protocol. + */ +function mapPleaseNotify( + params: unknown[], + clientAddress: string +): SubscribeRequest { + if (params.length < 5) { + throw new Error( + 'Can\'t call "pleaseNotify" because there aren\'t enough parameters.' + ); + } + if (params.length > 6) { + throw new Error( + 'Can\'t call "pleaseNotify" because there are too many parameters.' + ); + } + + const protocol = String(params[3]); + if (!VALID_PROTOCOLS.includes(protocol)) { + throw new Error( + `Can't accept the subscription because the protocol, ${protocol}, is unsupported.` + ); + } + + const port = String(params[1]); + const path = String(params[2]); + const urlList = params[4]; + const domain = params[5]; + + const resourceUrls = Array.isArray(urlList) + ? urlList.map((url) => String(url)) + : [String(urlList)]; + + let client: string; + let diffDomain: boolean; + if (domain === undefined) { + client = clientAddress; + diffDomain = false; + } else { + client = String(domain); + diffDomain = true; + } + + const scheme = + protocol === 'https-post' || port === '443' ? 'https' : 'http'; + + const request: SubscribeRequest = { + resourceUrls, + callbackUrl: glueUrlParts(scheme, client, port, path), + protocol: protocol as Protocol, + diffDomain + }; + + if (protocol === 'xml-rpc' && params[0]) { + request.notifyProcedure = String(params[0]); + } + + return request; +} + +/** Map the single `ping` param into a `PingRequest`. Throws (→ fault) on bad arity. */ +function mapPing(params: unknown[]): PingRequest { + if (params.length < 1) { + throw new Error( + 'Can\'t call "ping" because there aren\'t enough parameters.' + ); + } + if (params.length > 1) { + throw new Error( + 'Can\'t call "ping" because there are too many parameters.' + ); + } + + return { resourceUrl: String(params[0]) }; +} + +/** + * Build the rssCloud XML-RPC dispatcher. It owns the whole round trip — parse + * the `methodCall`, run the matching use case, and serialize the response — + * and never throws: malformed input and use-case errors both become faults. + */ +export function createXmlRpcDispatcher( + options: XmlRpcDispatcherOptions +): XmlRpcDispatcher { + const { core } = options; + + /** Map params, subscribe, and relay success; mapping/subscribe errors fault. */ + async function handlePleaseNotify( + params: unknown[], + clientAddress: string + ): Promise { + try { + const result = await core.subscribe( + mapPleaseNotify(params, clientAddress) + ); + return serializeSuccess(result.success); + } catch (err) { + return serializeFault(FAULT_CODE, errorMessage(err)); + } + } + + /** + * Map params and ping. Per Dave's rssCloud, the response is always success + * once the call is well-formed — even if the ping use case fails. Only a + * malformed call (bad arity) faults. + */ + async function handlePing(params: unknown[]): Promise { + let request: PingRequest; + try { + request = mapPing(params); + } catch (err) { + return serializeFault(FAULT_CODE, errorMessage(err)); + } + + try { + await core.ping(request); + } catch { + // Dave's rssCloud server always returns true whether it succeeded or not. + } + return serializeSuccess(true); + } + + async function dispatch( + xmlBody: string, + ctx: XmlRpcDispatchContext + ): Promise { + let methodName: string; + let params: unknown[]; + try { + ({ methodName, params } = await parseMethodCall(xmlBody)); + } catch (err) { + return serializeFault(FAULT_CODE, errorMessage(err)); + } + + switch (methodName) { + case 'rssCloud.hello': + return serializeSuccess(true); + case 'rssCloud.pleaseNotify': + return handlePleaseNotify(params, ctx.clientAddress); + case 'rssCloud.ping': + return handlePing(params); + default: + return serializeFault( + FAULT_CODE, + `Can't make the call because "${methodName}" is not defined.` + ); + } + } + + return { dispatch }; +} + +/** Extract a message from any thrown value. */ +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/packages/core/src/xml-rpc-plugin.test.ts b/packages/core/src/xml-rpc-plugin.test.ts new file mode 100644 index 0000000..7e61add --- /dev/null +++ b/packages/core/src/xml-rpc-plugin.test.ts @@ -0,0 +1,245 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { DeliveryContext, VerifyContext } from './plugin.js'; +import type { Resource } from './resource.js'; +import type { Subscription } from './subscription.js'; +import { parseMethodCall } from './xml-rpc-codec.js'; +import { createXmlRpcProtocolPlugin } from './xml-rpc-plugin.js'; + +const epoch = new Date(0); + +function subscription(url: string, notifyProcedure?: string): Subscription { + const sub: Subscription = { + url, + protocol: 'xml-rpc', + ctUpdates: 0, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: epoch, + whenLastUpdate: null, + whenLastError: null, + whenExpires: new Date('2099-01-01T00:00:00Z') + }; + if (notifyProcedure !== undefined) { + sub.notifyProcedure = notifyProcedure; + } + return sub; +} + +function resource(url: string): Resource { + return { + url, + lastHash: '', + lastSize: 0, + ctChecks: 0, + whenLastCheck: epoch, + ctUpdates: 0, + whenLastUpdate: epoch + }; +} + +function deliveryContext( + callbackUrl: string, + resourceUrl: string, + notifyProcedure?: string +): DeliveryContext { + return { + subscription: subscription(callbackUrl, notifyProcedure), + resource: resource(resourceUrl), + payload: { body: '', contentType: null } + }; +} + +function verifyContext( + callbackUrl: string, + resourceUrl: string, + diffDomain: boolean, + notifyProcedure?: string +): VerifyContext { + return { + subscription: subscription(callbackUrl, notifyProcedure), + resourceUrl, + diffDomain + }; +} + +describe('createXmlRpcProtocolPlugin deliver', () => { + it('POSTs a text/xml methodCall and reports ok on 2xx', async () => { + const calls: { url: string; init: RequestInit }[] = []; + const fakeFetch = (async (url: string | URL, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }); + return new Response('', { status: 200 }); + }) as typeof fetch; + + const plugin = createXmlRpcProtocolPlugin({ fetch: fakeFetch }); + + const result = await plugin.deliver( + deliveryContext( + 'https://subscriber.example/RPC2', + 'https://feed.example/rss', + 'myCloud.notify' + ) + ); + + expect(result).toEqual({ ok: true }); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe('https://subscriber.example/RPC2'); + expect(calls[0]?.init.method).toBe('POST'); + const headers = calls[0]?.init.headers as Record; + expect(headers['Content-Type']).toBe('text/xml'); + + const call = await parseMethodCall(calls[0]?.init.body as string); + expect(call.methodName).toBe('myCloud.notify'); + expect(call.params).toEqual(['https://feed.example/rss']); + }); + + it('sends an empty methodName when the subscription has no notifyProcedure', async () => { + let body = ''; + const fakeFetch = (async (_url: string | URL, init?: RequestInit) => { + body = init?.body as string; + return new Response('', { status: 200 }); + }) as typeof fetch; + + const plugin = createXmlRpcProtocolPlugin({ fetch: fakeFetch }); + + await plugin.deliver( + deliveryContext( + 'https://subscriber.example/RPC2', + 'https://feed.example/rss' + ) + ); + + const call = await parseMethodCall(body); + expect(call.methodName).toBe(''); + expect(call.params).toEqual(['https://feed.example/rss']); + }); + + it('reports failure when the callback responds non-2xx', async () => { + const fakeFetch = (async () => + new Response('nope', { status: 500 })) as typeof fetch; + + const plugin = createXmlRpcProtocolPlugin({ fetch: fakeFetch }); + + const result = await plugin.deliver( + deliveryContext( + 'https://subscriber.example/RPC2', + 'https://feed.example/rss', + 'myCloud.notify' + ) + ); + + expect(result.ok).toBe(false); + expect(result.error).toBeInstanceOf(Error); + }); + + it('reports failure when the request throws', async () => { + const fakeFetch = (async () => { + throw new Error('boom'); + }) as typeof fetch; + + const plugin = createXmlRpcProtocolPlugin({ fetch: fakeFetch }); + + const result = await plugin.deliver( + deliveryContext( + 'https://subscriber.example/RPC2', + 'https://feed.example/rss', + 'myCloud.notify' + ) + ); + + expect(result.ok).toBe(false); + expect(result.error).toBeInstanceOf(Error); + }); + + it('aborts and fails when the callback exceeds the timeout', async () => { + vi.useFakeTimers(); + let abortedWith: unknown; + const fakeFetch = ((_url: string | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + abortedWith = init.signal?.reason; + reject( + Object.assign(new Error('aborted'), { + name: 'AbortError' + }) + ); + }); + })) as typeof fetch; + + const plugin = createXmlRpcProtocolPlugin({ + fetch: fakeFetch, + requestTimeoutMs: 50 + }); + + const promise = plugin.deliver( + deliveryContext( + 'https://subscriber.example/RPC2', + 'https://feed.example/rss', + 'myCloud.notify' + ) + ); + + await vi.advanceTimersByTimeAsync(50); + const result = await promise; + + expect(result.ok).toBe(false); + expect(result.error).toBeInstanceOf(Error); + expect(abortedWith).toBeDefined(); + }); +}); + +describe('createXmlRpcProtocolPlugin verify', () => { + it('confirms with a plain test notify, ignoring diffDomain', async () => { + const calls: { url: string; init: RequestInit }[] = []; + const fakeFetch = (async (url: string | URL, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }); + return new Response('', { status: 200 }); + }) as typeof fetch; + + const plugin = createXmlRpcProtocolPlugin({ fetch: fakeFetch }); + + await expect( + plugin.verify( + verifyContext( + 'https://subscriber.example/RPC2', + 'https://feed.example/rss', + true, + 'myCloud.notify' + ) + ) + ).resolves.toBeUndefined(); + + expect(calls).toHaveLength(1); + expect(calls[0]?.init.method).toBe('POST'); + const call = await parseMethodCall(calls[0]?.init.body as string); + expect(call.methodName).toBe('myCloud.notify'); + expect(call.params).toEqual(['https://feed.example/rss']); + }); + + it('rejects when the test notify fails', async () => { + const fakeFetch = (async () => + new Response('', { status: 500 })) as typeof fetch; + + const plugin = createXmlRpcProtocolPlugin({ fetch: fakeFetch }); + + await expect( + plugin.verify( + verifyContext( + 'https://subscriber.example/RPC2', + 'https://feed.example/rss', + false + ) + ) + ).rejects.toThrow(); + }); +}); + +describe('createXmlRpcProtocolPlugin protocols', () => { + it('owns the xml-rpc protocol value', () => { + const plugin = createXmlRpcProtocolPlugin(); + expect(plugin.protocols).toEqual(['xml-rpc']); + }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); diff --git a/packages/core/src/xml-rpc-plugin.ts b/packages/core/src/xml-rpc-plugin.ts new file mode 100644 index 0000000..d84a668 --- /dev/null +++ b/packages/core/src/xml-rpc-plugin.ts @@ -0,0 +1,90 @@ +import type { + DeliveryContext, + DeliveryResult, + ProtocolPlugin, + VerifyContext +} from './plugin.js'; +import type { Protocol } from './protocol.js'; +import { buildNotifyCall } from './xml-rpc-codec.js'; + +/** Construction-time dependencies for the rssCloud XML-RPC protocol plugin. */ +export interface XmlRpcProtocolPluginOptions { + /** Injectable fetch (tests, edge runtimes); defaults to global fetch. */ + fetch?: typeof fetch; + /** Per-request timeout (ms) for outbound calls. */ + requestTimeoutMs?: number; +} + +const XML_RPC_PROTOCOLS: Protocol[] = ['xml-rpc']; + +/** Fallback request timeout when none is supplied (mirrors the server default). */ +const DEFAULT_REQUEST_TIMEOUT_MS = 4000; + +/** + * The rssCloud XML-RPC delivery protocol. Subscribers are notified with a + * `methodCall` POST to their `notifyProcedure`. As with Dave's original + * rssCloud, verification is a plain test notify — there is no cross-domain + * challenge handshake, so `diffDomain` is ignored. + */ +export function createXmlRpcProtocolPlugin( + options: XmlRpcProtocolPluginOptions = {} +): ProtocolPlugin { + const doFetch = options.fetch ?? fetch; + const requestTimeoutMs = + options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + + /** Fetch with the configured timeout enforced via an abort signal. */ + async function fetchWithTimeout( + url: string, + init: RequestInit + ): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), requestTimeoutMs); + + try { + return await doFetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } + } + + /** POST the notify methodCall; throws on timeout or non-2xx. */ + async function sendNotify( + targetUrl: string, + procedure: string, + resourceUrl: string + ): Promise { + const res = await fetchWithTimeout(targetUrl, { + method: 'POST', + headers: { 'Content-Type': 'text/xml' }, + body: buildNotifyCall(procedure, resourceUrl) + }); + + if (!res.ok) { + throw new Error('Notification Failed'); + } + } + + async function deliver(ctx: DeliveryContext): Promise { + try { + await sendNotify( + ctx.subscription.url, + ctx.subscription.notifyProcedure ?? '', + ctx.resource.url + ); + return { ok: true }; + } catch (err) { + return { ok: false, error: err as Error }; + } + } + + async function verify(ctx: VerifyContext): Promise { + await sendNotify( + ctx.subscription.url, + ctx.subscription.notifyProcedure ?? '', + ctx.resourceUrl + ); + } + + return { protocols: XML_RPC_PROTOCOLS, verify, deliver }; +} From ae5acbf29ae071fa0ed1e27d5b86b7e1dbcfe991 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Tue, 9 Jun 2026 19:36:27 -0500 Subject: [PATCH 20/90] refactor(core): organize src into domain-grouped folders Move flat src/ files into engine/, feed/, store/, and protocols/ folders, co-locating each interface contract with its implementation. Pure file moves plus relative-import rewiring; no behavior change and the public index barrel is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/{ => engine}/core.ts | 8 ++--- .../core/src/{ => engine}/create-core.test.ts | 10 +++---- packages/core/src/{ => engine}/create-core.ts | 6 ++-- packages/core/src/{ => engine}/dto.ts | 0 packages/core/src/{ => engine}/plugin.ts | 0 packages/core/src/{ => engine}/protocol.ts | 0 packages/core/src/{ => engine}/resource.ts | 2 +- packages/core/src/{ => engine}/stats.ts | 0 .../core/src/{ => engine}/subscription.ts | 0 packages/core/src/events.ts | 2 +- .../core/src/{ => feed}/feed-parser.test.ts | 0 packages/core/src/{ => feed}/feed-parser.ts | 2 +- packages/core/src/{ => feed}/feed.ts | 0 packages/core/src/index.ts | 30 +++++++++---------- .../src/{ => protocols}/rest-plugin.test.ts | 6 ++-- .../core/src/{ => protocols}/rest-plugin.ts | 4 +-- .../src/{ => protocols}/xml-rpc-codec.test.ts | 0 .../core/src/{ => protocols}/xml-rpc-codec.ts | 0 .../xml-rpc-dispatcher.test.ts | 2 +- .../src/{ => protocols}/xml-rpc-dispatcher.ts | 6 ++-- .../{ => protocols}/xml-rpc-plugin.test.ts | 6 ++-- .../src/{ => protocols}/xml-rpc-plugin.ts | 4 +-- .../core/src/{ => store}/memory-store.test.ts | 4 +-- packages/core/src/{ => store}/memory-store.ts | 4 +-- packages/core/src/{ => store}/store.ts | 4 +-- 25 files changed, 50 insertions(+), 50 deletions(-) rename packages/core/src/{ => engine}/core.ts (92%) rename packages/core/src/{ => engine}/create-core.test.ts (99%) rename packages/core/src/{ => engine}/create-core.ts (99%) rename packages/core/src/{ => engine}/dto.ts (100%) rename packages/core/src/{ => engine}/plugin.ts (100%) rename packages/core/src/{ => engine}/protocol.ts (100%) rename packages/core/src/{ => engine}/resource.ts (94%) rename packages/core/src/{ => engine}/stats.ts (100%) rename packages/core/src/{ => engine}/subscription.ts (100%) rename packages/core/src/{ => feed}/feed-parser.test.ts (100%) rename packages/core/src/{ => feed}/feed-parser.ts (99%) rename packages/core/src/{ => feed}/feed.ts (100%) rename packages/core/src/{ => protocols}/rest-plugin.test.ts (98%) rename packages/core/src/{ => protocols}/rest-plugin.ts (98%) rename packages/core/src/{ => protocols}/xml-rpc-codec.test.ts (100%) rename packages/core/src/{ => protocols}/xml-rpc-codec.ts (100%) rename packages/core/src/{ => protocols}/xml-rpc-dispatcher.test.ts (99%) rename packages/core/src/{ => protocols}/xml-rpc-dispatcher.ts (97%) rename packages/core/src/{ => protocols}/xml-rpc-plugin.test.ts (97%) rename packages/core/src/{ => protocols}/xml-rpc-plugin.ts (97%) rename packages/core/src/{ => store}/memory-store.test.ts (97%) rename packages/core/src/{ => store}/memory-store.ts (93%) rename packages/core/src/{ => store}/store.ts (89%) diff --git a/packages/core/src/core.ts b/packages/core/src/engine/core.ts similarity index 92% rename from packages/core/src/core.ts rename to packages/core/src/engine/core.ts index 0b11759..d2c0f62 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/engine/core.ts @@ -1,4 +1,4 @@ -import type { RssCloudConfig } from './config.js'; +import type { RssCloudConfig } from '../config.js'; import type { PingRequest, PingResponse, @@ -7,11 +7,11 @@ import type { UnsubscribeRequest, UnsubscribeResponse } from './dto.js'; -import type { EventBus } from './events.js'; -import type { FeedParser } from './feed.js'; +import type { EventBus } from '../events.js'; +import type { FeedParser } from '../feed/feed.js'; import type { ProtocolPlugin } from './plugin.js'; import type { MaintenanceResult, Stats } from './stats.js'; -import type { Store } from './store.js'; +import type { Store } from '../store/store.js'; /** * Everything core needs, assembled by the host's composition root. The shared diff --git a/packages/core/src/create-core.test.ts b/packages/core/src/engine/create-core.test.ts similarity index 99% rename from packages/core/src/create-core.test.ts rename to packages/core/src/engine/create-core.test.ts index 8674a28..4f59473 100644 --- a/packages/core/src/create-core.test.ts +++ b/packages/core/src/engine/create-core.test.ts @@ -1,12 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createRssCloudCore } from './create-core.js'; -import { resolveConfig } from './config.js'; -import { createEventBus } from './events.js'; -import type { RssCloudEventMap } from './events.js'; -import { createInMemoryStore } from './memory-store.js'; +import { resolveConfig } from '../config.js'; +import { createEventBus } from '../events.js'; +import type { RssCloudEventMap } from '../events.js'; +import { createInMemoryStore } from '../store/memory-store.js'; import type { ProtocolPlugin } from './plugin.js'; import type { Resource } from './resource.js'; -import type { Store } from './store.js'; +import type { Store } from '../store/store.js'; import type { Subscription } from './subscription.js'; const FEED = 'https://feed.example/rss'; diff --git a/packages/core/src/create-core.ts b/packages/core/src/engine/create-core.ts similarity index 99% rename from packages/core/src/create-core.ts rename to packages/core/src/engine/create-core.ts index d9276b7..34bca03 100644 --- a/packages/core/src/create-core.ts +++ b/packages/core/src/engine/create-core.ts @@ -8,9 +8,9 @@ import type { UnsubscribeRequest, UnsubscribeResponse } from './dto.js'; -import { RssCloudError } from './errors.js'; -import { createEventBus } from './events.js'; -import { createDefaultFeedParser } from './feed-parser.js'; +import { RssCloudError } from '../errors.js'; +import { createEventBus } from '../events.js'; +import { createDefaultFeedParser } from '../feed/feed-parser.js'; import type { ResourcePayload, ProtocolPlugin } from './plugin.js'; import type { Protocol } from './protocol.js'; import type { Resource } from './resource.js'; diff --git a/packages/core/src/dto.ts b/packages/core/src/engine/dto.ts similarity index 100% rename from packages/core/src/dto.ts rename to packages/core/src/engine/dto.ts diff --git a/packages/core/src/plugin.ts b/packages/core/src/engine/plugin.ts similarity index 100% rename from packages/core/src/plugin.ts rename to packages/core/src/engine/plugin.ts diff --git a/packages/core/src/protocol.ts b/packages/core/src/engine/protocol.ts similarity index 100% rename from packages/core/src/protocol.ts rename to packages/core/src/engine/protocol.ts diff --git a/packages/core/src/resource.ts b/packages/core/src/engine/resource.ts similarity index 94% rename from packages/core/src/resource.ts rename to packages/core/src/engine/resource.ts index b27bd4f..5718ecb 100644 --- a/packages/core/src/resource.ts +++ b/packages/core/src/engine/resource.ts @@ -1,4 +1,4 @@ -import type { FeedMetadata } from './feed.js'; +import type { FeedMetadata } from '../feed/feed.js'; /** * A feed the server tracks. Holds change-detection state plus the most recently diff --git a/packages/core/src/stats.ts b/packages/core/src/engine/stats.ts similarity index 100% rename from packages/core/src/stats.ts rename to packages/core/src/engine/stats.ts diff --git a/packages/core/src/subscription.ts b/packages/core/src/engine/subscription.ts similarity index 100% rename from packages/core/src/subscription.ts rename to packages/core/src/engine/subscription.ts diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index d56ab7d..846dd6e 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -1,4 +1,4 @@ -import type { Protocol } from './protocol.js'; +import type { Protocol } from './engine/protocol.js'; /** * Payloads for the observability bus. Core emits these as side effects of its diff --git a/packages/core/src/feed-parser.test.ts b/packages/core/src/feed/feed-parser.test.ts similarity index 100% rename from packages/core/src/feed-parser.test.ts rename to packages/core/src/feed/feed-parser.test.ts diff --git a/packages/core/src/feed-parser.ts b/packages/core/src/feed/feed-parser.ts similarity index 99% rename from packages/core/src/feed-parser.ts rename to packages/core/src/feed/feed-parser.ts index b1e8793..ac2b2e6 100644 --- a/packages/core/src/feed-parser.ts +++ b/packages/core/src/feed/feed-parser.ts @@ -1,5 +1,5 @@ import { Parser } from 'xml2js'; -import { DEFAULT_CONFIG } from './config.js'; +import { DEFAULT_CONFIG } from '../config.js'; import type { FeedMetadata, FeedParser } from './feed.js'; /** Construction-time options for the built-in feed parser. */ diff --git a/packages/core/src/feed.ts b/packages/core/src/feed/feed.ts similarity index 100% rename from packages/core/src/feed.ts rename to packages/core/src/feed/feed.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 58318ba..dc104b0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,36 +1,36 @@ export const version = '0.0.0'; // Implementations -export { createRssCloudCore } from './create-core.js'; +export { createRssCloudCore } from './engine/create-core.js'; export { DEFAULT_CONFIG, resolveConfig } from './config.js'; export { createEventBus } from './events.js'; export { RssCloudError } from './errors.js'; export { createRestProtocolPlugin, type RestProtocolPluginOptions -} from './rest-plugin.js'; +} from './protocols/rest-plugin.js'; export { createXmlRpcProtocolPlugin, type XmlRpcProtocolPluginOptions -} from './xml-rpc-plugin.js'; +} from './protocols/xml-rpc-plugin.js'; export { createXmlRpcDispatcher, type XmlRpcDispatcher, type XmlRpcDispatcherOptions, type XmlRpcDispatchContext -} from './xml-rpc-dispatcher.js'; +} from './protocols/xml-rpc-dispatcher.js'; export { createDefaultFeedParser, type DefaultFeedParserOptions -} from './feed-parser.js'; -export { createInMemoryStore } from './memory-store.js'; +} from './feed/feed-parser.js'; +export { createInMemoryStore } from './store/memory-store.js'; // Contracts -export type { BuiltInProtocol, Protocol } from './protocol.js'; -export type { FeedMetadata, FeedParser } from './feed.js'; -export type { Resource } from './resource.js'; -export type { Subscription } from './subscription.js'; -export type { FeedEntry, Store } from './store.js'; +export type { BuiltInProtocol, Protocol } from './engine/protocol.js'; +export type { FeedMetadata, FeedParser } from './feed/feed.js'; +export type { Resource } from './engine/resource.js'; +export type { Subscription } from './engine/subscription.js'; +export type { FeedEntry, Store } from './store/store.js'; export type { SubscribeRequest, SubscribeResult, @@ -39,20 +39,20 @@ export type { UnsubscribeResponse, PingRequest, PingResponse -} from './dto.js'; +} from './engine/dto.js'; export type { ResourcePayload, DeliveryResult, VerifyContext, DeliveryContext, ProtocolPlugin -} from './plugin.js'; +} from './engine/plugin.js'; export type { RssCloudEventMap, EventBus, CreateEventBus } from './events.js'; export type { RssCloudConfig, ResolveConfig } from './config.js'; export type { RssCloudErrorCode } from './errors.js'; -export type { FeedStat, Stats, MaintenanceResult } from './stats.js'; +export type { FeedStat, Stats, MaintenanceResult } from './engine/stats.js'; export type { RssCloudCoreOptions, RssCloudCore, CreateRssCloudCore -} from './core.js'; +} from './engine/core.js'; diff --git a/packages/core/src/rest-plugin.test.ts b/packages/core/src/protocols/rest-plugin.test.ts similarity index 98% rename from packages/core/src/rest-plugin.test.ts rename to packages/core/src/protocols/rest-plugin.test.ts index 59340a7..1b23e89 100644 --- a/packages/core/src/rest-plugin.test.ts +++ b/packages/core/src/protocols/rest-plugin.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { DeliveryContext, VerifyContext } from './plugin.js'; -import type { Resource } from './resource.js'; -import type { Subscription } from './subscription.js'; +import type { DeliveryContext, VerifyContext } from '../engine/plugin.js'; +import type { Resource } from '../engine/resource.js'; +import type { Subscription } from '../engine/subscription.js'; import { createRestProtocolPlugin } from './rest-plugin.js'; const epoch = new Date(0); diff --git a/packages/core/src/rest-plugin.ts b/packages/core/src/protocols/rest-plugin.ts similarity index 98% rename from packages/core/src/rest-plugin.ts rename to packages/core/src/protocols/rest-plugin.ts index b5f5655..ea20666 100644 --- a/packages/core/src/rest-plugin.ts +++ b/packages/core/src/protocols/rest-plugin.ts @@ -3,8 +3,8 @@ import type { DeliveryResult, ProtocolPlugin, VerifyContext -} from './plugin.js'; -import type { Protocol } from './protocol.js'; +} from '../engine/plugin.js'; +import type { Protocol } from '../engine/protocol.js'; /** Construction-time dependencies for the rssCloud REST protocol plugin. */ export interface RestProtocolPluginOptions { diff --git a/packages/core/src/xml-rpc-codec.test.ts b/packages/core/src/protocols/xml-rpc-codec.test.ts similarity index 100% rename from packages/core/src/xml-rpc-codec.test.ts rename to packages/core/src/protocols/xml-rpc-codec.test.ts diff --git a/packages/core/src/xml-rpc-codec.ts b/packages/core/src/protocols/xml-rpc-codec.ts similarity index 100% rename from packages/core/src/xml-rpc-codec.ts rename to packages/core/src/protocols/xml-rpc-codec.ts diff --git a/packages/core/src/xml-rpc-dispatcher.test.ts b/packages/core/src/protocols/xml-rpc-dispatcher.test.ts similarity index 99% rename from packages/core/src/xml-rpc-dispatcher.test.ts rename to packages/core/src/protocols/xml-rpc-dispatcher.test.ts index 1af72f9..10428da 100644 --- a/packages/core/src/xml-rpc-dispatcher.test.ts +++ b/packages/core/src/protocols/xml-rpc-dispatcher.test.ts @@ -5,7 +5,7 @@ import type { PingResponse, SubscribeRequest, SubscribeResponse -} from './dto.js'; +} from '../engine/dto.js'; import { createXmlRpcDispatcher } from './xml-rpc-dispatcher.js'; interface FakeCore { diff --git a/packages/core/src/xml-rpc-dispatcher.ts b/packages/core/src/protocols/xml-rpc-dispatcher.ts similarity index 97% rename from packages/core/src/xml-rpc-dispatcher.ts rename to packages/core/src/protocols/xml-rpc-dispatcher.ts index 33e94ee..4b50fc0 100644 --- a/packages/core/src/xml-rpc-dispatcher.ts +++ b/packages/core/src/protocols/xml-rpc-dispatcher.ts @@ -1,6 +1,6 @@ -import type { RssCloudCore } from './core.js'; -import type { PingRequest, SubscribeRequest } from './dto.js'; -import type { Protocol } from './protocol.js'; +import type { RssCloudCore } from '../engine/core.js'; +import type { PingRequest, SubscribeRequest } from '../engine/dto.js'; +import type { Protocol } from '../engine/protocol.js'; import { parseMethodCall, serializeFault, diff --git a/packages/core/src/xml-rpc-plugin.test.ts b/packages/core/src/protocols/xml-rpc-plugin.test.ts similarity index 97% rename from packages/core/src/xml-rpc-plugin.test.ts rename to packages/core/src/protocols/xml-rpc-plugin.test.ts index 7e61add..f9886b4 100644 --- a/packages/core/src/xml-rpc-plugin.test.ts +++ b/packages/core/src/protocols/xml-rpc-plugin.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { DeliveryContext, VerifyContext } from './plugin.js'; -import type { Resource } from './resource.js'; -import type { Subscription } from './subscription.js'; +import type { DeliveryContext, VerifyContext } from '../engine/plugin.js'; +import type { Resource } from '../engine/resource.js'; +import type { Subscription } from '../engine/subscription.js'; import { parseMethodCall } from './xml-rpc-codec.js'; import { createXmlRpcProtocolPlugin } from './xml-rpc-plugin.js'; diff --git a/packages/core/src/xml-rpc-plugin.ts b/packages/core/src/protocols/xml-rpc-plugin.ts similarity index 97% rename from packages/core/src/xml-rpc-plugin.ts rename to packages/core/src/protocols/xml-rpc-plugin.ts index d84a668..d18891c 100644 --- a/packages/core/src/xml-rpc-plugin.ts +++ b/packages/core/src/protocols/xml-rpc-plugin.ts @@ -3,8 +3,8 @@ import type { DeliveryResult, ProtocolPlugin, VerifyContext -} from './plugin.js'; -import type { Protocol } from './protocol.js'; +} from '../engine/plugin.js'; +import type { Protocol } from '../engine/protocol.js'; import { buildNotifyCall } from './xml-rpc-codec.js'; /** Construction-time dependencies for the rssCloud XML-RPC protocol plugin. */ diff --git a/packages/core/src/memory-store.test.ts b/packages/core/src/store/memory-store.test.ts similarity index 97% rename from packages/core/src/memory-store.test.ts rename to packages/core/src/store/memory-store.test.ts index 56a4c62..7a4fac6 100644 --- a/packages/core/src/memory-store.test.ts +++ b/packages/core/src/store/memory-store.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createInMemoryStore } from './memory-store.js'; -import type { Resource } from './resource.js'; -import type { Subscription } from './subscription.js'; +import type { Resource } from '../engine/resource.js'; +import type { Subscription } from '../engine/subscription.js'; function resource(url: string): Resource { return { diff --git a/packages/core/src/memory-store.ts b/packages/core/src/store/memory-store.ts similarity index 93% rename from packages/core/src/memory-store.ts rename to packages/core/src/store/memory-store.ts index 2b35a4d..646c63d 100644 --- a/packages/core/src/memory-store.ts +++ b/packages/core/src/store/memory-store.ts @@ -1,6 +1,6 @@ -import type { Resource } from './resource.js'; +import type { Resource } from '../engine/resource.js'; import type { FeedEntry, Store } from './store.js'; -import type { Subscription } from './subscription.js'; +import type { Subscription } from '../engine/subscription.js'; interface Entry { resource: Resource | null; diff --git a/packages/core/src/store.ts b/packages/core/src/store/store.ts similarity index 89% rename from packages/core/src/store.ts rename to packages/core/src/store/store.ts index 0cabb5a..d243316 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store/store.ts @@ -1,5 +1,5 @@ -import type { Resource } from './resource.js'; -import type { Subscription } from './subscription.js'; +import type { Resource } from '../engine/resource.js'; +import type { Subscription } from '../engine/subscription.js'; /** One feed's complete record: its resource state and its subscribers. */ export interface FeedEntry { From bb427cc12ac4da525f022e87f0d6daa53f2f9a46 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Tue, 9 Jun 2026 19:53:26 -0500 Subject: [PATCH 21/90] docs: require tdd skill and 100% package coverage in CLAUDE.md Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index a8b70f7..2dd4195 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co An [rssCloud](http://rsscloud.org/) notification protocol server. Subscribers register a callback URL via `/pleaseNotify`; publishers `/ping` when feeds update; the server fans notifications out to subscribers. Implementation lives in `apps/server/`. +## Development workflow + +Build features and fix bugs with the **`tdd` skill** — strict red-green-refactor, one vertical slice at a time. Write a failing test, make it pass, refactor; don't batch all the tests and all the code together. + +Packages under `packages/` (e.g. `@rsscloud/core`) are held to **100% code coverage**. Keep them there — every branch and line exercised by a test. If something genuinely can't or shouldn't be covered, justify it with an explicit ignore rather than letting coverage slip. + ## Data storage State (resources and subscriptions) is held in memory and persisted atomically to a JSON file (default `./data/subscriptions.json`, configurable via `DATA_FILE_PATH`). The flush happens on an interval, at shutdown, and on unexpected exit. There is no external database. From af68cdc6194393c9a6474fe3e74c1ab0000f260f Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Tue, 9 Jun 2026 19:55:09 -0500 Subject: [PATCH 22/90] chore: ignore *.local.* files Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 999d196..0a1506f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ xunit/ .turbo/ Procfile tunnel.sh +*.local.* \ No newline at end of file From 26b14a556156e1d097369f2ba2396ce982aada5d Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Tue, 9 Jun 2026 20:21:08 -0500 Subject: [PATCH 23/90] feat(core): add REST front door dispatcher Add createRestDispatcher mirroring the XML-RPC dispatcher: maps a parsed REST body into SubscribeRequest/PingRequest (parseUrlList, missing-param errors, glueUrlParts, protocol validation), drives core.subscribe/ping, and renders xml/json/406 responses relaying core's messages. REST ping reports failures as success:false (unlike XML-RPC); negotiation happens at render time so a 406 still performs the use case. 100% covered. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/index.ts | 8 + .../src/protocols/rest-dispatcher.test.ts | 405 ++++++++++++++++++ .../core/src/protocols/rest-dispatcher.ts | 256 +++++++++++ 3 files changed, 669 insertions(+) create mode 100644 packages/core/src/protocols/rest-dispatcher.test.ts create mode 100644 packages/core/src/protocols/rest-dispatcher.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dc104b0..00571a5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,6 +19,14 @@ export { type XmlRpcDispatcherOptions, type XmlRpcDispatchContext } from './protocols/xml-rpc-dispatcher.js'; +export { + createRestDispatcher, + type RestDispatcher, + type RestDispatcherOptions, + type RestDispatchContext, + type RestResponse, + type RestResponseFormat +} from './protocols/rest-dispatcher.js'; export { createDefaultFeedParser, type DefaultFeedParserOptions diff --git a/packages/core/src/protocols/rest-dispatcher.test.ts b/packages/core/src/protocols/rest-dispatcher.test.ts new file mode 100644 index 0000000..c0cc0cc --- /dev/null +++ b/packages/core/src/protocols/rest-dispatcher.test.ts @@ -0,0 +1,405 @@ +import { Parser } from 'xml2js'; +import { describe, expect, it } from 'vitest'; +import type { + PingRequest, + PingResponse, + SubscribeRequest, + SubscribeResponse +} from '../engine/dto.js'; +import { createRestDispatcher } from './rest-dispatcher.js'; + +interface FakeCore { + subscribe(req: SubscribeRequest): Promise; + ping(req: PingRequest): Promise; + subscribeCalls: SubscribeRequest[]; + pingCalls: PingRequest[]; +} + +function fakeCore(overrides: Partial = {}): FakeCore { + const core: FakeCore = { + subscribeCalls: [], + pingCalls: [], + async subscribe(req) { + core.subscribeCalls.push(req); + return { success: true, message: 'Subscription confirmed.' }; + }, + async ping(req) { + core.pingCalls.push(req); + return { success: true, message: 'Thanks for the ping.' }; + }, + ...overrides + }; + return core; +} + +describe('createRestDispatcher ping', () => { + it('maps the url and renders a JSON success envelope', async () => { + const core = fakeCore(); + const dispatcher = createRestDispatcher({ core }); + + const res = await dispatcher.ping( + { url: 'http://feed.example/rss' }, + { clientAddress: '203.0.113.5', format: 'json' } + ); + + expect(res.status).toBe(200); + expect(res.contentType).toBe('application/json'); + expect(JSON.parse(res.body)).toEqual({ + success: true, + msg: 'Thanks for the ping.' + }); + expect(core.pingCalls).toEqual([ + { resourceUrl: 'http://feed.example/rss' } + ]); + }); + + it('renders an XML success envelope under a element', async () => { + const dispatcher = createRestDispatcher({ core: fakeCore() }); + + const res = await dispatcher.ping( + { url: 'http://feed.example/rss' }, + { clientAddress: '203.0.113.5', format: 'xml' } + ); + + expect(res.status).toBe(200); + expect(res.contentType).toBe('text/xml'); + const parsed = await new Parser().parseStringPromise(res.body); + expect(parsed.result.$).toEqual({ + success: 'true', + msg: 'Thanks for the ping.' + }); + }); + + it('returns 406 when no format could be negotiated', async () => { + const core = fakeCore(); + const dispatcher = createRestDispatcher({ core }); + + const res = await dispatcher.ping( + { url: 'http://feed.example/rss' }, + { clientAddress: '203.0.113.5', format: null } + ); + + expect(res.status).toBe(406); + expect(res.contentType).toBe('text/plain'); + expect(res.body).toBe('Not Acceptable'); + // The use case still runs — negotiation happens at render time, as the + // server controllers do (run, then format). Only the reply is declined. + expect(core.pingCalls).toHaveLength(1); + }); + + it('renders success:false on a missing url without calling core', async () => { + const core = fakeCore(); + const dispatcher = createRestDispatcher({ core }); + + const res = await dispatcher.ping( + {}, + { clientAddress: '203.0.113.5', format: 'json' } + ); + + expect(JSON.parse(res.body)).toEqual({ + success: false, + msg: 'The following parameters were missing from the request body: url.' + }); + expect(core.pingCalls).toHaveLength(0); + }); + + it('relays a core ping failure as success:false (REST surfaces failures)', async () => { + const core = fakeCore({ + async ping() { + throw new Error( + 'The resource at http://feed.example/rss could not be read.' + ); + } + }); + const dispatcher = createRestDispatcher({ core }); + + const res = await dispatcher.ping( + { url: 'http://feed.example/rss' }, + { clientAddress: '203.0.113.5', format: 'json' } + ); + + expect(JSON.parse(res.body)).toEqual({ + success: false, + msg: 'The resource at http://feed.example/rss could not be read.' + }); + }); + + it('renders a failure as XML with success="false"', async () => { + const dispatcher = createRestDispatcher({ core: fakeCore() }); + + const res = await dispatcher.ping( + {}, + { clientAddress: '203.0.113.5', format: 'xml' } + ); + + expect(res.contentType).toBe('text/xml'); + const parsed = await new Parser().parseStringPromise(res.body); + expect(parsed.result.$).toEqual({ + success: 'false', + msg: 'The following parameters were missing from the request body: url.' + }); + }); +}); + +describe('createRestDispatcher pleaseNotify', () => { + it('maps an explicit-domain subscription and renders JSON success', async () => { + const core = fakeCore(); + const dispatcher = createRestDispatcher({ core }); + + const res = await dispatcher.pleaseNotify( + { + domain: 'sub.example.com', + port: '5337', + path: '/feedupdated', + protocol: 'http-post', + url1: 'http://feed.example/rss' + }, + { clientAddress: '203.0.113.5', format: 'json' } + ); + + expect(res.status).toBe(200); + expect(res.contentType).toBe('application/json'); + expect(JSON.parse(res.body)).toEqual({ + success: true, + msg: 'Subscription confirmed.' + }); + expect(core.subscribeCalls).toEqual([ + { + resourceUrls: ['http://feed.example/rss'], + callbackUrl: 'http://sub.example.com:5337/feedupdated', + protocol: 'http-post', + diffDomain: true + } + ]); + }); + + it('maps an xml-rpc subscription and renders XML under ', async () => { + const core = fakeCore(); + const dispatcher = createRestDispatcher({ core }); + + const res = await dispatcher.pleaseNotify( + { + domain: 'sub.example.com', + port: '5337', + path: '/RPC2', + protocol: 'xml-rpc', + notifyProcedure: 'river.feedUpdated', + url1: 'http://feed.example/rss' + }, + { clientAddress: '203.0.113.5', format: 'xml' } + ); + + expect(res.contentType).toBe('text/xml'); + const parsed = await new Parser().parseStringPromise(res.body); + expect(parsed.notifyResult.$).toEqual({ + success: 'true', + msg: 'Subscription confirmed.' + }); + expect(core.subscribeCalls).toEqual([ + { + resourceUrls: ['http://feed.example/rss'], + callbackUrl: 'http://sub.example.com:5337/RPC2', + protocol: 'xml-rpc', + notifyProcedure: 'river.feedUpdated', + diffDomain: true + } + ]); + }); + + it('falls back to the client address, strips ::ffff:, adds a path slash, collects every url*', async () => { + const core = fakeCore(); + const dispatcher = createRestDispatcher({ core }); + + await dispatcher.pleaseNotify( + { + port: '8080', + path: 'callback', + protocol: 'https-post', + url1: 'http://a.example/rss', + URL2: 'http://b.example/rss' + }, + { clientAddress: '::ffff:198.51.100.7', format: 'json' } + ); + + expect(core.subscribeCalls).toEqual([ + { + resourceUrls: ['http://a.example/rss', 'http://b.example/rss'], + callbackUrl: 'https://198.51.100.7:8080/callback', + protocol: 'https-post', + diffDomain: false + } + ]); + }); + + it('infers https from port 443 and brackets a bare IPv6 domain', async () => { + const core = fakeCore(); + const dispatcher = createRestDispatcher({ core }); + + await dispatcher.pleaseNotify( + { + domain: '::1', + port: '443', + path: '/cb', + protocol: 'http-post', + url1: 'http://feed.example/rss' + }, + { clientAddress: '203.0.113.5', format: 'json' } + ); + + expect(core.subscribeCalls[0]?.callbackUrl).toBe( + 'https://[::1]:443/cb' + ); + }); + + it('renders success:false listing every missing required param', async () => { + const core = fakeCore(); + const dispatcher = createRestDispatcher({ core }); + + const res = await dispatcher.pleaseNotify( + { url1: 'http://feed.example/rss' }, + { clientAddress: '203.0.113.5', format: 'json' } + ); + + expect(JSON.parse(res.body)).toEqual({ + success: false, + msg: 'The following parameters were missing from the request body: port, path, protocol.' + }); + expect(core.subscribeCalls).toHaveLength(0); + }); + + it('renders success:false on an unsupported protocol', async () => { + const core = fakeCore(); + const dispatcher = createRestDispatcher({ core }); + + const res = await dispatcher.pleaseNotify( + { + port: '80', + path: '/cb', + protocol: 'ftp', + url1: 'http://feed.example/rss' + }, + { clientAddress: '203.0.113.5', format: 'json' } + ); + + expect(JSON.parse(res.body)).toEqual({ + success: false, + msg: 'Can\'t accept the subscription because the protocol, ftp, is unsupported.' + }); + expect(core.subscribeCalls).toHaveLength(0); + }); + + it('surfaces the first failed resource error when the subscribe fails', async () => { + const core = fakeCore({ + async subscribe() { + return { + success: false, + message: 'Subscription could not be confirmed for any resource.', + results: [ + { + resourceUrl: 'http://feed.example/rss', + success: false, + error: 'The resource at http://feed.example/rss could not be read.' + } + ] + }; + } + }); + const dispatcher = createRestDispatcher({ core }); + + const res = await dispatcher.pleaseNotify( + { + port: '80', + path: '/cb', + protocol: 'http-post', + url1: 'http://feed.example/rss' + }, + { clientAddress: '203.0.113.5', format: 'json' } + ); + + expect(JSON.parse(res.body)).toEqual({ + success: false, + msg: 'The resource at http://feed.example/rss could not be read.' + }); + }); + + it('falls back to the summary message when the failure carries no results', async () => { + const core = fakeCore({ + async subscribe() { + return { success: false, message: 'Nothing worked.' }; + } + }); + const dispatcher = createRestDispatcher({ core }); + + const res = await dispatcher.pleaseNotify( + { + port: '80', + path: '/cb', + protocol: 'http-post', + url1: 'http://feed.example/rss' + }, + { clientAddress: '203.0.113.5', format: 'json' } + ); + + expect(JSON.parse(res.body)).toEqual({ + success: false, + msg: 'Nothing worked.' + }); + }); + + it('falls back to the summary message when no failed resource has an error', async () => { + const core = fakeCore({ + async subscribe() { + return { + success: false, + message: 'Nothing worked.', + results: [ + { + resourceUrl: 'http://feed.example/rss', + success: false + } + ] + }; + } + }); + const dispatcher = createRestDispatcher({ core }); + + const res = await dispatcher.pleaseNotify( + { + port: '80', + path: '/cb', + protocol: 'http-post', + url1: 'http://feed.example/rss' + }, + { clientAddress: '203.0.113.5', format: 'json' } + ); + + expect(JSON.parse(res.body)).toEqual({ + success: false, + msg: 'Nothing worked.' + }); + }); + + it('relays a non-Error thrown value as its string form', async () => { + const core = fakeCore({ + async subscribe() { + throw 'plain string failure'; + } + }); + const dispatcher = createRestDispatcher({ core }); + + const res = await dispatcher.pleaseNotify( + { + port: '80', + path: '/cb', + protocol: 'http-post', + url1: 'http://feed.example/rss' + }, + { clientAddress: '203.0.113.5', format: 'json' } + ); + + expect(JSON.parse(res.body)).toEqual({ + success: false, + msg: 'plain string failure' + }); + }); +}); diff --git a/packages/core/src/protocols/rest-dispatcher.ts b/packages/core/src/protocols/rest-dispatcher.ts new file mode 100644 index 0000000..2bd4542 --- /dev/null +++ b/packages/core/src/protocols/rest-dispatcher.ts @@ -0,0 +1,256 @@ +import { Builder } from 'xml2js'; +import type { RssCloudCore } from '../engine/core.js'; +import type { + PingRequest, + SubscribeRequest, + SubscribeResponse +} from '../engine/dto.js'; +import type { Protocol } from '../engine/protocol.js'; + +/** Negotiated response format the adapter resolved from the `Accept` header. */ +export type RestResponseFormat = 'xml' | 'json' | null; + +/** Per-request context the adapter resolves before handing the front door a body. */ +export interface RestDispatchContext { + /** Caller address (already resolved from x-forwarded-for/remote address). */ + clientAddress: string; + /** Negotiated response format; `null` → 406 Not Acceptable. */ + format: RestResponseFormat; +} + +/** A fully-rendered HTTP response the adapter copies onto its framework's reply. */ +export interface RestResponse { + status: number; + contentType: string; + body: string; +} + +/** Construction-time dependencies for the REST dispatcher. */ +export interface RestDispatcherOptions { + core: Pick; +} + +/** Parsed-body-in, rendered-response-out rssCloud REST front door. */ +export interface RestDispatcher { + pleaseNotify( + body: Record, + ctx: RestDispatchContext + ): Promise; + ping( + body: Record, + ctx: RestDispatchContext + ): Promise; +} + +/** The wire-neutral outcome the renderer turns into xml/json. */ +interface RestResult { + success: boolean; + message: string; +} + +/** The message thrown when required body params are absent. */ +function missingParams(names: string): string { + return `The following parameters were missing from the request body: ${names}.`; +} + +/** Extract a message from any thrown value. */ +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** + * The message for a failed subscribe: prefer the first failed resource's + * specific error, falling back to the response's summary message. + */ +function failureMessage(response: SubscribeResponse): string { + const results = response.results ?? []; + const failed = results.find((result) => result.error !== undefined); + return failed?.error ?? response.message; +} + +/** Map the REST ping body into a `PingRequest`. Throws (→ failure) on a missing url. */ +function mapPing(body: Record): PingRequest { + if (body['url'] === undefined) { + throw new Error(missingParams('url')); + } + return { resourceUrl: String(body['url']) }; +} + +/** Protocols a subscriber may register under. */ +const VALID_PROTOCOLS = ['http-post', 'https-post', 'xml-rpc']; + +/** Collect every `url*` body key (any case) into a resource list. */ +function parseUrlList(body: Record): string[] { + const urls: string[] = []; + for (const key of Object.keys(body)) { + if (key.toLowerCase().startsWith('url')) { + urls.push(String(body[key])); + } + } + return urls; +} + +/** Assemble a callback URL from its parts the way the legacy `glueUrlParts` did. */ +function glueUrlParts( + scheme: string, + client: string, + port: string, + path: string +): string { + let host = client; + if (host.startsWith('::ffff:')) { + host = host.slice(7); + } + if (host.includes(':')) { + host = `[${host}]`; + } + + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${scheme}://${host}:${port}${normalizedPath}`; +} + +/** + * Map the REST `pleaseNotify` body (`port`, `path`, `protocol`, any `url*`, + * optional `domain`/`notifyProcedure`) into a `SubscribeRequest`. Throws + * (→ failure) on missing required params or an unsupported protocol. + */ +function mapPleaseNotify( + body: Record, + clientAddress: string +): SubscribeRequest { + const missing: string[] = []; + if (body['port'] === undefined) { + missing.push('port'); + } + if (body['path'] === undefined) { + missing.push('path'); + } + if (body['protocol'] === undefined) { + missing.push('protocol'); + } + if (missing.length > 0) { + throw new Error(missingParams(missing.join(', '))); + } + + const protocol = String(body['protocol']); + if (!VALID_PROTOCOLS.includes(protocol)) { + throw new Error( + `Can't accept the subscription because the protocol, ${protocol}, is unsupported.` + ); + } + + const port = String(body['port']); + const path = String(body['path']); + const domain = body['domain']; + + let client: string; + let diffDomain: boolean; + if (domain === undefined || domain === null || domain === '') { + client = clientAddress; + diffDomain = false; + } else { + client = String(domain); + diffDomain = true; + } + + const scheme = + protocol === 'https-post' || port === '443' ? 'https' : 'http'; + + const request: SubscribeRequest = { + resourceUrls: parseUrlList(body), + callbackUrl: glueUrlParts(scheme, client, port, path), + protocol: protocol as Protocol, + diffDomain + }; + + if (body['notifyProcedure'] && protocol === 'xml-rpc') { + request.notifyProcedure = String(body['notifyProcedure']); + } + + return request; +} + +/** Serialize a result as a `` document. */ +function serializeXml(element: string, result: RestResult): string { + return new Builder().buildObject({ + [element]: { + $: { + success: result.success ? 'true' : 'false', + msg: result.message + } + } + }); +} + +/** Render a result in the negotiated format. The XML element names the use case. */ +function render( + format: RestResponseFormat, + element: string, + result: RestResult +): RestResponse { + if (format === 'xml') { + return { + status: 200, + contentType: 'text/xml', + body: serializeXml(element, result) + }; + } + if (format === 'json') { + return { + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: result.success, + msg: result.message + }) + }; + } + return { status: 406, contentType: 'text/plain', body: 'Not Acceptable' }; +} + +/** Build the rssCloud REST front door. */ +export function createRestDispatcher( + options: RestDispatcherOptions +): RestDispatcher { + const { core } = options; + + async function pleaseNotify( + body: Record, + ctx: RestDispatchContext + ): Promise { + let result: RestResult; + try { + const response = await core.subscribe( + mapPleaseNotify(body, ctx.clientAddress) + ); + result = { + success: response.success, + message: response.success + ? response.message + : failureMessage(response) + }; + } catch (err) { + result = { success: false, message: errorMessage(err) }; + } + return render(ctx.format, 'notifyResult', result); + } + + async function ping( + body: Record, + ctx: RestDispatchContext + ): Promise { + let result: RestResult; + try { + const response = await core.ping(mapPing(body)); + result = { + success: response.success, + message: response.message + }; + } catch (err) { + result = { success: false, message: errorMessage(err) }; + } + return render(ctx.format, 'result', result); + } + + return { pleaseNotify, ping }; +} From c70f6ad81aa810ea1c55bd0cab89a4fefae8134f Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Tue, 9 Jun 2026 20:47:50 -0500 Subject: [PATCH 24/90] fix(core): match dispatcher wire messages to the rssCloud contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REST and XML-RPC front doors emitted freshly-written engine strings that diverged from the legacy server's wire contract (which the e2e suite asserts verbatim). Most critically, an all-resources-failed pleaseNotify over XML-RPC returned a boolean-false success response instead of a fault. Port the legacy app-messages catalog into a shared protocols/app-messages.ts. The engine now speaks codes only (a new SubscribeResult.errorCode plus RssCloudError codes) and the dispatchers own the wire wording — necessary because one engine condition (resource-read-failed) renders as two different strings depending on the front door ("the ping was cancelled..." vs "the subscription was cancelled..."). - XML-RPC pleaseNotify failure now returns a fault, not boolean false - subscribe success / read-failure / verify-failure / no-resources all match the legacy strings across REST and XML-RPC - centralize missingParams / invalidProtocol / RPC arity strings Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/engine/create-core.test.ts | 4 +- packages/core/src/engine/create-core.ts | 4 +- packages/core/src/engine/dto.ts | 8 +- packages/core/src/protocols/app-messages.ts | 80 ++++++++++++++++ .../src/protocols/rest-dispatcher.test.ts | 93 +++++++++++++++++-- .../core/src/protocols/rest-dispatcher.ts | 61 ++++++------ .../src/protocols/xml-rpc-dispatcher.test.ts | 82 +++++++++++++++- .../core/src/protocols/xml-rpc-dispatcher.ts | 42 +++++---- 8 files changed, 310 insertions(+), 64 deletions(-) create mode 100644 packages/core/src/protocols/app-messages.ts diff --git a/packages/core/src/engine/create-core.test.ts b/packages/core/src/engine/create-core.test.ts index 4f59473..fdf786a 100644 --- a/packages/core/src/engine/create-core.test.ts +++ b/packages/core/src/engine/create-core.test.ts @@ -534,7 +534,7 @@ describe('createRssCloudCore subscribe', () => { expect(response.success).toBe(false); expect(response.results?.[0]).toMatchObject({ success: false, - error: 'Subscription verification failed.' + errorCode: 'SUBSCRIPTION_VERIFICATION_FAILED' }); expect(await store.getSubscriptions(FEED)).toHaveLength(0); }); @@ -554,7 +554,7 @@ describe('createRssCloudCore subscribe', () => { }); expect(response.success).toBe(false); - expect(response.results?.[0]?.error).toContain('could not be read'); + expect(response.results?.[0]?.errorCode).toBe('RESOURCE_READ_FAILED'); }); it('reports a failure when seeding the resource throws unexpectedly', async () => { diff --git a/packages/core/src/engine/create-core.ts b/packages/core/src/engine/create-core.ts index 34bca03..8877472 100644 --- a/packages/core/src/engine/create-core.ts +++ b/packages/core/src/engine/create-core.ts @@ -308,7 +308,7 @@ export function createRssCloudCore( return { resourceUrl, success: false, - error: `The resource at ${resourceUrl} could not be read.` + errorCode: 'RESOURCE_READ_FAILED' }; } } @@ -324,7 +324,7 @@ export function createRssCloudCore( return { resourceUrl, success: false, - error: 'Subscription verification failed.' + errorCode: 'SUBSCRIPTION_VERIFICATION_FAILED' }; } diff --git a/packages/core/src/engine/dto.ts b/packages/core/src/engine/dto.ts index be6a52d..c6ddaae 100644 --- a/packages/core/src/engine/dto.ts +++ b/packages/core/src/engine/dto.ts @@ -1,3 +1,4 @@ +import type { RssCloudErrorCode } from '../errors.js'; import type { Protocol } from './protocol.js'; /** @@ -29,7 +30,12 @@ export interface SubscribeRequest { export interface SubscribeResult { resourceUrl: string; success: boolean; - error?: string; + /** + * Machine-readable cause of a per-resource failure. Adapters map this to + * the wire wording (which differs by front door), so the engine never + * bakes a user-facing string here. + */ + errorCode?: RssCloudErrorCode; } export interface SubscribeResponse { diff --git a/packages/core/src/protocols/app-messages.ts b/packages/core/src/protocols/app-messages.ts new file mode 100644 index 0000000..10c51de --- /dev/null +++ b/packages/core/src/protocols/app-messages.ts @@ -0,0 +1,80 @@ +import type { SubscribeResult } from '../engine/dto.js'; +import { RssCloudError } from '../errors.js'; + +/** + * The rssCloud wire vocabulary, ported from the legacy server's + * `app-messages.js`. The dispatchers own these user-facing strings because the + * same engine condition can surface with different wording depending on the + * front door — a failed read is "The ping was cancelled..." via `/ping` but + * "The subscription was cancelled..." via `/pleaseNotify` — so the engine + * speaks codes and the adapter chooses the words. + */ +export const appMessages = { + error: { + subscription: { + missingParams: (params: string): string => + `The following parameters were missing from the request body: ${params}.`, + invalidProtocol: (protocol: string): string => + `Can't accept the subscription because the protocol, ${protocol}, is unsupported.`, + readResource: (url: string): string => + `The subscription was cancelled because there was an error reading the resource at URL ${url}.`, + failedHandler: + 'The subscription was cancelled because the call failed when we tested the handler.', + noResources: 'No resources specified.' + }, + ping: { + readResource: (url: string): string => + `The ping was cancelled because there was an error reading the resource at URL ${url}.` + }, + rpc: { + notEnoughParams: (method: string): string => + `Can't call "${method}" because there aren't enough parameters.`, + tooManyParams: (method: string): string => + `Can't call "${method}" because there are too many parameters.` + } + }, + success: { + subscription: + 'Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!' + } +}; + +/** Extract a message from any thrown value. */ +export function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** + * The wire message for a failed subscribe: the first failed resource's specific + * wording, falling back to the response's summary `message`. + */ +export function subscriptionFailureMessage( + results: SubscribeResult[] | undefined, + fallback: string +): string { + const failed = (results ?? []).find( + (result) => result.errorCode !== undefined + ); + switch (failed?.errorCode) { + case 'RESOURCE_READ_FAILED': + return appMessages.error.subscription.readResource( + failed.resourceUrl + ); + case 'SUBSCRIPTION_VERIFICATION_FAILED': + return appMessages.error.subscription.failedHandler; + default: + return fallback; + } +} + +/** + * The wire message for an error thrown out of a subscribe request: a coded + * no-resources error gets the legacy wording; anything else (a mapping error + * whose message is already final, or an incidental failure) keeps its message. + */ +export function subscriptionRequestErrorMessage(err: unknown): string { + if (err instanceof RssCloudError && err.code === 'NO_RESOURCES') { + return appMessages.error.subscription.noResources; + } + return errorMessage(err); +} diff --git a/packages/core/src/protocols/rest-dispatcher.test.ts b/packages/core/src/protocols/rest-dispatcher.test.ts index c0cc0cc..d737395 100644 --- a/packages/core/src/protocols/rest-dispatcher.test.ts +++ b/packages/core/src/protocols/rest-dispatcher.test.ts @@ -6,6 +6,7 @@ import type { SubscribeRequest, SubscribeResponse } from '../engine/dto.js'; +import { RssCloudError } from '../errors.js'; import { createRestDispatcher } from './rest-dispatcher.js'; interface FakeCore { @@ -103,10 +104,11 @@ describe('createRestDispatcher ping', () => { expect(core.pingCalls).toHaveLength(0); }); - it('relays a core ping failure as success:false (REST surfaces failures)', async () => { + it('renders the ping read-failure wording when core reports the resource is unreadable', async () => { const core = fakeCore({ async ping() { - throw new Error( + throw new RssCloudError( + 'RESOURCE_READ_FAILED', 'The resource at http://feed.example/rss could not be read.' ); } @@ -120,7 +122,26 @@ describe('createRestDispatcher ping', () => { expect(JSON.parse(res.body)).toEqual({ success: false, - msg: 'The resource at http://feed.example/rss could not be read.' + msg: 'The ping was cancelled because there was an error reading the resource at URL http://feed.example/rss.' + }); + }); + + it('relays an unexpected core ping error as success:false using its message', async () => { + const core = fakeCore({ + async ping() { + throw new Error('socket hang up'); + } + }); + const dispatcher = createRestDispatcher({ core }); + + const res = await dispatcher.ping( + { url: 'http://feed.example/rss' }, + { clientAddress: '203.0.113.5', format: 'json' } + ); + + expect(JSON.parse(res.body)).toEqual({ + success: false, + msg: 'socket hang up' }); }); @@ -161,7 +182,7 @@ describe('createRestDispatcher pleaseNotify', () => { expect(res.contentType).toBe('application/json'); expect(JSON.parse(res.body)).toEqual({ success: true, - msg: 'Subscription confirmed.' + msg: 'Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!' }); expect(core.subscribeCalls).toEqual([ { @@ -193,7 +214,7 @@ describe('createRestDispatcher pleaseNotify', () => { const parsed = await new Parser().parseStringPromise(res.body); expect(parsed.notifyResult.$).toEqual({ success: 'true', - msg: 'Subscription confirmed.' + msg: 'Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!' }); expect(core.subscribeCalls).toEqual([ { @@ -288,7 +309,7 @@ describe('createRestDispatcher pleaseNotify', () => { expect(core.subscribeCalls).toHaveLength(0); }); - it('surfaces the first failed resource error when the subscribe fails', async () => { + it('surfaces the subscription read-failure message when the subscribe fails', async () => { const core = fakeCore({ async subscribe() { return { @@ -298,7 +319,7 @@ describe('createRestDispatcher pleaseNotify', () => { { resourceUrl: 'http://feed.example/rss', success: false, - error: 'The resource at http://feed.example/rss could not be read.' + errorCode: 'RESOURCE_READ_FAILED' } ] }; @@ -318,7 +339,41 @@ describe('createRestDispatcher pleaseNotify', () => { expect(JSON.parse(res.body)).toEqual({ success: false, - msg: 'The resource at http://feed.example/rss could not be read.' + msg: 'The subscription was cancelled because there was an error reading the resource at URL http://feed.example/rss.' + }); + }); + + it('surfaces the handler-test message when verification fails', async () => { + const core = fakeCore({ + async subscribe() { + return { + success: false, + message: 'Subscription could not be confirmed for any resource.', + results: [ + { + resourceUrl: 'http://feed.example/rss', + success: false, + errorCode: 'SUBSCRIPTION_VERIFICATION_FAILED' + } + ] + }; + } + }); + const dispatcher = createRestDispatcher({ core }); + + const res = await dispatcher.pleaseNotify( + { + port: '80', + path: '/cb', + protocol: 'http-post', + url1: 'http://feed.example/rss' + }, + { clientAddress: '203.0.113.5', format: 'json' } + ); + + expect(JSON.parse(res.body)).toEqual({ + success: false, + msg: 'The subscription was cancelled because the call failed when we tested the handler.' }); }); @@ -379,6 +434,28 @@ describe('createRestDispatcher pleaseNotify', () => { }); }); + it('renders the no-resources message when the request carries none', async () => { + const core = fakeCore({ + async subscribe() { + throw new RssCloudError( + 'NO_RESOURCES', + 'No resources were supplied to subscribe to.' + ); + } + }); + const dispatcher = createRestDispatcher({ core }); + + const res = await dispatcher.pleaseNotify( + { port: '80', path: '/cb', protocol: 'http-post' }, + { clientAddress: '203.0.113.5', format: 'json' } + ); + + expect(JSON.parse(res.body)).toEqual({ + success: false, + msg: 'No resources specified.' + }); + }); + it('relays a non-Error thrown value as its string form', async () => { const core = fakeCore({ async subscribe() { diff --git a/packages/core/src/protocols/rest-dispatcher.ts b/packages/core/src/protocols/rest-dispatcher.ts index 2bd4542..5a0e37b 100644 --- a/packages/core/src/protocols/rest-dispatcher.ts +++ b/packages/core/src/protocols/rest-dispatcher.ts @@ -1,11 +1,14 @@ import { Builder } from 'xml2js'; import type { RssCloudCore } from '../engine/core.js'; -import type { - PingRequest, - SubscribeRequest, - SubscribeResponse -} from '../engine/dto.js'; +import type { PingRequest, SubscribeRequest } from '../engine/dto.js'; import type { Protocol } from '../engine/protocol.js'; +import { RssCloudError } from '../errors.js'; +import { + appMessages, + errorMessage, + subscriptionFailureMessage, + subscriptionRequestErrorMessage +} from './app-messages.js'; /** Negotiated response format the adapter resolved from the `Accept` header. */ export type RestResponseFormat = 'xml' | 'json' | null; @@ -48,30 +51,24 @@ interface RestResult { message: string; } -/** The message thrown when required body params are absent. */ -function missingParams(names: string): string { - return `The following parameters were missing from the request body: ${names}.`; -} - -/** Extract a message from any thrown value. */ -function errorMessage(err: unknown): string { - return err instanceof Error ? err.message : String(err); -} - /** - * The message for a failed subscribe: prefer the first failed resource's - * specific error, falling back to the response's summary message. + * The wire message for a failed ping: a coded unreadable-resource error gets the + * ping-specific wording; anything else (e.g. a missing url) keeps its message. */ -function failureMessage(response: SubscribeResponse): string { - const results = response.results ?? []; - const failed = results.find((result) => result.error !== undefined); - return failed?.error ?? response.message; +function pingFailureMessage( + err: unknown, + body: Record +): string { + if (err instanceof RssCloudError && err.code === 'RESOURCE_READ_FAILED') { + return appMessages.error.ping.readResource(String(body['url'])); + } + return errorMessage(err); } /** Map the REST ping body into a `PingRequest`. Throws (→ failure) on a missing url. */ function mapPing(body: Record): PingRequest { if (body['url'] === undefined) { - throw new Error(missingParams('url')); + throw new Error(appMessages.error.subscription.missingParams('url')); } return { resourceUrl: String(body['url']) }; } @@ -129,13 +126,15 @@ function mapPleaseNotify( missing.push('protocol'); } if (missing.length > 0) { - throw new Error(missingParams(missing.join(', '))); + throw new Error( + appMessages.error.subscription.missingParams(missing.join(', ')) + ); } const protocol = String(body['protocol']); if (!VALID_PROTOCOLS.includes(protocol)) { throw new Error( - `Can't accept the subscription because the protocol, ${protocol}, is unsupported.` + appMessages.error.subscription.invalidProtocol(protocol) ); } @@ -226,11 +225,17 @@ export function createRestDispatcher( result = { success: response.success, message: response.success - ? response.message - : failureMessage(response) + ? appMessages.success.subscription + : subscriptionFailureMessage( + response.results, + response.message + ) }; } catch (err) { - result = { success: false, message: errorMessage(err) }; + result = { + success: false, + message: subscriptionRequestErrorMessage(err) + }; } return render(ctx.format, 'notifyResult', result); } @@ -247,7 +252,7 @@ export function createRestDispatcher( message: response.message }; } catch (err) { - result = { success: false, message: errorMessage(err) }; + result = { success: false, message: pingFailureMessage(err, body) }; } return render(ctx.format, 'result', result); } diff --git a/packages/core/src/protocols/xml-rpc-dispatcher.test.ts b/packages/core/src/protocols/xml-rpc-dispatcher.test.ts index 10428da..83a26cf 100644 --- a/packages/core/src/protocols/xml-rpc-dispatcher.test.ts +++ b/packages/core/src/protocols/xml-rpc-dispatcher.test.ts @@ -6,6 +6,7 @@ import type { SubscribeRequest, SubscribeResponse } from '../engine/dto.js'; +import { RssCloudError } from '../errors.js'; import { createXmlRpcDispatcher } from './xml-rpc-dispatcher.js'; interface FakeCore { @@ -218,10 +219,56 @@ describe('createXmlRpcDispatcher pleaseNotify', () => { ]); }); - it('relays a subscribe failure as boolean false', async () => { + it('faults with the subscription read-failure message when subscribe fails', async () => { const core = fakeCore({ async subscribe() { - return { success: false, message: 'no' }; + return { + success: false, + message: + 'Subscription could not be confirmed for any resource.', + results: [ + { + resourceUrl: 'http://feed.example/rss', + success: false, + errorCode: 'RESOURCE_READ_FAILED' + } + ] + }; + } + }); + const dispatcher = createXmlRpcDispatcher({ core }); + + const out = await dispatcher.dispatch( + methodCall('rssCloud.pleaseNotify', [ + '', + '80', + '/cb', + 'http-post', + 'http://feed.example/rss' + ]), + { clientAddress: '203.0.113.5' } + ); + + expect(faultString(await parseResponse(out))).toBe( + 'The subscription was cancelled because there was an error reading the resource at URL http://feed.example/rss.' + ); + }); + + it('faults with the handler-test message when verification fails', async () => { + const core = fakeCore({ + async subscribe() { + return { + success: false, + message: + 'Subscription could not be confirmed for any resource.', + results: [ + { + resourceUrl: 'http://feed.example/rss', + success: false, + errorCode: 'SUBSCRIPTION_VERIFICATION_FAILED' + } + ] + }; } }); const dispatcher = createXmlRpcDispatcher({ core }); @@ -237,7 +284,9 @@ describe('createXmlRpcDispatcher pleaseNotify', () => { { clientAddress: '203.0.113.5' } ); - expect(isSuccess(await parseResponse(out))).toBe(false); + expect(faultString(await parseResponse(out))).toBe( + 'The subscription was cancelled because the call failed when we tested the handler.' + ); }); it('faults when there are too few params', async () => { @@ -293,6 +342,33 @@ describe('createXmlRpcDispatcher pleaseNotify', () => { ); }); + it('faults with the no-resources message when the request carries none', async () => { + const core = fakeCore({ + async subscribe() { + throw new RssCloudError( + 'NO_RESOURCES', + 'No resources were supplied to subscribe to.' + ); + } + }); + const dispatcher = createXmlRpcDispatcher({ core }); + + const out = await dispatcher.dispatch( + methodCall('rssCloud.pleaseNotify', [ + '', + '80', + '/cb', + 'http-post', + 'http://feed.example/rss' + ]), + { clientAddress: '203.0.113.5' } + ); + + expect(faultString(await parseResponse(out))).toBe( + 'No resources specified.' + ); + }); + it('faults when subscribe throws an Error', async () => { const core = fakeCore({ async subscribe() { diff --git a/packages/core/src/protocols/xml-rpc-dispatcher.ts b/packages/core/src/protocols/xml-rpc-dispatcher.ts index 4b50fc0..f8e61b9 100644 --- a/packages/core/src/protocols/xml-rpc-dispatcher.ts +++ b/packages/core/src/protocols/xml-rpc-dispatcher.ts @@ -1,6 +1,12 @@ import type { RssCloudCore } from '../engine/core.js'; import type { PingRequest, SubscribeRequest } from '../engine/dto.js'; import type { Protocol } from '../engine/protocol.js'; +import { + appMessages, + errorMessage, + subscriptionFailureMessage, + subscriptionRequestErrorMessage +} from './app-messages.js'; import { parseMethodCall, serializeFault, @@ -58,20 +64,16 @@ function mapPleaseNotify( clientAddress: string ): SubscribeRequest { if (params.length < 5) { - throw new Error( - 'Can\'t call "pleaseNotify" because there aren\'t enough parameters.' - ); + throw new Error(appMessages.error.rpc.notEnoughParams('pleaseNotify')); } if (params.length > 6) { - throw new Error( - 'Can\'t call "pleaseNotify" because there are too many parameters.' - ); + throw new Error(appMessages.error.rpc.tooManyParams('pleaseNotify')); } const protocol = String(params[3]); if (!VALID_PROTOCOLS.includes(protocol)) { throw new Error( - `Can't accept the subscription because the protocol, ${protocol}, is unsupported.` + appMessages.error.subscription.invalidProtocol(protocol) ); } @@ -114,14 +116,10 @@ function mapPleaseNotify( /** Map the single `ping` param into a `PingRequest`. Throws (→ fault) on bad arity. */ function mapPing(params: unknown[]): PingRequest { if (params.length < 1) { - throw new Error( - 'Can\'t call "ping" because there aren\'t enough parameters.' - ); + throw new Error(appMessages.error.rpc.notEnoughParams('ping')); } if (params.length > 1) { - throw new Error( - 'Can\'t call "ping" because there are too many parameters.' - ); + throw new Error(appMessages.error.rpc.tooManyParams('ping')); } return { resourceUrl: String(params[0]) }; @@ -146,9 +144,18 @@ export function createXmlRpcDispatcher( const result = await core.subscribe( mapPleaseNotify(params, clientAddress) ); - return serializeSuccess(result.success); + if (!result.success) { + return serializeFault( + FAULT_CODE, + subscriptionFailureMessage(result.results, result.message) + ); + } + return serializeSuccess(true); } catch (err) { - return serializeFault(FAULT_CODE, errorMessage(err)); + return serializeFault( + FAULT_CODE, + subscriptionRequestErrorMessage(err) + ); } } @@ -202,8 +209,3 @@ export function createXmlRpcDispatcher( return { dispatch }; } - -/** Extract a message from any thrown value. */ -function errorMessage(err: unknown): string { - return err instanceof Error ? err.message : String(err); -} From 05c1e399d1c3faab6071bd71c056c663c7fcb107 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 09:14:37 -0500 Subject: [PATCH 25/90] feat(core): add file-backed Store adapter Add createFileStore(), a file-backed Store alongside createInMemoryStore, to back apps/server's persistence on @rsscloud/core. - Debounced write-behind queue: puts/removes update memory and resolve immediately, (re)arming a debounceMs timer capped by maxWaitMs; a single in-flight write, with mid-write mutations coalesced into one follow-up pass. - Round-trip-faithful legacy subscriptions.json mapping in both directions (keyed by feed URL, flat feed fields <-> nested resource.feed, ISO-Z dates, epoch <-> null, notifyProcedure false <-> absent, synthesized whenCreated). - Loads on init, awaitable flush()/close(); best-effort on write failure. 100% covered. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/index.ts | 5 + packages/core/src/store/file-store.test.ts | 467 +++++++++++++++++++++ packages/core/src/store/file-store.ts | 311 ++++++++++++++ 3 files changed, 783 insertions(+) create mode 100644 packages/core/src/store/file-store.test.ts create mode 100644 packages/core/src/store/file-store.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 00571a5..c7d43e4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -32,6 +32,11 @@ export { type DefaultFeedParserOptions } from './feed/feed-parser.js'; export { createInMemoryStore } from './store/memory-store.js'; +export { + createFileStore, + type FileStore, + type FileStoreOptions +} from './store/file-store.js'; // Contracts export type { BuiltInProtocol, Protocol } from './engine/protocol.js'; diff --git a/packages/core/src/store/file-store.test.ts b/packages/core/src/store/file-store.test.ts new file mode 100644 index 0000000..685990b --- /dev/null +++ b/packages/core/src/store/file-store.test.ts @@ -0,0 +1,467 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createFileStore } from './file-store.js'; +import type { FileStore, FileStoreOptions } from './file-store.js'; +import type { Resource } from '../engine/resource.js'; +import type { Subscription } from '../engine/subscription.js'; + +let dir: string; +let filePath: string; +let stores: FileStore[]; + +beforeEach(async () => { + vi.useFakeTimers(); + stores = []; + dir = await mkdtemp(join(tmpdir(), 'rsscloud-file-store-')); + filePath = join(dir, 'subscriptions.json'); +}); + +afterEach(async () => { + vi.useRealTimers(); + // Close every store so no background write is in flight during cleanup. + for (const store of stores) await store.close(); + await rm(dir, { recursive: true, force: true }); +}); + +/** Create a store and register it for guaranteed cleanup. */ +async function makeStore( + opts?: Partial +): Promise { + const store = await createFileStore({ filePath, ...opts }); + stores.push(store); + return store; +} + +const LEGACY_FEED = 'http://scripting.com/rss.xml'; +const NEW_FEED = 'https://feed.example/rss'; + +async function readDisk(): Promise { + return JSON.parse(await readFile(filePath, 'utf8')); +} + +function fileExists(): Promise { + return readFile(filePath, 'utf8').then( + () => true, + () => false + ); +} + +function coreResource(): Resource { + return { + url: NEW_FEED, + lastHash: 'abc', + lastSize: 100, + ctChecks: 5, + whenLastCheck: new Date('2026-01-02T03:04:05.000Z'), + ctUpdates: 2, + whenLastUpdate: new Date('2026-01-02T03:04:05.000Z') + }; +} + +function coreSubscription(): Subscription { + return { + url: 'http://sub.example/notify', + protocol: 'http-post', + ctUpdates: 0, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: new Date('2026-01-01T00:00:00.000Z'), + whenLastUpdate: null, + whenLastError: null, + whenExpires: new Date('2099-01-01T00:00:00.000Z') + }; +} + +const LEGACY_FILE = { + [LEGACY_FEED]: { + resource: { + lastSize: 84682, + lastHash: '0dbe7cdebe7669b47423a4f53cc67f68', + ctChecks: 41426, + whenLastCheck: '2026-06-10T12:55:28.000Z', + ctUpdates: 35737, + whenLastUpdate: '2026-06-10T12:45:25.000Z', + feedType: 'rss', + feedTitle: 'Scripting News', + feedDescription: 'Dave Winer, OG blogger.', + feedHtmlUrl: 'http://scripting.com/', + feedLanguage: 'en-us' + }, + subscribers: [ + { + ctUpdates: 89560, + whenLastUpdate: '2026-06-10T12:55:28.000Z', + ctErrors: 122, + ctConsecutiveErrors: 0, + whenLastError: '2024-12-12T23:37:21.000Z', + whenExpires: '2026-06-11T13:55:28+00:00', + url: 'http://157.230.11.43:1414/feedping', + notifyProcedure: false, + protocol: 'http-post' + } + ] + } +}; + +async function writeLegacy(): Promise { + await writeFile(filePath, JSON.stringify(LEGACY_FILE, null, 2)); +} + +describe('createFileStore', () => { + it('loads a legacy file and exposes the resource in core shape', async () => { + await writeLegacy(); + + const store = await makeStore(); + + expect(await store.getResource(LEGACY_FEED)).toEqual({ + url: LEGACY_FEED, + lastHash: '0dbe7cdebe7669b47423a4f53cc67f68', + lastSize: 84682, + ctChecks: 41426, + whenLastCheck: new Date('2026-06-10T12:55:28.000Z'), + ctUpdates: 35737, + whenLastUpdate: new Date('2026-06-10T12:45:25.000Z'), + feed: { + type: 'rss', + title: 'Scripting News', + description: 'Dave Winer, OG blogger.', + htmlUrl: 'http://scripting.com/', + language: 'en-us' + } + }); + }); + + it('maps legacy subscribers to core subscriptions', async () => { + await writeLegacy(); + + const store = await makeStore(); + + expect(await store.getSubscriptions(LEGACY_FEED)).toEqual([ + { + url: 'http://157.230.11.43:1414/feedping', + protocol: 'http-post', + ctUpdates: 89560, + ctErrors: 122, + ctConsecutiveErrors: 0, + whenCreated: new Date('2026-06-11T13:55:28+00:00'), + whenLastUpdate: new Date('2026-06-10T12:55:28.000Z'), + whenLastError: new Date('2024-12-12T23:37:21.000Z'), + whenExpires: new Date('2026-06-11T13:55:28+00:00') + } + ]); + }); + + it('lists every tracked feed with its mapped resource and subscriptions', async () => { + await writeLegacy(); + + const store = await makeStore(); + + expect(await store.list()).toEqual([ + { + feedUrl: LEGACY_FEED, + resource: await store.getResource(LEGACY_FEED), + subscriptions: await store.getSubscriptions(LEGACY_FEED) + } + ]); + }); + + it('removes a feed entirely', async () => { + await writeLegacy(); + + const store = await makeStore(); + await store.remove(LEGACY_FEED); + + expect(await store.getResource(LEGACY_FEED)).toBeNull(); + expect(await store.getSubscriptions(LEGACY_FEED)).toEqual([]); + expect(await store.list()).toEqual([]); + }); + + it('flushes putResource as a faithful flat resource shape', async () => { + const store = await makeStore(); + + await store.putResource(NEW_FEED, { + url: NEW_FEED, + lastHash: 'abc', + lastSize: 100, + ctChecks: 5, + whenLastCheck: new Date('2026-01-02T03:04:05.000Z'), + ctUpdates: 2, + whenLastUpdate: new Date('2026-01-02T03:04:05.000Z'), + feed: { + type: 'rss', + title: 'New', + description: 'D', + htmlUrl: 'http://x/', + language: 'en' + } + }); + await store.flush(); + + expect(await readDisk()).toEqual({ + [NEW_FEED]: { + resource: { + lastSize: 100, + lastHash: 'abc', + ctChecks: 5, + whenLastCheck: '2026-01-02T03:04:05.000Z', + ctUpdates: 2, + whenLastUpdate: '2026-01-02T03:04:05.000Z', + feedType: 'rss', + feedTitle: 'New', + feedDescription: 'D', + feedHtmlUrl: 'http://x/', + feedLanguage: 'en' + }, + subscribers: [] + } + }); + }); + + it('flushes putSubscriptions as faithful subscriber records', async () => { + const store = await makeStore(); + + await store.putSubscriptions(NEW_FEED, [ + { + url: 'http://sub.example/notify', + protocol: 'http-post', + ctUpdates: 0, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: new Date('2026-01-01T00:00:00.000Z'), + whenLastUpdate: null, + whenLastError: null, + whenExpires: new Date('2099-01-01T00:00:00.000Z') + }, + { + url: 'http://sub.example/rpc', + protocol: 'xml-rpc', + notifyProcedure: 'river.feedUpdated', + ctUpdates: 3, + ctErrors: 1, + ctConsecutiveErrors: 0, + whenCreated: new Date('2026-01-01T00:00:00.000Z'), + whenLastUpdate: new Date('2026-02-01T00:00:00.000Z'), + whenLastError: new Date('2025-12-01T00:00:00.000Z'), + whenExpires: new Date('2099-01-01T00:00:00.000Z'), + details: { secret: 's3cr3t' } + } + ]); + await store.flush(); + + expect(await readDisk()).toEqual({ + [NEW_FEED]: { + resource: {}, + subscribers: [ + { + ctUpdates: 0, + whenLastUpdate: '1970-01-01T00:00:00.000Z', + ctErrors: 0, + ctConsecutiveErrors: 0, + whenLastError: '1970-01-01T00:00:00.000Z', + whenExpires: '2099-01-01T00:00:00.000Z', + url: 'http://sub.example/notify', + notifyProcedure: false, + protocol: 'http-post' + }, + { + ctUpdates: 3, + whenLastUpdate: '2026-02-01T00:00:00.000Z', + ctErrors: 1, + ctConsecutiveErrors: 0, + whenLastError: '2025-12-01T00:00:00.000Z', + whenExpires: '2099-01-01T00:00:00.000Z', + url: 'http://sub.example/rpc', + notifyProcedure: 'river.feedUpdated', + protocol: 'xml-rpc', + details: { secret: 's3cr3t' } + } + ] + } + }); + + // An entry created via subscriptions only has no real resource. + expect(await store.getResource(NEW_FEED)).toBeNull(); + }); + + it('round-trips subscriptions through put and get', async () => { + const store = await makeStore(); + + await store.putSubscriptions(NEW_FEED, [coreSubscription()]); + + // whenCreated is not persisted; it is re-derived from whenExpires. + expect(await store.getSubscriptions(NEW_FEED)).toEqual([ + { + ...coreSubscription(), + whenCreated: new Date('2099-01-01T00:00:00.000Z') + } + ]); + }); + + it('coalesces a burst of puts into a single scheduled flush', async () => { + const store = await makeStore({ debounceMs: 1000 }); + + for (let i = 0; i < 5; i += 1) { + await store.putResource(`${NEW_FEED}/${i}`, coreResource()); + } + // Each put re-arms one timer rather than queuing five. + expect(vi.getTimerCount()).toBe(1); + + await vi.advanceTimersByTimeAsync(1000); + await store.flush(); + + // The single flush captured every feed from the burst. + expect(Object.keys((await readDisk()) as object)).toHaveLength(5); + }); + + it('flushes by maxWaitMs even when churn keeps re-arming the debounce', async () => { + const store = await makeStore({ debounceMs: 1000, maxWaitMs: 3000 }); + + await store.putResource(NEW_FEED, coreResource()); + + // Churn every 900ms so the 1000ms debounce never settles on its own. + for (let t = 900; t <= 2700; t += 900) { + await vi.advanceTimersByTimeAsync(900); + expect(await fileExists()).toBe(false); + await store.putResource(`${NEW_FEED}/${t}`, coreResource()); + } + + // At t=3000 the maxWait ceiling forces a flush a debounce-only + // scheduler would have pushed out to t=3700. + await vi.advanceTimersByTimeAsync(300); + await store.flush(); + expect(await fileExists()).toBe(true); + }); + + it('does not write until the debounce interval elapses', async () => { + const store = await makeStore({ debounceMs: 1000 }); + + await store.putResource(NEW_FEED, coreResource()); + + await vi.advanceTimersByTimeAsync(999); + expect(await fileExists()).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + // The debounce timer has fired; join its write to settle it. + await store.flush(); + expect(await fileExists()).toBe(true); + expect(Object.keys((await readDisk()) as object)).toEqual([NEW_FEED]); + }); + + it('keeps resource and subscriptions together for one feed', async () => { + const store = await makeStore(); + + await store.putResource(NEW_FEED, coreResource()); + await store.putSubscriptions(NEW_FEED, [coreSubscription()]); + await store.flush(); + + const onDisk = (await readDisk()) as Record< + string, + { resource: unknown; subscribers: unknown[] } + >; + expect(onDisk[NEW_FEED]?.resource).not.toEqual({}); + expect(onDisk[NEW_FEED]?.subscribers).toHaveLength(1); + }); + + it('reads sparse, hand-written entries with core defaults', async () => { + const SPARSE_FEED = 'https://sparse.example/feed'; + const NOSUB_FEED = 'https://nosub.example/feed'; + await writeFile( + filePath, + JSON.stringify({ + [SPARSE_FEED]: { + // Only one change field present, no feed metadata. + resource: { lastHash: 'x' }, + subscribers: [ + { + url: 'https://sub.example/rpc', + protocol: 'xml-rpc', + notifyProcedure: 'river.feedUpdated', + whenCreated: '2020-01-01T00:00:00.000Z', + details: { secret: 'x' } + // counters, whenLastUpdate/Error, whenExpires absent + } + ] + }, + // An entry with a resource but no subscribers key at all. + [NOSUB_FEED]: { resource: { lastSize: 1 } } + }) + ); + + const store = await makeStore(); + + expect(await store.getResource(SPARSE_FEED)).toEqual({ + url: SPARSE_FEED, + lastHash: 'x', + lastSize: 0, + ctChecks: 0, + whenLastCheck: new Date(0), + ctUpdates: 0, + whenLastUpdate: new Date(0) + }); + + expect(await store.getSubscriptions(SPARSE_FEED)).toEqual([ + { + url: 'https://sub.example/rpc', + protocol: 'xml-rpc', + notifyProcedure: 'river.feedUpdated', + ctUpdates: 0, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: new Date('2020-01-01T00:00:00.000Z'), + whenLastUpdate: null, + whenLastError: null, + whenExpires: new Date(0), + details: { secret: 'x' } + } + ]); + + // A resource-only entry lists with no subscriptions. + expect(await store.getSubscriptions(NOSUB_FEED)).toEqual([]); + const nosub = (await store.list()).find(e => e.feedUrl === NOSUB_FEED); + expect(nosub?.subscriptions).toEqual([]); + }); + + it('starts empty when the file is corrupt', async () => { + await writeFile(filePath, 'not json at all'); + + const store = await makeStore(); + + expect(await store.list()).toEqual([]); + }); + + it('keeps data in memory when a write fails, without throwing', async () => { + // A file where a directory is expected makes the atomic write fail. + await writeFile(join(dir, 'blocker'), 'x'); + const store = await makeStore({ + filePath: join(dir, 'blocker', 'subscriptions.json') + }); + + await store.putResource(NEW_FEED, coreResource()); + await expect(store.flush()).resolves.toBeUndefined(); + + // The change is retained in memory (and re-armed for a later retry). + expect(await store.getResource(NEW_FEED)).not.toBeNull(); + }); + + it('flush is a no-op when nothing has changed', async () => { + const store = await makeStore(); + + await store.flush(); + + expect(await fileExists()).toBe(false); + }); + + it('close performs a final flush and stops the timer', async () => { + const store = await makeStore({ debounceMs: 1000 }); + + await store.putResource(NEW_FEED, coreResource()); + expect(vi.getTimerCount()).toBe(1); + + await store.close(); + + expect(await fileExists()).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/packages/core/src/store/file-store.ts b/packages/core/src/store/file-store.ts new file mode 100644 index 0000000..50ae194 --- /dev/null +++ b/packages/core/src/store/file-store.ts @@ -0,0 +1,311 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import type { FeedMetadata } from '../feed/feed.js'; +import type { Protocol } from '../engine/protocol.js'; +import type { Resource } from '../engine/resource.js'; +import type { Subscription } from '../engine/subscription.js'; +import type { FeedEntry, Store } from './store.js'; + +/** Options for {@link createFileStore}. */ +export interface FileStoreOptions { + /** Path to the JSON file the store loads from and flushes to. */ + filePath: string; + /** Quiet-gap delay before a coalesced flush. Defaults to 1000ms. */ + debounceMs?: number; + /** Hard ceiling between flushes under sustained churn. Defaults to 60000ms. */ + maxWaitMs?: number; +} + +/** A file-backed {@link Store} with durable-flush controls. */ +export interface FileStore extends Store { + /** Force a durable write of the current state; resolves once on disk. */ + flush(): Promise; + /** Stop the flush timer and perform a final durable write. */ + close(): Promise; +} + +/** One feed's on-disk record: flat resource fields plus its subscribers. */ +interface DiskResource { + lastSize?: number; + lastHash?: string; + ctChecks?: number; + whenLastCheck?: string; + ctUpdates?: number; + whenLastUpdate?: string; + feedType?: string; + feedTitle?: string; + feedDescription?: string; + feedHtmlUrl?: string; + feedLanguage?: string; +} + +interface DiskSubscriber { + url: string; + protocol: Protocol; + notifyProcedure?: string | false; + ctUpdates?: number; + ctErrors?: number; + ctConsecutiveErrors?: number; + whenCreated?: string; + whenLastUpdate?: string; + whenLastError?: string; + whenExpires?: string; + details?: Record; +} + +interface DiskEntry { + resource?: DiskResource | null; + subscribers?: DiskSubscriber[]; +} + +type DiskData = Record; + +const EPOCH_ISO = new Date(0).toISOString(); + +/** Epoch (`new Date(0)`) marks "never happened" on disk. */ +function readWhen(value: string | undefined): Date { + return new Date(value ?? 0); +} + +/** Epoch on disk maps to `null` ("never") in the core model. */ +function readNullableWhen(value: string | undefined): Date | null { + const date = new Date(value ?? 0); + return date.getTime() === 0 ? null : date; +} + +function readSubscription(raw: DiskSubscriber): Subscription { + const whenExpires = readWhen(raw.whenExpires); + const subscription: Subscription = { + url: raw.url, + protocol: raw.protocol, + ctUpdates: raw.ctUpdates ?? 0, + ctErrors: raw.ctErrors ?? 0, + ctConsecutiveErrors: raw.ctConsecutiveErrors ?? 0, + // Legacy records carry no creation time; synthesize from expiry. + whenCreated: raw.whenCreated != null ? readWhen(raw.whenCreated) : whenExpires, + whenLastUpdate: readNullableWhen(raw.whenLastUpdate), + whenLastError: readNullableWhen(raw.whenLastError), + whenExpires + }; + if (typeof raw.notifyProcedure === 'string') { + subscription.notifyProcedure = raw.notifyProcedure; + } + if (raw.details !== undefined) { + subscription.details = raw.details; + } + return subscription; +} + +function readFeed(raw: DiskResource): FeedMetadata | undefined { + const feed: FeedMetadata = {}; + if (raw.feedType != null) feed.type = raw.feedType; + if (raw.feedTitle != null) feed.title = raw.feedTitle; + if (raw.feedDescription != null) feed.description = raw.feedDescription; + if (raw.feedHtmlUrl != null) feed.htmlUrl = raw.feedHtmlUrl; + if (raw.feedLanguage != null) feed.language = raw.feedLanguage; + return Object.keys(feed).length > 0 ? feed : undefined; +} + +function readResource( + feedUrl: string, + raw: DiskResource | null | undefined +): Resource | null { + // A missing or `{}` resource (a subscriptions-only entry) is "no resource". + if (raw == null || Object.keys(raw).length === 0) return null; + const resource: Resource = { + url: feedUrl, + lastHash: raw.lastHash ?? '', + lastSize: raw.lastSize ?? 0, + ctChecks: raw.ctChecks ?? 0, + whenLastCheck: new Date(raw.whenLastCheck ?? 0), + ctUpdates: raw.ctUpdates ?? 0, + whenLastUpdate: new Date(raw.whenLastUpdate ?? 0) + }; + const feed = readFeed(raw); + if (feed !== undefined) resource.feed = feed; + return resource; +} + +function writeResource(resource: Resource): DiskResource { + const out: DiskResource = { + lastSize: resource.lastSize, + lastHash: resource.lastHash, + ctChecks: resource.ctChecks, + whenLastCheck: resource.whenLastCheck.toISOString(), + ctUpdates: resource.ctUpdates, + whenLastUpdate: resource.whenLastUpdate.toISOString() + }; + const feed = resource.feed; + if (feed !== undefined) { + if (feed.type != null) out.feedType = feed.type; + if (feed.title != null) out.feedTitle = feed.title; + if (feed.description != null) out.feedDescription = feed.description; + if (feed.htmlUrl != null) out.feedHtmlUrl = feed.htmlUrl; + if (feed.language != null) out.feedLanguage = feed.language; + } + return out; +} + +/** `null` ("never") serializes back to the epoch string the legacy reader uses. */ +function writeWhen(value: Date | null): string { + return value === null ? EPOCH_ISO : value.toISOString(); +} + +function writeSubscription(subscription: Subscription): DiskSubscriber { + const out: DiskSubscriber = { + ctUpdates: subscription.ctUpdates, + whenLastUpdate: writeWhen(subscription.whenLastUpdate), + ctErrors: subscription.ctErrors, + ctConsecutiveErrors: subscription.ctConsecutiveErrors, + whenLastError: writeWhen(subscription.whenLastError), + whenExpires: subscription.whenExpires.toISOString(), + url: subscription.url, + // REST subs carry no procedure; the legacy shape records that as `false`. + notifyProcedure: subscription.notifyProcedure ?? false, + protocol: subscription.protocol + }; + if (subscription.details !== undefined) { + out.details = subscription.details; + } + return out; +} + +async function loadDisk(filePath: string): Promise { + try { + return JSON.parse(await readFile(filePath, 'utf8')) as DiskData; + } catch { + return {}; + } +} + +/** + * A file-backed {@link Store}. Loads on init, maps the legacy on-disk shape + * (keyed by feed URL, flat feed fields, string dates) to and from core's model. + */ +export async function createFileStore( + options: FileStoreOptions +): Promise { + const { filePath } = options; + const debounceMs = options.debounceMs ?? 1000; + const maxWaitMs = options.maxWaitMs ?? 60000; + const disk = await loadDisk(filePath); + + let dirty = false; + let firstDirtyAt: number | null = null; + let flushTimer: ReturnType | null = null; + let inFlight: Promise | null = null; + + function entryFor(feedUrl: string): DiskEntry { + const existing = disk[feedUrl]; + if (existing !== undefined) return existing; + // Mirror the legacy shape: every entry has a resource and subscribers. + const created: DiskEntry = { resource: {}, subscribers: [] }; + disk[feedUrl] = created; + return created; + } + + async function writeToDisk(): Promise { + await mkdir(dirname(filePath), { recursive: true }); + // Snapshot synchronously so an in-flight write can't tear. + const snapshot = JSON.stringify(disk, null, 2); + const tmp = `${filePath}.tmp`; + await writeFile(tmp, snapshot); + await rename(tmp, filePath); + } + + function clearFlushTimer(): void { + if (flushTimer !== null) { + clearTimeout(flushTimer); + flushTimer = null; + } + } + + /** + * Write the current state durably. Joins an in-flight write rather than + * starting a second one; coalesces any mutations that land mid-write into a + * follow-up pass; resolves once the disk reflects everything seen. A failed + * write is best-effort — the data stays in memory and is retried on the next + * mutation or flush rather than thrown. + */ + function doFlush(): Promise { + if (inFlight !== null) return inFlight; + if (!dirty) return Promise.resolve(); + clearFlushTimer(); + inFlight = (async () => { + try { + while (dirty) { + dirty = false; + firstDirtyAt = null; + await writeToDisk(); + } + } catch { + dirty = true; + } finally { + inFlight = null; + clearFlushTimer(); + } + })(); + return inFlight; + } + + function markDirty(): void { + dirty = true; + const startedAt = firstDirtyAt ?? Date.now(); + firstDirtyAt = startedAt; + clearFlushTimer(); + // Debounce, but never push the next write past the maxWait ceiling + // measured from when the run of changes began. A negative wait (already + // past the ceiling) lets setTimeout fire on the next tick. + const wait = Math.min(debounceMs, maxWaitMs - (Date.now() - startedAt)); + flushTimer = setTimeout(() => void doFlush(), wait); + } + + return { + async getResource(feedUrl: string): Promise { + return readResource(feedUrl, disk[feedUrl]?.resource); + }, + + async putResource( + feedUrl: string, + resource: Resource + ): Promise { + entryFor(feedUrl).resource = writeResource(resource); + markDirty(); + }, + + async getSubscriptions(feedUrl: string): Promise { + const subscribers = disk[feedUrl]?.subscribers ?? []; + return subscribers.map(readSubscription); + }, + + async putSubscriptions( + feedUrl: string, + subscriptions: Subscription[] + ): Promise { + entryFor(feedUrl).subscribers = subscriptions.map(writeSubscription); + markDirty(); + }, + + async list(): Promise { + return Object.entries(disk).map(([feedUrl, entry]) => ({ + feedUrl, + resource: readResource(feedUrl, entry.resource), + subscriptions: (entry.subscribers ?? []).map(readSubscription) + })); + }, + + async remove(feedUrl: string): Promise { + delete disk[feedUrl]; + markDirty(); + }, + + async flush(): Promise { + await doFlush(); + }, + + async close(): Promise { + clearFlushTimer(); + await doFlush(); + } + }; +} From f3a00381b40172dc54390a9070daf09f653854f7 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 11:15:01 -0500 Subject: [PATCH 26/90] feat(express): add Express middleware for the rssCloud front doors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce @rsscloud/express with per-endpoint factories — pleaseNotify, ping, and rpc2 — each a drop-in Express handler stack built from a @rsscloud/core engine. Handlers parse their own body, resolve the client address from X-Forwarded-For/socket, negotiate the Accept response format, and delegate to core's REST and XML-RPC dispatchers; they hold no protocol logic of their own. 100% covered with vitest + supertest. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/express/LICENSE.md | 20 ++ packages/express/README.md | 49 +++++ packages/express/eslint.config.mjs | 17 ++ packages/express/package.json | 79 ++++++++ packages/express/src/client-address.test.ts | 41 ++++ packages/express/src/client-address.ts | 13 ++ packages/express/src/index.test.ts | 10 + packages/express/src/index.ts | 11 ++ packages/express/src/rest-middleware.test.ts | 184 ++++++++++++++++++ packages/express/src/rest-middleware.ts | 54 +++++ .../express/src/xml-rpc-middleware.test.ts | 122 ++++++++++++ packages/express/src/xml-rpc-middleware.ts | 30 +++ packages/express/tsconfig.build.json | 8 + packages/express/tsconfig.json | 27 +++ packages/express/tsup.config.ts | 13 ++ packages/express/vitest.config.ts | 19 ++ pnpm-lock.yaml | 145 ++++++++++++++ 17 files changed, 842 insertions(+) create mode 100644 packages/express/LICENSE.md create mode 100644 packages/express/README.md create mode 100644 packages/express/eslint.config.mjs create mode 100644 packages/express/package.json create mode 100644 packages/express/src/client-address.test.ts create mode 100644 packages/express/src/client-address.ts create mode 100644 packages/express/src/index.test.ts create mode 100644 packages/express/src/index.ts create mode 100644 packages/express/src/rest-middleware.test.ts create mode 100644 packages/express/src/rest-middleware.ts create mode 100644 packages/express/src/xml-rpc-middleware.test.ts create mode 100644 packages/express/src/xml-rpc-middleware.ts create mode 100644 packages/express/tsconfig.build.json create mode 100644 packages/express/tsconfig.json create mode 100644 packages/express/tsup.config.ts create mode 100644 packages/express/vitest.config.ts diff --git a/packages/express/LICENSE.md b/packages/express/LICENSE.md new file mode 100644 index 0000000..d81b273 --- /dev/null +++ b/packages/express/LICENSE.md @@ -0,0 +1,20 @@ +Copyright (c) 2015-2026 Andrew Shell + +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. diff --git a/packages/express/README.md b/packages/express/README.md new file mode 100644 index 0000000..9a16a6e --- /dev/null +++ b/packages/express/README.md @@ -0,0 +1,49 @@ +# @rsscloud/express + +[Express](https://expressjs.com/) middleware for the +[rssCloud](https://github.com/rsscloud/rsscloud-server) notification protocol. + +Each endpoint is a separate, drop-in handler built from a `@rsscloud/core` +engine, so an app mounts only the front doors it wants to expose: + +```ts +import express from 'express'; +import { + createRssCloudCore, + createInMemoryStore, + createRestProtocolPlugin, + resolveConfig +} from '@rsscloud/core'; +import { pleaseNotify, ping, rpc2 } from '@rsscloud/express'; + +const config = resolveConfig(); +const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [createRestProtocolPlugin({ requestTimeoutMs: config.requestTimeoutMs })], + config +}); + +const app = express(); + +app.post('/pleaseNotify', pleaseNotify({ core })); +app.post('/ping', ping({ core })); +app.post('/RPC2', rpc2({ core })); +``` + +Each handler parses its own request body (`urlencoded` for the REST front +doors, `text/*xml` for `RPC2`), resolves the caller address from +`X-Forwarded-For` or the socket, negotiates the response format from the +`Accept` header, and delegates the protocol work to `@rsscloud/core`'s +dispatchers. The handlers hold no rssCloud logic of their own. + +## Install + +```bash +pnpm add @rsscloud/express @rsscloud/core express +``` + +`express` is a peer dependency. + +## License + +MIT — see [LICENSE.md](./LICENSE.md). diff --git a/packages/express/eslint.config.mjs b/packages/express/eslint.config.mjs new file mode 100644 index 0000000..c6a5ff4 --- /dev/null +++ b/packages/express/eslint.config.mjs @@ -0,0 +1,17 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist', 'coverage'] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + languageOptions: { + ecmaVersion: 2023, + sourceType: 'module', + parserOptions: { + tsconfigRootDir: import.meta.dirname + } + } + } +); diff --git a/packages/express/package.json b/packages/express/package.json new file mode 100644 index 0000000..7ea63e4 --- /dev/null +++ b/packages/express/package.json @@ -0,0 +1,79 @@ +{ + "name": "@rsscloud/express", + "version": "0.0.0", + "description": "Express middleware for the rssCloud notification protocol — pleaseNotify, ping, and RPC2 front doors", + "license": "MIT", + "author": "Andrew Shell ", + "repository": { + "type": "git", + "url": "https://github.com/rsscloud/rsscloud-server.git", + "directory": "packages/express" + }, + "homepage": "https://github.com/rsscloud/rsscloud-server/tree/main/packages/express#readme", + "bugs": { + "url": "https://github.com/rsscloud/rsscloud-server/issues" + }, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "LICENSE.md", + "CHANGELOG.md" + ], + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=22" + }, + "sideEffects": false, + "peerDependencies": { + "express": "^4.16.0 || ^5.0.0" + }, + "dependencies": { + "@rsscloud/core": "workspace:*" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "clean": "rm -rf dist coverage", + "lint": "eslint src", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "coverage": "vitest run --coverage", + "check": "pnpm run typecheck && pnpm run lint && pnpm run test", + "prepack": "pnpm run build", + "prepublishOnly": "pnpm run build" + }, + "devDependencies": { + "@eslint/js": "^9.18.0", + "@types/express": "^4.17.21", + "@types/node": "^22.10.0", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^3.2.4", + "eslint": "^9.18.0", + "express": "^4.22.2", + "supertest": "^7.0.0", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "typescript-eslint": "^8.20.0", + "vitest": "^3.2.4" + } +} diff --git a/packages/express/src/client-address.test.ts b/packages/express/src/client-address.test.ts new file mode 100644 index 0000000..7b7d247 --- /dev/null +++ b/packages/express/src/client-address.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import type { Request } from 'express'; +import { resolveClientAddress } from './client-address.js'; + +function fakeReq(opts: { + forwarded?: string | string[]; + remote?: string; +}): Request { + const headers = + opts.forwarded === undefined ? {} : { 'x-forwarded-for': opts.forwarded }; + return { + headers, + socket: { remoteAddress: opts.remote } + } as unknown as Request; +} + +describe('resolveClientAddress', () => { + it('prefers the X-Forwarded-For header when present', () => { + const address = resolveClientAddress( + fakeReq({ forwarded: '203.0.113.5', remote: '10.0.0.1' }) + ); + expect(address).toBe('203.0.113.5'); + }); + + it('falls back to the socket remote address', () => { + const address = resolveClientAddress(fakeReq({ remote: '10.0.0.1' })); + expect(address).toBe('10.0.0.1'); + }); + + it('ignores a list-valued X-Forwarded-For header', () => { + const address = resolveClientAddress( + fakeReq({ forwarded: ['203.0.113.5', '198.51.100.1'], remote: '10.0.0.1' }) + ); + expect(address).toBe('10.0.0.1'); + }); + + it('returns an empty string when neither source is available', () => { + const address = resolveClientAddress(fakeReq({})); + expect(address).toBe(''); + }); +}); diff --git a/packages/express/src/client-address.ts b/packages/express/src/client-address.ts new file mode 100644 index 0000000..10f8e99 --- /dev/null +++ b/packages/express/src/client-address.ts @@ -0,0 +1,13 @@ +import type { Request } from 'express'; + +/** + * Resolve the caller's address the way the rssCloud server always has: trust an + * `X-Forwarded-For` header when present, otherwise fall back to the socket's + * remote address. The REST and XML-RPC dispatchers fold this into a callback URL + * when a subscriber omits an explicit `domain`. + */ +export function resolveClientAddress(req: Request): string { + const forwarded = req.headers['x-forwarded-for']; + const candidate = typeof forwarded === 'string' ? forwarded : ''; + return candidate || req.socket.remoteAddress || ''; +} diff --git a/packages/express/src/index.test.ts b/packages/express/src/index.test.ts new file mode 100644 index 0000000..6c1cea5 --- /dev/null +++ b/packages/express/src/index.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest'; +import * as api from './index.js'; + +describe('@rsscloud/express public API', () => { + it('exports the three endpoint middleware factories', () => { + expect(typeof api.pleaseNotify).toBe('function'); + expect(typeof api.ping).toBe('function'); + expect(typeof api.rpc2).toBe('function'); + }); +}); diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts new file mode 100644 index 0000000..3428d8a --- /dev/null +++ b/packages/express/src/index.ts @@ -0,0 +1,11 @@ +export const version = '0.0.0'; + +export { + pleaseNotify, + ping, + type RestMiddlewareOptions +} from './rest-middleware.js'; +export { + rpc2, + type XmlRpcMiddlewareOptions +} from './xml-rpc-middleware.js'; diff --git a/packages/express/src/rest-middleware.test.ts b/packages/express/src/rest-middleware.test.ts new file mode 100644 index 0000000..fcbc05e --- /dev/null +++ b/packages/express/src/rest-middleware.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import type { + PingRequest, + RssCloudCore, + SubscribeRequest +} from '@rsscloud/core'; +import { ping, pleaseNotify } from './rest-middleware.js'; + +interface FakeCore { + core: Pick; + pingCalls: PingRequest[]; + subscribeCalls: SubscribeRequest[]; +} + +function fakeCore(): FakeCore { + const pingCalls: PingRequest[] = []; + const subscribeCalls: SubscribeRequest[] = []; + const core: Pick = { + async ping(req) { + pingCalls.push(req); + return { success: true, message: 'Thanks for the ping.' }; + }, + async subscribe(req) { + subscribeCalls.push(req); + return { success: true, message: 'ok' }; + } + }; + return { core, pingCalls, subscribeCalls }; +} + +describe('ping middleware', () => { + it('renders a JSON success envelope and maps the url to a ping request', async () => { + const fake = fakeCore(); + const app = express(); + app.post('/ping', ping({ core: fake.core })); + + const res = await request(app) + .post('/ping') + .type('form') + .set('Accept', 'application/json') + .send({ url: 'http://feed.example/rss' }); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('application/json'); + expect(res.body).toEqual({ + success: true, + msg: 'Thanks for the ping.' + }); + expect(fake.pingCalls).toEqual([ + { resourceUrl: 'http://feed.example/rss' } + ]); + }); + + it('renders an XML envelope when the caller accepts xml', async () => { + const fake = fakeCore(); + const app = express(); + app.post('/ping', ping({ core: fake.core })); + + const res = await request(app) + .post('/ping') + .type('form') + .set('Accept', 'application/xml') + .send({ url: 'http://feed.example/rss' }); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/xml'); + expect(res.text).toContain(' { + const fake = fakeCore(); + const app = express(); + app.post('/ping', ping({ core: fake.core })); + + const res = await request(app) + .post('/ping') + .type('form') + .send({ url: 'http://feed.example/rss' }); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/xml'); + }); + + it('responds 406 when the caller accepts neither xml nor json', async () => { + const fake = fakeCore(); + const app = express(); + app.post('/ping', ping({ core: fake.core })); + + const res = await request(app) + .post('/ping') + .type('form') + .set('Accept', 'text/plain') + .send({ url: 'http://feed.example/rss' }); + + expect(res.status).toBe(406); + expect(res.text).toBe('Not Acceptable'); + }); +}); + +describe('pleaseNotify middleware', () => { + it('maps the body into a subscribe request using an explicit domain', async () => { + const fake = fakeCore(); + const app = express(); + app.post('/pleaseNotify', pleaseNotify({ core: fake.core })); + + const res = await request(app) + .post('/pleaseNotify') + .type('form') + .set('Accept', 'application/json') + .send({ + url: 'http://feed.example/rss', + port: '5337', + path: '/notify', + protocol: 'http-post', + domain: 'example.com' + }); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('application/json'); + expect(res.body.success).toBe(true); + expect(fake.subscribeCalls).toEqual([ + { + resourceUrls: ['http://feed.example/rss'], + callbackUrl: 'http://example.com:5337/notify', + protocol: 'http-post', + diffDomain: true + } + ]); + }); + + it('builds the callback host from X-Forwarded-For when no domain is given', async () => { + const fake = fakeCore(); + const app = express(); + app.post('/pleaseNotify', pleaseNotify({ core: fake.core })); + + const res = await request(app) + .post('/pleaseNotify') + .type('form') + .set('Accept', 'application/json') + .set('X-Forwarded-For', '203.0.113.5') + .send({ + url: 'http://feed.example/rss', + port: '5337', + path: '/notify', + protocol: 'http-post' + }); + + expect(res.status).toBe(200); + expect(fake.subscribeCalls).toEqual([ + { + resourceUrls: ['http://feed.example/rss'], + callbackUrl: 'http://203.0.113.5:5337/notify', + protocol: 'http-post', + diffDomain: false + } + ]); + }); + + it('renders a notifyResult XML envelope when the caller accepts xml', async () => { + const fake = fakeCore(); + const app = express(); + app.post('/pleaseNotify', pleaseNotify({ core: fake.core })); + + const res = await request(app) + .post('/pleaseNotify') + .type('form') + .set('Accept', 'application/xml') + .send({ + url: 'http://feed.example/rss', + port: '5337', + path: '/notify', + protocol: 'http-post', + domain: 'example.com' + }); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/xml'); + expect(res.text).toContain('; +} + +/** Parses `application/x-www-form-urlencoded` bodies for the REST front doors. */ +const urlencodedParser = express.urlencoded({ extended: false }); + +/** Negotiate the response format from the `Accept` header (`null` → 406). */ +function negotiateFormat(req: Request): RestResponseFormat { + return (req.accepts('xml', 'json') || null) as RestResponseFormat; +} + +/** + * Build the handler stack for a REST front door: parse the urlencoded body, + * resolve the request context, hand it to one of the dispatcher's use cases, + * and copy the rendered response onto the Express reply. `pleaseNotify` and + * `ping` share the same shape and differ only in which use case they invoke. + */ +function restMiddleware( + options: RestMiddlewareOptions, + select: (dispatcher: RestDispatcher) => RestDispatcher['ping'] +): RequestHandler[] { + const dispatch = select(createRestDispatcher({ core: options.core })); + const handler: RequestHandler = async (req, res) => { + const result = await dispatch(req.body as Record, { + clientAddress: resolveClientAddress(req), + format: negotiateFormat(req) + }); + res.status(result.status) + .set('Content-Type', result.contentType) + .send(result.body); + }; + return [urlencodedParser, handler]; +} + +/** Express handler stack for the rssCloud REST `pleaseNotify`. */ +export function pleaseNotify(options: RestMiddlewareOptions): RequestHandler[] { + return restMiddleware(options, (dispatcher) => dispatcher.pleaseNotify); +} + +/** Express handler stack for the rssCloud REST `ping`. */ +export function ping(options: RestMiddlewareOptions): RequestHandler[] { + return restMiddleware(options, (dispatcher) => dispatcher.ping); +} diff --git a/packages/express/src/xml-rpc-middleware.test.ts b/packages/express/src/xml-rpc-middleware.test.ts new file mode 100644 index 0000000..a2f2f13 --- /dev/null +++ b/packages/express/src/xml-rpc-middleware.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import type { + PingRequest, + RssCloudCore, + SubscribeRequest +} from '@rsscloud/core'; +import { rpc2 } from './xml-rpc-middleware.js'; + +interface FakeCore { + core: Pick; + pingCalls: PingRequest[]; + subscribeCalls: SubscribeRequest[]; +} + +function fakeCore(): FakeCore { + const pingCalls: PingRequest[] = []; + const subscribeCalls: SubscribeRequest[] = []; + const core: Pick = { + async ping(req) { + pingCalls.push(req); + return { success: true, message: 'Thanks for the ping.' }; + }, + async subscribe(req) { + subscribeCalls.push(req); + return { success: true, message: 'ok' }; + } + }; + return { core, pingCalls, subscribeCalls }; +} + +const pingCall = + '' + + 'rssCloud.ping' + + 'http://feed.example/rss' + + ''; + +const pleaseNotifyCall = + '' + + 'rssCloud.pleaseNotify' + + 'notify' + + '5337' + + '/notify' + + 'http-post' + + 'http://feed.example/rss' + + ''; + +describe('rpc2 middleware', () => { + it('dispatches a methodCall and renders the methodResponse as text/xml', async () => { + const fake = fakeCore(); + const app = express(); + app.post('/RPC2', rpc2({ core: fake.core })); + + const res = await request(app) + .post('/RPC2') + .set('Content-Type', 'text/xml') + .send(pingCall); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/xml'); + expect(res.text).toContain(''); + expect(res.text).toContain('1'); + expect(fake.pingCalls).toEqual([ + { resourceUrl: 'http://feed.example/rss' } + ]); + }); + + it('passes the resolved client address into the dispatch context', async () => { + const fake = fakeCore(); + const app = express(); + app.post('/RPC2', rpc2({ core: fake.core })); + + const res = await request(app) + .post('/RPC2') + .set('Content-Type', 'text/xml') + .set('X-Forwarded-For', '203.0.113.5') + .send(pleaseNotifyCall); + + expect(res.status).toBe(200); + expect(fake.subscribeCalls).toEqual([ + { + resourceUrls: ['http://feed.example/rss'], + callbackUrl: 'http://203.0.113.5:5337/notify', + protocol: 'http-post', + diffDomain: false + } + ]); + }); + + it('responds 406 when the caller does not accept xml', async () => { + const fake = fakeCore(); + const app = express(); + app.post('/RPC2', rpc2({ core: fake.core })); + + const res = await request(app) + .post('/RPC2') + .set('Content-Type', 'text/xml') + .set('Accept', 'application/json') + .send(pingCall); + + expect(res.status).toBe(406); + expect(res.text).toBe('Not Acceptable'); + expect(fake.pingCalls).toEqual([]); + }); + + it('dispatches an empty document when the body is not xml', async () => { + const fake = fakeCore(); + const app = express(); + app.post('/RPC2', rpc2({ core: fake.core })); + + const res = await request(app) + .post('/RPC2') + .set('Content-Type', 'text/plain') + .send(pingCall); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/xml'); + expect(res.text).toContain('faultString'); + expect(fake.pingCalls).toEqual([]); + }); +}); diff --git a/packages/express/src/xml-rpc-middleware.ts b/packages/express/src/xml-rpc-middleware.ts new file mode 100644 index 0000000..82eb032 --- /dev/null +++ b/packages/express/src/xml-rpc-middleware.ts @@ -0,0 +1,30 @@ +import express, { type RequestHandler } from 'express'; +import { createXmlRpcDispatcher, type RssCloudCore } from '@rsscloud/core'; +import { resolveClientAddress } from './client-address.js'; + +/** Construction-time dependencies for the XML-RPC front-door middleware. */ +export interface XmlRpcMiddlewareOptions { + core: Pick; +} + +/** Parses any XML content-type body into the raw string the dispatcher expects. */ +const xmlTextParser = express.text({ type: '*/xml' }); + +/** Express handler stack for the rssCloud XML-RPC `/RPC2` front door. */ +export function rpc2(options: XmlRpcMiddlewareOptions): RequestHandler[] { + const dispatcher = createXmlRpcDispatcher({ core: options.core }); + const handler: RequestHandler = async (req, res) => { + if (!req.accepts('xml')) { + res.status(406) + .set('Content-Type', 'text/plain') + .send('Not Acceptable'); + return; + } + const xmlBody = typeof req.body === 'string' ? req.body : ''; + const xml = await dispatcher.dispatch(xmlBody, { + clientAddress: resolveClientAddress(req) + }); + res.status(200).set('Content-Type', 'text/xml').send(xml); + }; + return [xmlTextParser, handler]; +} diff --git a/packages/express/tsconfig.build.json b/packages/express/tsconfig.build.json new file mode 100644 index 0000000..9d88b88 --- /dev/null +++ b/packages/express/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "coverage", "node_modules", "src/**/*.test.ts"] +} diff --git a/packages/express/tsconfig.json b/packages/express/tsconfig.json new file mode 100644 index 0000000..cd657bd --- /dev/null +++ b/packages/express/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "moduleDetection": "force", + "isolatedModules": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts", "*.config.ts"], + "exclude": ["dist", "coverage", "node_modules"] +} diff --git a/packages/express/tsup.config.ts b/packages/express/tsup.config.ts new file mode 100644 index 0000000..0f29864 --- /dev/null +++ b/packages/express/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + treeshake: true, + splitting: false, + target: 'node22', + tsconfig: './tsconfig.build.json' +}); diff --git a/packages/express/vitest.config.ts b/packages/express/vitest.config.ts new file mode 100644 index 0000000..f571462 --- /dev/null +++ b/packages/express/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts'], + thresholds: { + lines: 100, + functions: 100, + branches: 100, + statements: 100 + } + } + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5e545a..d278d5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,49 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1) + packages/express: + dependencies: + '@rsscloud/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@eslint/js': + specifier: ^9.18.0 + version: 9.39.4 + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/node': + specifier: ^22.10.0 + version: 22.19.19 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1)) + eslint: + specifier: ^9.18.0 + version: 9.39.4(jiti@2.6.1) + express: + specifier: ^4.22.2 + version: 4.22.2 + supertest: + specifier: ^7.0.0 + version: 7.2.2 + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.15)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + typescript-eslint: + specifier: ^8.20.0 + version: 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1) + packages: '@ampproject/remapping@2.3.0': @@ -817,12 +860,18 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@4.3.20': resolution: {integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} @@ -838,18 +887,54 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@4.19.8': + resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} + + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/node@22.19.19': resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} '@types/node@25.8.0': resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==} + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + '@types/superagent@4.1.13': resolution: {integrity: sha512-YIGelp3ZyMiH0/A09PMAORO0EBGlF5xIKfDpK74wdYvWUs2o96b5CItJcWPdH409b7SAXIIG6p8NdU/4U2Maww==} + '@types/superagent@8.1.10': + resolution: {integrity: sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/xml2js@0.4.14': resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} @@ -3235,6 +3320,11 @@ snapshots: tslib: 2.8.1 optional: true + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.19.19 + '@types/chai@4.3.20': {} '@types/chai@5.2.3': @@ -3242,6 +3332,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.19.19 + '@types/cookiejar@2.1.5': {} '@types/deep-eql@4.0.2': {} @@ -3252,8 +3346,28 @@ snapshots: '@types/estree@1.0.9': {} + '@types/express-serve-static-core@4.19.8': + dependencies: + '@types/node': 22.19.19 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.8 + '@types/qs': 6.15.1 + '@types/serve-static': 1.15.10 + + '@types/http-errors@2.0.5': {} + '@types/json-schema@7.0.15': {} + '@types/methods@1.1.4': {} + + '@types/mime@1.3.5': {} + '@types/node@22.19.19': dependencies: undici-types: 6.21.0 @@ -3262,11 +3376,42 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 22.19.19 + + '@types/send@1.2.1': + dependencies: + '@types/node': 22.19.19 + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.19.19 + '@types/send': 0.17.6 + '@types/superagent@4.1.13': dependencies: '@types/cookiejar': 2.1.5 '@types/node': 22.19.19 + '@types/superagent@8.1.10': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 22.19.19 + form-data: 4.0.5 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.10 + '@types/xml2js@0.4.14': dependencies: '@types/node': 22.19.19 From a49103c50bd8b2d1d4b278274e45ea2d8f4bbcfa Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 11:15:10 -0500 Subject: [PATCH 27/90] chore(core): pin tsconfigRootDir in eslint config typescript-eslint 8.59 infers tsconfigRootDir when it is unset and errors when multiple candidate roots are present. Adding the @rsscloud/express package introduced a second package config, so pin the root explicitly to silence the parser error in editors that lint from the repo root. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/eslint.config.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/eslint.config.mjs b/packages/core/eslint.config.mjs index 04a8101..c6a5ff4 100644 --- a/packages/core/eslint.config.mjs +++ b/packages/core/eslint.config.mjs @@ -8,7 +8,10 @@ export default tseslint.config( { languageOptions: { ecmaVersion: 2023, - sourceType: 'module' + sourceType: 'module', + parserOptions: { + tsconfigRootDir: import.meta.dirname + } } } ); From 98248e3250002dd8543317c7632315545dd69fae Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 11:49:16 -0500 Subject: [PATCH 28/90] build(server): depend on @rsscloud/core + @rsscloud/express, build them in image Add the two workspace packages as dependencies of apps/server and rework the Dockerfile into a multi-stage build that compiles core -> express to their CJS dist before the slim --prod runtime copies the built dist in, so require() resolves at runtime. No runtime change yet: app.js still uses the legacy json-store and services. This is slice 1a (plumbing) of migrating the protocol endpoints onto the @rsscloud/express middleware; the full Docker e2e suite stays green (134 passing). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/Dockerfile | 14 +++++++++++++- apps/server/package.json | 2 ++ pnpm-lock.yaml | 6 ++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 6fdd677..3bb2c35 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -8,14 +8,26 @@ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ COPY apps/server/package.json apps/server/ COPY apps/e2e/package.json apps/e2e/ COPY packages/core/package.json packages/core/ +COPY packages/express/package.json packages/express/ + +# Build the workspace packages (TS → CJS/ESM dist) the server now consumes. +FROM base AS build + +COPY packages/core packages/core +COPY packages/express packages/express +RUN pnpm install --frozen-lockfile --filter "@rsscloud/express..." +RUN pnpm --filter @rsscloud/core run build \ + && pnpm --filter @rsscloud/express run build FROM base AS dependencies -RUN pnpm install --frozen-lockfile --filter @rsscloud/server --prod --ignore-scripts +RUN pnpm install --frozen-lockfile --filter "@rsscloud/server..." --prod --ignore-scripts FROM dependencies AS runtime COPY apps/server apps/server +COPY --from=build /app/packages/core/dist packages/core/dist +COPY --from=build /app/packages/express/dist packages/express/dist WORKDIR /app/apps/server diff --git a/apps/server/package.json b/apps/server/package.json index cad1ea6..2cb1298 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,6 +15,8 @@ "author": "Andrew Shell ", "license": "MIT", "dependencies": { + "@rsscloud/core": "workspace:*", + "@rsscloud/express": "workspace:*", "body-parser": "^2.2.2", "cors": "^2.8.6", "dayjs": "^1.11.20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d278d5c..2a5a666 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,12 @@ importers: apps/server: dependencies: + '@rsscloud/core': + specifier: workspace:* + version: link:../../packages/core + '@rsscloud/express': + specifier: workspace:* + version: link:../../packages/express body-parser: specifier: ^2.2.2 version: 2.2.2 From 58d6f638a4882beed43a18af74af6cdea24a6f51 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 12:39:49 -0500 Subject: [PATCH 29/90] refactor(server): bridge core onto the legacy store via an adapter (slice 1b) Add a composition root (apps/server/core.js) constructing @rsscloud/core with the REST + XML-RPC delivery plugins and a Store adapter over the legacy synchronous json-store, plus a core.events -> websocket bridge for /viewLog. The adapter (services/core-store-adapter.js) maps the legacy on-disk shape to and from core's model (mirroring file-store.ts), so core and the not-yet- migrated legacy services + /test/* share one in-memory store with no changes to either. createFileStore stays unused until the endgame swap. core is wired but not yet on any request path; slice 2 (/ping) is its first live exercise. Adapter + bridge unit-tested with node:test; full Docker e2e remains green (134 passing). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/app.js | 8 +- apps/server/core.js | 39 +++ apps/server/package.json | 1 + apps/server/services/core-event-bridge.js | 40 +++ .../server/services/core-event-bridge.test.js | 69 ++++++ apps/server/services/core-store-adapter.js | 154 ++++++++++++ .../services/core-store-adapter.test.js | 227 ++++++++++++++++++ 7 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 apps/server/core.js create mode 100644 apps/server/services/core-event-bridge.js create mode 100644 apps/server/services/core-event-bridge.test.js create mode 100644 apps/server/services/core-store-adapter.js create mode 100644 apps/server/services/core-store-adapter.test.js diff --git a/apps/server/app.js b/apps/server/app.js index fd21254..6ee0b55 100644 --- a/apps/server/app.js +++ b/apps/server/app.js @@ -9,7 +9,9 @@ const config = require('./config'), stats = require('./services/stats'), morgan = require('morgan'), removeExpiredSubscriptions = require('./services/remove-expired-subscriptions'), - websocket = require('./services/websocket'); + websocket = require('./services/websocket'), + { events: coreEvents } = require('./core'), + bridgeCoreEvents = require('./services/core-event-bridge'); let app, hbs, server, dayjs; @@ -144,6 +146,10 @@ async function startServer() { // Initialize WebSocket server for /wsLog websocket.initialize(server); + // Bridge core's events onto /wsLog so /viewLog keeps working as + // endpoints migrate onto @rsscloud/core. + bridgeCoreEvents(coreEvents, websocket); + console.log( `Listening at http://${app.locals.host}:${app.locals.port}` ); diff --git a/apps/server/core.js b/apps/server/core.js new file mode 100644 index 0000000..301a972 --- /dev/null +++ b/apps/server/core.js @@ -0,0 +1,39 @@ +// Composition root for @rsscloud/core. Builds the protocol-neutral engine the +// server's front doors will run on, wiring server config + the REST/XML-RPC +// delivery plugins to a Store. +// +// During the migration (PLAN: "Endpoint migration onto @rsscloud/express") the +// Store is an adapter over the legacy synchronous json-store, so core and the +// not-yet-migrated legacy services + /test/* API share one in-memory store. +// At the end of the migration this becomes `await createFileStore({ filePath })` +// and the adapter + json-store are deleted. + +const { + createRssCloudCore, + createRestProtocolPlugin, + createXmlRpcProtocolPlugin, + resolveConfig +} = require('@rsscloud/core'); +const config = require('./config'); +const jsonStore = require('./services/json-store'); +const createJsonStoreAdapter = require('./services/core-store-adapter'); + +const coreConfig = resolveConfig({ + minSecsBetweenPings: config.minSecsBetweenPings, + ctSecsResourceExpire: config.ctSecsResourceExpire, + maxConsecutiveErrors: config.maxConsecutiveErrors, + maxResourceSize: config.maxResourceSize, + requestTimeoutMs: config.requestTimeout, + feedsChangedWindowDays: config.feedsChangedWindowDays +}); + +const store = createJsonStoreAdapter(jsonStore); + +const plugins = [ + createRestProtocolPlugin({ requestTimeoutMs: config.requestTimeout }), + createXmlRpcProtocolPlugin({ requestTimeoutMs: config.requestTimeout }) +]; + +const core = createRssCloudCore({ store, plugins, config: coreConfig }); + +module.exports = { core, events: core.events, store }; diff --git a/apps/server/package.json b/apps/server/package.json index 2cb1298..189abd5 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -7,6 +7,7 @@ "start": "node --use_strict app.js", "dev": "nodemon --use_strict --ignore data/ ./app.js", "client": "nodemon --use_strict --ignore data/ ./client.js", + "test": "node --test services/*.test.js", "lint": "eslint --fix controllers/ services/ *.js" }, "engines": { diff --git a/apps/server/services/core-event-bridge.js b/apps/server/services/core-event-bridge.js new file mode 100644 index 0000000..182fd46 --- /dev/null +++ b/apps/server/services/core-event-bridge.js @@ -0,0 +1,40 @@ +// Bridges core's observability events onto the /wsLog websocket so /viewLog keeps +// working once endpoints run through @rsscloud/core. Per PLAN #4 we render core's +// events as-is: no enrichment, request headers dropped, and per-event timing taken +// from core's `durationMs` where it carries one (only `ping` does today). + +const CORE_EVENTS = [ + 'ping', + 'subscribe', + 'resourceChanged', + 'notify', + 'notifyFailed', + 'error' +]; + +// The `error` payload carries an Error instance, which would JSON-serialize to +// `{}`; surface its scope and message instead. Other payloads broadcast as-is. +function toData(eventtype, payload) { + if (eventtype === 'error') { + return { scope: payload.scope, error: payload.error?.message }; + } + return payload; +} + +function bridgeCoreEvents(events, websocket, now = () => new Date()) { + for (const eventtype of CORE_EVENTS) { + events.on(eventtype, payload => { + websocket.broadcast({ + eventtype, + data: toData(eventtype, payload), + secs: + typeof payload.durationMs === 'number' + ? payload.durationMs / 1000 + : 0, + time: now() + }); + }); + } +} + +module.exports = bridgeCoreEvents; diff --git a/apps/server/services/core-event-bridge.test.js b/apps/server/services/core-event-bridge.test.js new file mode 100644 index 0000000..39ccaff --- /dev/null +++ b/apps/server/services/core-event-bridge.test.js @@ -0,0 +1,69 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createEventBus } = require('@rsscloud/core'); +const bridgeCoreEvents = require('./core-event-bridge'); + +function fakeWebsocket() { + const sent = []; + return { sent, broadcast: message => sent.push(message) }; +} + +const fixedNow = () => new Date('2026-06-10T00:00:00.000Z'); + +test('broadcasts a ping event with timing from durationMs', () => { + const events = createEventBus(); + const ws = fakeWebsocket(); + bridgeCoreEvents(events, ws, fixedNow); + + events.emit('ping', { + resourceUrl: 'https://example.com/feed.xml', + changed: true, + hash: 'h', + size: 10, + durationMs: 1500 + }); + + assert.deepStrictEqual(ws.sent, [ + { + eventtype: 'ping', + data: { + resourceUrl: 'https://example.com/feed.xml', + changed: true, + hash: 'h', + size: 10, + durationMs: 1500 + }, + secs: 1.5, + time: new Date('2026-06-10T00:00:00.000Z') + } + ]); +}); + +test('broadcasts an event without timing as secs 0', () => { + const events = createEventBus(); + const ws = fakeWebsocket(); + bridgeCoreEvents(events, ws, fixedNow); + + events.emit('notify', { + callbackUrl: 'https://aggregator.example/cb', + protocol: 'https-post', + resourceUrl: 'https://example.com/feed.xml' + }); + + assert.equal(ws.sent.length, 1); + assert.equal(ws.sent[0].eventtype, 'notify'); + assert.equal(ws.sent[0].secs, 0); +}); + +test('flattens an error event to its scope and message', () => { + const events = createEventBus(); + const ws = fakeWebsocket(); + bridgeCoreEvents(events, ws, fixedNow); + + events.emit('error', { + scope: 'ping', + error: new Error('boom') + }); + + assert.deepStrictEqual(ws.sent[0].data, { scope: 'ping', error: 'boom' }); +}); diff --git a/apps/server/services/core-store-adapter.js b/apps/server/services/core-store-adapter.js new file mode 100644 index 0000000..27ce10e --- /dev/null +++ b/apps/server/services/core-store-adapter.js @@ -0,0 +1,154 @@ +// Bridges core's async `Store` interface to the legacy synchronous `json-store` +// during the migration onto @rsscloud/core. core writes/reads through this +// adapter while the legacy services and the /test/* API keep using json-store +// directly, so both share one in-memory store. The legacy<->core shape mapping +// here mirrors packages/core/src/store/file-store.ts (the eventual replacement); +// keep them in sync until json-store is retired and core uses createFileStore. + +function toCoreFeed(raw) { + const feed = {}; + if (raw.feedType != null) feed.type = raw.feedType; + if (raw.feedTitle != null) feed.title = raw.feedTitle; + if (raw.feedDescription != null) feed.description = raw.feedDescription; + if (raw.feedHtmlUrl != null) feed.htmlUrl = raw.feedHtmlUrl; + if (raw.feedLanguage != null) feed.language = raw.feedLanguage; + return Object.keys(feed).length > 0 ? feed : undefined; +} + +function toCoreResource(feedUrl, raw) { + // A missing entry or a resource with no real fields (json-store returns + // `{ _id }` for an empty `{}` resource) is "no resource". + if (raw == null) return null; + if (Object.keys(raw).filter(key => key !== '_id').length === 0) return null; + const resource = { + url: feedUrl, + lastHash: raw.lastHash ?? '', + lastSize: raw.lastSize ?? 0, + ctChecks: raw.ctChecks ?? 0, + whenLastCheck: new Date(raw.whenLastCheck ?? 0), + ctUpdates: raw.ctUpdates ?? 0, + whenLastUpdate: new Date(raw.whenLastUpdate ?? 0) + }; + const feed = toCoreFeed(raw); + if (feed !== undefined) resource.feed = feed; + return resource; +} + +function toLegacyResource(resource) { + const out = { + lastSize: resource.lastSize, + lastHash: resource.lastHash, + ctChecks: resource.ctChecks, + whenLastCheck: resource.whenLastCheck.toISOString(), + ctUpdates: resource.ctUpdates, + whenLastUpdate: resource.whenLastUpdate.toISOString() + }; + const feed = resource.feed; + if (feed !== undefined) { + if (feed.type != null) out.feedType = feed.type; + if (feed.title != null) out.feedTitle = feed.title; + if (feed.description != null) out.feedDescription = feed.description; + if (feed.htmlUrl != null) out.feedHtmlUrl = feed.htmlUrl; + if (feed.language != null) out.feedLanguage = feed.language; + } + return out; +} + +const EPOCH_ISO = new Date(0).toISOString(); + +// Epoch ("never happened" on disk) maps to `null` in the core model. +function toNullableDate(value) { + const date = new Date(value ?? 0); + return date.getTime() === 0 ? null : date; +} + +// `null` ("never") serializes back to the epoch string the legacy reader uses. +function fromNullableDate(value) { + return value === null ? EPOCH_ISO : value.toISOString(); +} + +function toCoreSubscription(raw) { + const whenExpires = new Date(raw.whenExpires ?? 0); + const subscription = { + url: raw.url, + protocol: raw.protocol, + ctUpdates: raw.ctUpdates ?? 0, + ctErrors: raw.ctErrors ?? 0, + ctConsecutiveErrors: raw.ctConsecutiveErrors ?? 0, + // Legacy records carry no creation time; synthesize from expiry. + whenCreated: + raw.whenCreated != null ? new Date(raw.whenCreated) : whenExpires, + whenLastUpdate: toNullableDate(raw.whenLastUpdate), + whenLastError: toNullableDate(raw.whenLastError), + whenExpires + }; + if (typeof raw.notifyProcedure === 'string') { + subscription.notifyProcedure = raw.notifyProcedure; + } + if (raw.details !== undefined) { + subscription.details = raw.details; + } + return subscription; +} + +function toLegacySubscription(subscription) { + const out = { + ctUpdates: subscription.ctUpdates, + whenLastUpdate: fromNullableDate(subscription.whenLastUpdate), + ctErrors: subscription.ctErrors, + ctConsecutiveErrors: subscription.ctConsecutiveErrors, + whenLastError: fromNullableDate(subscription.whenLastError), + whenExpires: subscription.whenExpires.toISOString(), + url: subscription.url, + // REST subs carry no procedure; the legacy shape records that as `false`. + notifyProcedure: subscription.notifyProcedure ?? false, + protocol: subscription.protocol + }; + if (subscription.details !== undefined) { + out.details = subscription.details; + } + return out; +} + +function createJsonStoreAdapter(jsonStore) { + return { + async getResource(feedUrl) { + return toCoreResource(feedUrl, jsonStore.getResource(feedUrl)); + }, + + async putResource(feedUrl, resource) { + jsonStore.setResource(feedUrl, toLegacyResource(resource)); + }, + + async getSubscriptions(feedUrl) { + return jsonStore + .getSubscriptions(feedUrl) + .pleaseNotify.map(toCoreSubscription); + }, + + async putSubscriptions(feedUrl, subscriptions) { + jsonStore.setSubscriptions( + feedUrl, + subscriptions.map(toLegacySubscription) + ); + }, + + async list() { + return Object.entries(jsonStore.getData()).map( + ([feedUrl, entry]) => ({ + feedUrl, + resource: toCoreResource(feedUrl, entry.resource), + subscriptions: (entry.subscribers ?? []).map( + toCoreSubscription + ) + }) + ); + }, + + async remove(feedUrl) { + jsonStore.removeEntry(feedUrl); + } + }; +} + +module.exports = createJsonStoreAdapter; diff --git a/apps/server/services/core-store-adapter.test.js b/apps/server/services/core-store-adapter.test.js new file mode 100644 index 0000000..0ff91dd --- /dev/null +++ b/apps/server/services/core-store-adapter.test.js @@ -0,0 +1,227 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const jsonStore = require('./json-store'); +const createJsonStoreAdapter = require('./core-store-adapter'); + +test('round-trips a resource through the legacy store', async() => { + jsonStore.clear(); + const store = createJsonStoreAdapter(jsonStore); + + const resource = { + url: 'https://example.com/feed.xml', + lastHash: 'abc123', + lastSize: 42, + ctChecks: 3, + whenLastCheck: new Date('2026-06-01T12:00:00.000Z'), + ctUpdates: 2, + whenLastUpdate: new Date('2026-06-02T08:30:00.000Z'), + feed: { + type: 'rss', + title: 'Example', + description: 'An example feed', + htmlUrl: 'https://example.com', + language: 'en' + } + }; + + await store.putResource(resource.url, resource); + + assert.deepStrictEqual(await store.getResource(resource.url), resource); +}); + +test('getResource returns null for an unknown feed', async() => { + jsonStore.clear(); + const store = createJsonStoreAdapter(jsonStore); + + assert.equal(await store.getResource('https://example.com/unknown'), null); +}); + +test('getResource returns null for a subscriptions-only entry', async() => { + jsonStore.clear(); + const store = createJsonStoreAdapter(jsonStore); + + // setSubscriptions creates an entry with an empty `{}` resource. + jsonStore.setSubscriptions('https://example.com/feed.xml', []); + + assert.equal( + await store.getResource('https://example.com/feed.xml'), + null + ); +}); + +test('round-trips a REST subscription through the legacy store', async() => { + jsonStore.clear(); + const store = createJsonStoreAdapter(jsonStore); + const feedUrl = 'https://example.com/feed.xml'; + + const subscription = { + url: 'https://aggregator.example/callback', + protocol: 'https-post', + ctUpdates: 1, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: new Date('2026-06-01T00:00:00.000Z'), + whenLastUpdate: new Date('2026-06-02T00:00:00.000Z'), + whenLastError: null, + whenExpires: new Date('2026-06-03T00:00:00.000Z') + }; + + await store.putSubscriptions(feedUrl, [subscription]); + + assert.deepStrictEqual(await store.getSubscriptions(feedUrl), [ + { + ...subscription, + // REST subs carry no notifyProcedure (stored as `false`, dropped on read). + // whenCreated isn't persisted; it's synthesized from whenExpires. + whenCreated: new Date('2026-06-03T00:00:00.000Z') + } + ]); +}); + +test('getSubscriptions returns an empty array for an unknown feed', async() => { + jsonStore.clear(); + const store = createJsonStoreAdapter(jsonStore); + + assert.deepStrictEqual( + await store.getSubscriptions('https://example.com/unknown'), + [] + ); +}); + +test('round-trips an XML-RPC subscription, preserving procedure and details', async() => { + jsonStore.clear(); + const store = createJsonStoreAdapter(jsonStore); + const feedUrl = 'https://example.com/feed.xml'; + + const subscription = { + url: 'https://aggregator.example/rpc', + protocol: 'xml-rpc', + notifyProcedure: 'river.feedUpdated', + ctUpdates: 5, + ctErrors: 2, + ctConsecutiveErrors: 1, + whenCreated: new Date('2026-06-01T00:00:00.000Z'), + whenLastUpdate: new Date('2026-06-02T00:00:00.000Z'), + whenLastError: new Date('2026-06-02T06:00:00.000Z'), + whenExpires: new Date('2026-06-03T00:00:00.000Z'), + details: { secret: 's3cret', leaseSeconds: 86400 } + }; + + await store.putSubscriptions(feedUrl, [subscription]); + + assert.deepStrictEqual(await store.getSubscriptions(feedUrl), [ + { ...subscription, whenCreated: new Date('2026-06-03T00:00:00.000Z') } + ]); +}); + +test('list returns every tracked feed as a core FeedEntry', async() => { + jsonStore.clear(); + const store = createJsonStoreAdapter(jsonStore); + + const resource = { + url: 'https://example.com/a.xml', + lastHash: 'h', + lastSize: 10, + ctChecks: 1, + whenLastCheck: new Date('2026-06-01T00:00:00.000Z'), + ctUpdates: 1, + whenLastUpdate: new Date('2026-06-01T00:00:00.000Z') + }; + const subscription = { + url: 'https://aggregator.example/cb', + protocol: 'https-post', + ctUpdates: 0, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: new Date('2026-06-03T00:00:00.000Z'), + whenLastUpdate: null, + whenLastError: null, + whenExpires: new Date('2026-06-03T00:00:00.000Z') + }; + + await store.putResource(resource.url, resource); + await store.putSubscriptions(resource.url, [subscription]); + // A subscriptions-only feed: resource maps to null. + jsonStore.setSubscriptions('https://example.com/b.xml', []); + + assert.deepStrictEqual(await store.list(), [ + { feedUrl: resource.url, resource, subscriptions: [subscription] }, + { + feedUrl: 'https://example.com/b.xml', + resource: null, + subscriptions: [] + } + ]); +}); + +test('remove deletes the feed entry entirely', async() => { + jsonStore.clear(); + const store = createJsonStoreAdapter(jsonStore); + const feedUrl = 'https://example.com/feed.xml'; + + jsonStore.setSubscriptions(feedUrl, []); + await store.remove(feedUrl); + + assert.deepStrictEqual(await store.list(), []); + assert.equal(await store.getResource(feedUrl), null); +}); + +test('core writes surface in json-store in the legacy on-disk shape', async() => { + jsonStore.clear(); + const store = createJsonStoreAdapter(jsonStore); + const feedUrl = 'https://example.com/feed.xml'; + + await store.putResource(feedUrl, { + url: feedUrl, + lastHash: 'h', + lastSize: 5, + ctChecks: 1, + whenLastCheck: new Date('2026-06-01T00:00:00.000Z'), + ctUpdates: 1, + whenLastUpdate: new Date('2026-06-01T00:00:00.000Z'), + feed: { type: 'rss', title: 'A' } + }); + await store.putSubscriptions(feedUrl, [ + { + url: 'https://aggregator.example/cb', + protocol: 'https-post', + ctUpdates: 0, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: new Date('2026-06-03T00:00:00.000Z'), + whenLastUpdate: null, + whenLastError: null, + whenExpires: new Date('2026-06-03T00:00:00.000Z') + } + ]); + + // What the legacy readers (/test/getData, /subscriptions.json, stats, + // remove-expired) see when they read json-store directly. + assert.deepStrictEqual(jsonStore.getData(), { + [feedUrl]: { + resource: { + lastSize: 5, + lastHash: 'h', + ctChecks: 1, + whenLastCheck: '2026-06-01T00:00:00.000Z', + ctUpdates: 1, + whenLastUpdate: '2026-06-01T00:00:00.000Z', + feedType: 'rss', + feedTitle: 'A' + }, + subscribers: [ + { + ctUpdates: 0, + whenLastUpdate: '1970-01-01T00:00:00.000Z', + ctErrors: 0, + ctConsecutiveErrors: 0, + whenLastError: '1970-01-01T00:00:00.000Z', + whenExpires: '2026-06-03T00:00:00.000Z', + url: 'https://aggregator.example/cb', + notifyProcedure: false, + protocol: 'https-post' + } + ] + } + }); +}); From 8d31bef8664020691b89b906d9384d002ad8070e Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 12:56:30 -0500 Subject: [PATCH 30/90] refactor(server): serve /ping via @rsscloud/express middleware (slice 2) Replace the hand-rolled controllers/ping.js with the @rsscloud/express ping({ core }) handler stack, mounted POST-only in app.js ahead of the controllers router. This puts @rsscloud/core on a live request path for the first time. POST-binding matches how the package is designed and tested (it returns a method-agnostic handler stack and delegates method-binding to the consumer), so GET /ping still falls through to a 404 like the legacy router. Also fixes a packaging bug this surfaced: @rsscloud/express declared express only as a peerDependency, so its CJS build's require('express') failed to resolve in the --prod Docker runtime (the workspace package is symlinked to source, where peers are not installed) and the server crashed on boot. Add express to @rsscloud/express dependencies, keeping the peer range as the published contract, mirroring how core resolves xml2js. Gate: full Docker e2e green (134 passing); all unit suites green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/app.js | 9 ++++++- apps/server/controllers/index.js | 1 - apps/server/controllers/ping.js | 43 -------------------------------- packages/express/package.json | 4 +-- pnpm-lock.yaml | 6 ++--- 5 files changed, 13 insertions(+), 50 deletions(-) delete mode 100644 apps/server/controllers/ping.js diff --git a/apps/server/app.js b/apps/server/app.js index 6ee0b55..f1226ee 100644 --- a/apps/server/app.js +++ b/apps/server/app.js @@ -10,7 +10,8 @@ const config = require('./config'), morgan = require('morgan'), removeExpiredSubscriptions = require('./services/remove-expired-subscriptions'), websocket = require('./services/websocket'), - { events: coreEvents } = require('./core'), + { ping } = require('@rsscloud/express'), + { core, events: coreEvents } = require('./core'), bridgeCoreEvents = require('./services/core-event-bridge'); let app, hbs, server, dayjs; @@ -90,6 +91,12 @@ app.use( }) ); +// Serve /ping via @rsscloud/express (driving @rsscloud/core) ahead of the +// legacy controllers — first live exercise of core on a request path. +// POST-bound (the package delegates method-binding to the consumer) so a +// GET /ping still falls through to a 404, matching the legacy router. +app.post('/ping', ping({ core })); + // Load controllers app.use(require('./controllers')); diff --git a/apps/server/controllers/index.js b/apps/server/controllers/index.js index 9597378..cef052c 100644 --- a/apps/server/controllers/index.js +++ b/apps/server/controllers/index.js @@ -27,7 +27,6 @@ router.get('/LICENSE.md', (req, res) => { }); router.use('/pleaseNotify', require('./please-notify')); router.use('/pleaseNotifyForm', require('./please-notify-form')); -router.use('/ping', require('./ping')); router.use('/pingForm', require('./ping-form')); router.use('/viewLog', require('./view-log')); router.use('/RPC2', require('./rpc2')); diff --git a/apps/server/controllers/ping.js b/apps/server/controllers/ping.js deleted file mode 100644 index edf5932..0000000 --- a/apps/server/controllers/ping.js +++ /dev/null @@ -1,43 +0,0 @@ -const bodyParser = require('body-parser'), - ErrorResponse = require('../services/error-response'), - errorResult = require('../services/error-result'), - express = require('express'), - parsePingParams = require('../services/parse-ping-params'), - ping = require('../services/ping'), - restReturnSuccess = require('../services/rest-return-success'), - router = new express.Router(), - urlencodedParser = bodyParser.urlencoded({ extended: false }); - -function processResponse(req, res, result) { - switch (req.accepts('xml', 'json')) { - case 'xml': - res.set('Content-Type', 'text/xml'); - res.send(restReturnSuccess(result.success, result.msg, 'result')); - break; - case 'json': - res.json(result); - break; - default: - res.status(406).send('Not Acceptable'); - break; - } -} - -function handleError(req, res, err) { - if (!(err instanceof ErrorResponse)) { - console.error(err); - } - processResponse(req, res, errorResult(err.message)); -} - -router.post('/', urlencodedParser, async(req, res) => { - try { - const params = parsePingParams.rest(req); - const result = await ping(params.url); - processResponse(req, res, result); - } catch (err) { - handleError(req, res, err); - } -}); - -module.exports = router; diff --git a/packages/express/package.json b/packages/express/package.json index 7ea63e4..3e8bb42 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -47,7 +47,8 @@ "express": "^4.16.0 || ^5.0.0" }, "dependencies": { - "@rsscloud/core": "workspace:*" + "@rsscloud/core": "workspace:*", + "express": "^4.22.2" }, "scripts": { "build": "tsup", @@ -69,7 +70,6 @@ "@types/supertest": "^6.0.2", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.18.0", - "express": "^4.22.2", "supertest": "^7.0.0", "tsup": "^8.3.5", "typescript": "^5.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a5a666..59fa42f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,6 +165,9 @@ importers: '@rsscloud/core': specifier: workspace:* version: link:../core + express: + specifier: ^4.22.2 + version: 4.22.2 devDependencies: '@eslint/js': specifier: ^9.18.0 @@ -184,9 +187,6 @@ importers: eslint: specifier: ^9.18.0 version: 9.39.4(jiti@2.6.1) - express: - specifier: ^4.22.2 - version: 4.22.2 supertest: specifier: ^7.0.0 version: 7.2.2 From fe908064afd95a75b29399c575b81a3af3a13a31 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 13:21:44 -0500 Subject: [PATCH 31/90] refactor(server): serve /pleaseNotify via @rsscloud/express (slice 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the legacy controllers/please-notify.js with app.post('/pleaseNotify', pleaseNotify({ core })), mounted next to /ping ahead of the controllers router. POST-bound so GET /pleaseNotify still 404s, matching the legacy router. Only the REST front door moves; XML-RPC pleaseNotify still routes through the legacy /RPC2 controller (slice 4). Delete the orphaned controller (its only requirer was controllers/index.js); the shared services it used stay because rpc2.js still needs them. No package change — pleaseNotify was already exported/built in @rsscloud/express. Wire parity confirmed: verbose registration copy, bad-resource cancel copy (RESOURCE_READ_FAILED -> subscriptionFailureMessage), 406. Server lint + 12 node:test green; full Docker e2e 134 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/app.js | 8 +++- apps/server/controllers/index.js | 1 - apps/server/controllers/please-notify.js | 51 ------------------------ 3 files changed, 7 insertions(+), 53 deletions(-) delete mode 100644 apps/server/controllers/please-notify.js diff --git a/apps/server/app.js b/apps/server/app.js index f1226ee..0b7f53c 100644 --- a/apps/server/app.js +++ b/apps/server/app.js @@ -10,7 +10,7 @@ const config = require('./config'), morgan = require('morgan'), removeExpiredSubscriptions = require('./services/remove-expired-subscriptions'), websocket = require('./services/websocket'), - { ping } = require('@rsscloud/express'), + { ping, pleaseNotify } = require('@rsscloud/express'), { core, events: coreEvents } = require('./core'), bridgeCoreEvents = require('./services/core-event-bridge'); @@ -97,6 +97,12 @@ app.use( // GET /ping still falls through to a 404, matching the legacy router. app.post('/ping', ping({ core })); +// Serve /pleaseNotify via @rsscloud/express (driving @rsscloud/core) ahead of +// the legacy controllers, mirroring /ping. POST-bound so a GET /pleaseNotify +// still falls through to a 404, matching the legacy router. XML-RPC +// pleaseNotify still routes through the legacy /RPC2 controller (slice 4). +app.post('/pleaseNotify', pleaseNotify({ core })); + // Load controllers app.use(require('./controllers')); diff --git a/apps/server/controllers/index.js b/apps/server/controllers/index.js index cef052c..b7a0f69 100644 --- a/apps/server/controllers/index.js +++ b/apps/server/controllers/index.js @@ -25,7 +25,6 @@ router.get('/LICENSE.md', (req, res) => { res.status(500).send('Internal Server Error'); } }); -router.use('/pleaseNotify', require('./please-notify')); router.use('/pleaseNotifyForm', require('./please-notify-form')); router.use('/pingForm', require('./ping-form')); router.use('/viewLog', require('./view-log')); diff --git a/apps/server/controllers/please-notify.js b/apps/server/controllers/please-notify.js deleted file mode 100644 index f624803..0000000 --- a/apps/server/controllers/please-notify.js +++ /dev/null @@ -1,51 +0,0 @@ -const bodyParser = require('body-parser'), - ErrorResponse = require('../services/error-response'), - errorResult = require('../services/error-result'), - express = require('express'), - parseNotifyParams = require('../services/parse-notify-params'), - pleaseNotify = require('../services/please-notify'), - restReturnSuccess = require('../services/rest-return-success'), - router = new express.Router(), - urlencodedParser = bodyParser.urlencoded({ extended: false }); - -function processResponse(req, res, result) { - switch (req.accepts('xml', 'json')) { - case 'xml': - res.set('Content-Type', 'text/xml'); - res.send( - restReturnSuccess(result.success, result.msg, 'notifyResult') - ); - break; - case 'json': - res.json(result); - break; - default: - res.status(406).send('Not Acceptable'); - break; - } -} - -function handleError(req, res, err) { - if (!(err instanceof ErrorResponse)) { - console.error(err); - } - processResponse(req, res, errorResult(err.message)); -} - -router.post('/', urlencodedParser, async function(req, res) { - try { - const params = parseNotifyParams.rest(req); - const result = await pleaseNotify( - params.notifyProcedure, - params.apiurl, - params.protocol, - params.urlList, - params.diffDomain - ); - processResponse(req, res, result); - } catch (err) { - handleError(req, res, err); - } -}); - -module.exports = router; From 2a2bda1f2b64f89504b0dcb6597ea6290ff514c6 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 13:27:36 -0500 Subject: [PATCH 32/90] refactor(server): mount the core-backed protocol doors in the controllers router Move the @rsscloud/express /ping + /pleaseNotify mounts out of app.js into controllers/index.js (router.post(...), top of the routing table). /RPC2 already lives in that router and slice 4 will swap it in place, so this keeps all three protocol front doors in one routing table rather than split across app.js and the router. Behavior-identical: core is a module singleton (controllers/index.js now does const { core } = require('../core') for the same instance), the controllers router mounts after cors + static either way so middleware order is unchanged, and there is no route collision (home handles GET / only). app.js no longer imports @rsscloud/express or the core value, keeping events for the bridge. Server lint + 12 node:test green; full Docker e2e 134 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/app.js | 17 ++--------------- apps/server/controllers/index.js | 9 +++++++++ 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/apps/server/app.js b/apps/server/app.js index 0b7f53c..39aba0d 100644 --- a/apps/server/app.js +++ b/apps/server/app.js @@ -10,8 +10,7 @@ const config = require('./config'), morgan = require('morgan'), removeExpiredSubscriptions = require('./services/remove-expired-subscriptions'), websocket = require('./services/websocket'), - { ping, pleaseNotify } = require('@rsscloud/express'), - { core, events: coreEvents } = require('./core'), + { events: coreEvents } = require('./core'), bridgeCoreEvents = require('./services/core-event-bridge'); let app, hbs, server, dayjs; @@ -91,19 +90,7 @@ app.use( }) ); -// Serve /ping via @rsscloud/express (driving @rsscloud/core) ahead of the -// legacy controllers — first live exercise of core on a request path. -// POST-bound (the package delegates method-binding to the consumer) so a -// GET /ping still falls through to a 404, matching the legacy router. -app.post('/ping', ping({ core })); - -// Serve /pleaseNotify via @rsscloud/express (driving @rsscloud/core) ahead of -// the legacy controllers, mirroring /ping. POST-bound so a GET /pleaseNotify -// still falls through to a 404, matching the legacy router. XML-RPC -// pleaseNotify still routes through the legacy /RPC2 controller (slice 4). -app.post('/pleaseNotify', pleaseNotify({ core })); - -// Load controllers +// Load controllers (includes the core-backed /ping + /pleaseNotify front doors) app.use(require('./controllers')); async function gracefulShutdown() { diff --git a/apps/server/controllers/index.js b/apps/server/controllers/index.js index b7a0f69..ccebf7d 100644 --- a/apps/server/controllers/index.js +++ b/apps/server/controllers/index.js @@ -5,8 +5,17 @@ const express = require('express'), config = require('../config'), getDayjs = require('../services/dayjs-wrapper'), jsonStore = require('../services/json-store'), + { ping, pleaseNotify } = require('@rsscloud/express'), + { core } = require('../core'), router = new express.Router(); +// Core-backed protocol front doors (@rsscloud/express driving @rsscloud/core). +// POST-bound (the package delegates method-binding to the consumer) so a GET to +// either path still falls through to a 404, matching the legacy routers. XML-RPC +// pleaseNotify still routes through the legacy /RPC2 controller (slice 4). +router.post('/ping', ping({ core })); +router.post('/pleaseNotify', pleaseNotify({ core })); + router.use('/', require('./home')); router.use('/docs', require('./docs')); From d0d238e9b93e61b5c6dc1b75ad1af1020a949923 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 13:32:35 -0500 Subject: [PATCH 33/90] refactor(server): serve /RPC2 via @rsscloud/express (slice 4) Replace the legacy controllers/rpc2.js with router.post('/RPC2', rpc2({ core })), mounted in the protocol-doors block alongside the already-migrated /ping and /pleaseNotify. This is the last of the three protocol front doors to move onto @rsscloud/core; the XML-RPC dispatcher owns the whole round trip (parse, run the matching use case, serialize) and never throws -- malformed input and use-case errors both become faults in the response. Parity confirmed against core's createXmlRpcDispatcher: rssCloud.hello -> success, ping always succeeds once well-formed (faults only on bad arity), unknown method -> fault 4 with the exact legacy "Can't make the call..." wording, and bad arity/protocol/resource reuse the same fault copy as the REST door. */xml body parsing and 406-on-non-xml are preserved. One accepted behavior change: the legacy logEvent('XmlRpc', ...) call is gone, so hello + pre-engine failures no longer appear in /viewLog -- already signed off in PLAN #4. No package change: rpc2 was already exported/built in @rsscloud/express. The now controller-orphaned protocol-glue services (parse-rpc-request, parse-notify-params, parse-ping-params, please-notify, ping, rpc-return-*, rest-return-success, error-result, error-response) are left in place: please-notify and ping transitively back the still-live scheduled remove-expired-subscriptions and stats, so sweeping them is the deferred legacy-services cleanup, not this slice. Server lint + 12 node:test green; full Docker e2e 134 green -- every /RPC2 case (XML-RPC ping in ping.js, XML-RPC pleaseNotify in please-notify.js) now runs through core. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/controllers/index.js | 9 +-- apps/server/controllers/rpc2.js | 102 ------------------------------- 2 files changed, 5 insertions(+), 106 deletions(-) delete mode 100644 apps/server/controllers/rpc2.js diff --git a/apps/server/controllers/index.js b/apps/server/controllers/index.js index ccebf7d..b0c447e 100644 --- a/apps/server/controllers/index.js +++ b/apps/server/controllers/index.js @@ -5,16 +5,18 @@ const express = require('express'), config = require('../config'), getDayjs = require('../services/dayjs-wrapper'), jsonStore = require('../services/json-store'), - { ping, pleaseNotify } = require('@rsscloud/express'), + { ping, pleaseNotify, rpc2 } = require('@rsscloud/express'), { core } = require('../core'), router = new express.Router(); // Core-backed protocol front doors (@rsscloud/express driving @rsscloud/core). // POST-bound (the package delegates method-binding to the consumer) so a GET to -// either path still falls through to a 404, matching the legacy routers. XML-RPC -// pleaseNotify still routes through the legacy /RPC2 controller (slice 4). +// any of these paths still falls through to a 404, matching the legacy routers. +// /RPC2 handles rssCloud.hello/pleaseNotify/ping; the dispatcher never throws, +// faulting in-response on malformed or unknown calls. router.post('/ping', ping({ core })); router.post('/pleaseNotify', pleaseNotify({ core })); +router.post('/RPC2', rpc2({ core })); router.use('/', require('./home')); router.use('/docs', require('./docs')); @@ -37,7 +39,6 @@ router.get('/LICENSE.md', (req, res) => { router.use('/pleaseNotifyForm', require('./please-notify-form')); router.use('/pingForm', require('./ping-form')); router.use('/viewLog', require('./view-log')); -router.use('/RPC2', require('./rpc2')); router.use('/stats', require('./stats')); router.get('/stats.json', (req, res) => { diff --git a/apps/server/controllers/rpc2.js b/apps/server/controllers/rpc2.js deleted file mode 100644 index 6d67ee5..0000000 --- a/apps/server/controllers/rpc2.js +++ /dev/null @@ -1,102 +0,0 @@ -const bodyParser = require('body-parser'), - ErrorResponse = require('../services/error-response'), - express = require('express'), - getDayjs = require('../services/dayjs-wrapper'), - logEvent = require('../services/log-event'), - parseRpcRequest = require('../services/parse-rpc-request'), - parseNotifyParams = require('../services/parse-notify-params'), - parsePingParams = require('../services/parse-ping-params'), - pleaseNotify = require('../services/please-notify'), - ping = require('../services/ping'), - router = new express.Router(), - rpcReturnSuccess = require('../services/rpc-return-success'), - rpcReturnFault = require('../services/rpc-return-fault'), - textParser = bodyParser.text({ type: '*/xml' }); - -function processResponse(req, res, xmlString) { - switch (req.accepts('xml')) { - case 'xml': - res.set('Content-Type', 'text/xml'); - res.send(xmlString); - break; - default: - res.status(406).send('Not Acceptable'); - break; - } -} - -function handleError(req, res, err) { - if (!(err instanceof ErrorResponse)) { - console.error(err); - } - processResponse(req, res, rpcReturnFault(4, err.message)); -} - -router.post('/', textParser, async function(req, res) { - let params; - const dayjs = await getDayjs(); - - try { - const request = await parseRpcRequest(req); - - logEvent( - 'XmlRpc', - { - methodName: request.methodName, - params: request.params - }, - dayjs().format('x') - ); - - switch (request.methodName) { - case 'rssCloud.hello': - processResponse(req, res, rpcReturnSuccess(true)); - break; - case 'rssCloud.pleaseNotify': - try { - params = parseNotifyParams.rpc(req, request.params); - const result = await pleaseNotify( - params.notifyProcedure, - params.apiurl, - params.protocol, - params.urlList, - params.diffDomain - ); - processResponse(req, res, rpcReturnSuccess(result.success)); - } catch (err) { - handleError(req, res, err); - } - break; - case 'rssCloud.ping': - try { - params = parsePingParams.rpc(req, request.params); - // Dave's rssCloud server always returns true whether it succeeded or not - try { - const result = await ping(params.url); - processResponse( - req, - res, - rpcReturnSuccess(result.success) - ); - } catch { - processResponse(req, res, rpcReturnSuccess(true)); - } - } catch (err) { - handleError(req, res, err); - } - break; - default: - handleError( - req, - res, - new Error( - `Can't make the call because "${request.methodName}" is not defined.` - ) - ); - } - } catch (err) { - handleError(req, res, err); - } -}); - -module.exports = router; From 779de88b505757a5771c307b419683386b482dd4 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 13:46:06 -0500 Subject: [PATCH 34/90] refactor(server): delete orphaned RPC/REST protocol-glue services (slice 5) These 7 modules became dead code once the protocol doors moved onto @rsscloud/express (slices 2-4): parse-rpc-request, parse-notify-params, parse-ping-params, rpc-return-success, rpc-return-fault, rest-return-success, error-result. No live references, no tests; the e2e suite carries its own duplicated copies in apps/e2e/test/helpers/. Full Docker e2e suite green (134 passing). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/services/error-result.js | 8 - apps/server/services/parse-notify-params.js | 164 -------------------- apps/server/services/parse-ping-params.js | 36 ----- apps/server/services/parse-rpc-request.js | 91 ----------- apps/server/services/rest-return-success.js | 13 -- apps/server/services/rpc-return-fault.js | 32 ---- apps/server/services/rpc-return-success.js | 21 --- 7 files changed, 365 deletions(-) delete mode 100644 apps/server/services/error-result.js delete mode 100644 apps/server/services/parse-notify-params.js delete mode 100644 apps/server/services/parse-ping-params.js delete mode 100644 apps/server/services/parse-rpc-request.js delete mode 100644 apps/server/services/rest-return-success.js delete mode 100644 apps/server/services/rpc-return-fault.js delete mode 100644 apps/server/services/rpc-return-success.js diff --git a/apps/server/services/error-result.js b/apps/server/services/error-result.js deleted file mode 100644 index 016dcc1..0000000 --- a/apps/server/services/error-result.js +++ /dev/null @@ -1,8 +0,0 @@ -function errorResult(err) { - return { - success: false, - msg: err - }; -} - -module.exports = errorResult; diff --git a/apps/server/services/parse-notify-params.js b/apps/server/services/parse-notify-params.js deleted file mode 100644 index 57831c9..0000000 --- a/apps/server/services/parse-notify-params.js +++ /dev/null @@ -1,164 +0,0 @@ -const appMessages = require('./app-messages'), - ErrorResponse = require('./error-response'); - -function validProtocol(protocol) { - switch (protocol) { - case 'http-post': - case 'https-post': - case 'xml-rpc': - return true; - default: - throw new ErrorResponse( - appMessages.error.subscription.invalidProtocol(protocol) - ); - } -} - -function parseUrlList(argv) { - let key, - urlList = []; - - if (undefined === argv.hasOwnProperty) { - Object.setPrototypeOf(argv, {}); - } - - for (key in argv) { - if ( - Object.prototype.hasOwnProperty.call(argv, key) && - 0 === key.toLowerCase().indexOf('url') - ) { - urlList.push(argv[key]); - } - } - - return urlList; -} - -function glueUrlParts(scheme, client, port, path) { - if (client.startsWith('::ffff:')) { - client = client.slice(7); - } - - if (client.indexOf(':') > -1) { - client = `[${client}]`; - } - - if (0 !== path.indexOf('/')) { - path = `/${path}`; - } - - return `${scheme}://${client}:${port}${path}`; -} - -function rest(req) { - let s = '', - params = {}, - parts = {}; - - if (validProtocol(req.body.protocol)) { - params.protocol = req.body.protocol; - } - - params.urlList = parseUrlList(req.body); - - if (null == req.body.domain || '' === req.body.domain) { - parts.client = - req.headers['x-forwarded-for'] || req.connection.remoteAddress; - params.diffDomain = false; - } else { - parts.client = req.body.domain; - params.diffDomain = true; - } - if (undefined === req.body.port) { - s += 'port, '; - } - if (undefined === req.body.path) { - s += 'path, '; - } - if (undefined === req.body.protocol) { - s += 'protocol, '; - } - - if (req.body.notifyProcedure && 'xml-rpc' === req.body.protocol) { - params.notifyProcedure = req.body.notifyProcedure; - } else { - params.notifyProcedure = false; - } - - if (0 === s.length) { - parts.scheme = - 'https-post' === params.protocol || '443' === String(req.body.port) - ? 'https' - : 'http'; - parts.port = req.body.port; - parts.path = req.body.path; - - params.apiurl = glueUrlParts( - parts.scheme, - parts.client, - parts.port, - parts.path - ); - - return params; - } else { - s = s.substr(0, s.length - 2); - throw new ErrorResponse( - appMessages.error.subscription.missingParams(s) - ); - } -} - -function rpc(req, rpcParams) { - let params = {}, - parts = {}; - - if (5 > rpcParams.length) { - throw new ErrorResponse( - appMessages.error.rpc.notEnoughParams('pleaseNotify') - ); - } else if (6 < rpcParams.length) { - throw new ErrorResponse( - appMessages.error.rpc.tooManyParams('pleaseNotify') - ); - } - - if (validProtocol(rpcParams[3])) { - params.protocol = rpcParams[3]; - } - - params.urlList = rpcParams[4]; - - if (undefined === rpcParams[5]) { - parts.client = - req.headers['x-forwarded-for'] || req.connection.remoteAddress; - params.diffDomain = false; - } else { - parts.client = rpcParams[5]; - params.diffDomain = true; - } - - if (rpcParams[0] && 'xml-rpc' === params.protocol) { - params.notifyProcedure = rpcParams[0]; - } else { - params.notifyProcedure = false; - } - - parts.scheme = - 'https-post' === params.protocol || '443' === String(rpcParams[1]) - ? 'https' - : 'http'; - parts.port = rpcParams[1]; - parts.path = rpcParams[2]; - - params.apiurl = glueUrlParts( - parts.scheme, - parts.client, - parts.port, - parts.path - ); - - return params; -} - -module.exports = { rest, rpc }; diff --git a/apps/server/services/parse-ping-params.js b/apps/server/services/parse-ping-params.js deleted file mode 100644 index 2a0055f..0000000 --- a/apps/server/services/parse-ping-params.js +++ /dev/null @@ -1,36 +0,0 @@ -const appMessages = require('./app-messages'), - ErrorResponse = require('./error-response'); - -function rest(req) { - let s = ''; - const params = {}; - - if (undefined === req.body.url) { - s += 'url, '; - } - if (0 === s.length) { - params.url = req.body.url; - return params; - } else { - s = s.substr(0, s.length - 2); - throw new ErrorResponse( - appMessages.error.subscription.missingParams(s) - ); - } -} - -function rpc(req, rpcParams) { - let params = {}; - - if (1 > rpcParams.length) { - throw new ErrorResponse(appMessages.error.rpc.notEnoughParams('ping')); - } else if (1 < rpcParams.length) { - throw new ErrorResponse(appMessages.error.rpc.tooManyParams('ping')); - } - - params.url = rpcParams[0]; - - return params; -} - -module.exports = { rest, rpc }; diff --git a/apps/server/services/parse-rpc-request.js b/apps/server/services/parse-rpc-request.js deleted file mode 100644 index 8c6f3ab..0000000 --- a/apps/server/services/parse-rpc-request.js +++ /dev/null @@ -1,91 +0,0 @@ -const getDayjs = require('./dayjs-wrapper'), - xml2js = require('xml2js'); - -async function parseRpcParam(param, dayjs) { - let returnedValue, tag, member, values; - - const value = param.value || param; - - for (tag in value) { - switch (tag) { - case 'i4': - case 'int': - case 'double': - returnedValue = Number(value[tag]); - break; - case 'string': - returnedValue = value[tag]; - break; - case 'boolean': - returnedValue = 'true' === value[tag] || !!Number(value[tag]); - break; - case 'dateTime.iso8601': - returnedValue = dayjs.utc(value[tag], [ - 'YYYYMMDDTHHmmss', - dayjs.ISO_8601 - ]); - break; - case 'base64': - returnedValue = Buffer.from(value[tag], 'base64').toString( - 'utf8' - ); - break; - case 'struct': - member = value[tag].member || []; - if (!Array.isArray(member)) { - member = [member]; - } - returnedValue = {}; - for (const item of member) { - returnedValue[item.name] = await parseRpcParam(item, dayjs); - } - break; - case 'array': - values = (value[tag].data || {}).value || []; - if (!Array.isArray(values)) { - values = [values]; - } - returnedValue = []; - for (const item of values) { - returnedValue.push(await parseRpcParam(item, dayjs)); - } - break; - } - } - - if (undefined === returnedValue) { - returnedValue = value; - } - - return returnedValue; -} - -async function parseRpcRequest(req) { - const dayjs = await getDayjs(); - const parser = new xml2js.Parser({ explicitArray: false }), - jstruct = await parser.parseStringPromise(req.body), - methodCall = jstruct.methodCall, - methodName = (methodCall || {}).methodName, - params = ((methodCall || {}).params || {}).param || []; - - if (undefined === methodCall) { - throw new Error('Bad XML-RPC call, missing "methodCall" element.'); - } - - if (undefined === methodName) { - throw new Error('Bad XML-RPC call, missing "methodName" element.'); - } - - const parsedParams = []; - const paramArray = Array.isArray(params) ? params : [params]; - for (const param of paramArray) { - parsedParams.push(await parseRpcParam(param, dayjs)); - } - - return { - methodName, - params: parsedParams - }; -} - -module.exports = parseRpcRequest; diff --git a/apps/server/services/rest-return-success.js b/apps/server/services/rest-return-success.js deleted file mode 100644 index 81580b8..0000000 --- a/apps/server/services/rest-return-success.js +++ /dev/null @@ -1,13 +0,0 @@ -const builder = require('xmlbuilder'); - -function restReturnSuccess(success, message, element) { - element = element || 'result'; - - return builder - .create(element) - .att('success', success ? 'true' : 'false') - .att('msg', message) - .end({ pretty: true }); -} - -module.exports = restReturnSuccess; diff --git a/apps/server/services/rpc-return-fault.js b/apps/server/services/rpc-return-fault.js deleted file mode 100644 index 368a3a7..0000000 --- a/apps/server/services/rpc-return-fault.js +++ /dev/null @@ -1,32 +0,0 @@ -const builder = require('xmlbuilder'); - -function rpcReturnFault(faultCode, faultString) { - return builder - .create({ - methodResponse: { - fault: { - value: { - struct: { - member: [ - { - name: 'faultCode', - value: { - int: faultCode - } - }, - { - name: 'faultString', - value: { - string: faultString - } - } - ] - } - } - } - } - }) - .end({ pretty: true }); -} - -module.exports = rpcReturnFault; diff --git a/apps/server/services/rpc-return-success.js b/apps/server/services/rpc-return-success.js deleted file mode 100644 index 95ab3c9..0000000 --- a/apps/server/services/rpc-return-success.js +++ /dev/null @@ -1,21 +0,0 @@ -const builder = require('xmlbuilder'); - -function rpcReturnSuccess(success) { - return builder - .create({ - methodResponse: { - params: { - param: [ - { - value: { - boolean: success ? 1 : 0 - } - } - ] - } - } - }) - .end({ pretty: true }); -} - -module.exports = rpcReturnSuccess; From 34f64dfeda4ca22eeb05bdd4340447394009c079 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 13:52:09 -0500 Subject: [PATCH 35/90] test(server): characterize stats service behavior Pin the observable contract of services/stats.js before backing it with @rsscloud/core: getStats() default fallback, generateStats() persistence round-trip, the legacy-shaped aggregate (feedsChangedLast7Days field name, protocolBreakdown seeded with all three protocols, active-subscription filter, unique-aggregator dedup, topFeeds/moreFeeds), and omission of feeds whose subscriptions have all expired. These tests seed the shared in-memory json-store, which both the current direct-read implementation and the forthcoming core-backed one consume identically, so the net stays green across the refactor. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/services/stats.test.js | 131 +++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 apps/server/services/stats.test.js diff --git a/apps/server/services/stats.test.js b/apps/server/services/stats.test.js new file mode 100644 index 0000000..30a7839 --- /dev/null +++ b/apps/server/services/stats.test.js @@ -0,0 +1,131 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +// stats.js reads config.statsFilePath, and config snapshots process.env at +// require time, so point it at a throwaway temp file before requiring anything. +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rsscloud-stats-')); +process.env.STATS_FILE_PATH = path.join(tmpDir, 'stats.json'); + +const config = require('../config'); +const jsonStore = require('./json-store'); +const stats = require('./stats'); + +test.beforeEach(() => { + jsonStore.clear(); + fs.rmSync(config.statsFilePath, { force: true }); +}); + +test('getStats returns the default shape when no stats file exists', () => { + assert.deepEqual(stats.getStats(), { + generatedAt: null, + feedsChangedLast7Days: 0, + feedsWithSubscribers: 0, + uniqueAggregators: 0, + totalActiveSubscriptions: 0, + topFeeds: [], + moreFeeds: [], + protocolBreakdown: { 'http-post': 0, 'https-post': 0, 'xml-rpc': 0 } + }); +}); + +test('generateStats persists an empty snapshot getStats reads back', async() => { + const generated = await stats.generateStats(); + + assert.equal(typeof generated.generatedAt, 'string'); + assert.ok(!Number.isNaN(Date.parse(generated.generatedAt))); + assert.deepEqual( + { ...generated, generatedAt: null }, + { + generatedAt: null, + feedsChangedLast7Days: 0, + feedsWithSubscribers: 0, + uniqueAggregators: 0, + totalActiveSubscriptions: 0, + topFeeds: [], + moreFeeds: [], + protocolBreakdown: { 'http-post': 0, 'https-post': 0, 'xml-rpc': 0 } + } + ); + assert.deepEqual(stats.getStats(), generated); +}); + +const DAY_MS = 24 * 60 * 60 * 1000; + +test('generateStats aggregates active subscriptions into the legacy shape', async() => { + const recent = new Date(Date.now() - DAY_MS).toISOString(); + const future = new Date(Date.now() + DAY_MS).toISOString(); + const past = new Date(Date.now() - DAY_MS).toISOString(); + + jsonStore.setResource('https://a.example.com/feed.xml', { + feedTitle: 'Alpha', + whenLastUpdate: recent + }); + jsonStore.setSubscriptions('https://a.example.com/feed.xml', [ + { url: 'http://sub1.example.com/notify', protocol: 'http-post', whenExpires: future }, + { url: 'http://sub2.example.com/notify', protocol: 'http-post', whenExpires: future }, + { url: 'http://gone.example.com/notify', protocol: 'http-post', whenExpires: past } + ]); + + jsonStore.setResource('https://b.example.com/feed.xml', { + feedTitle: 'Bravo', + whenLastUpdate: recent + }); + jsonStore.setSubscriptions('https://b.example.com/feed.xml', [ + { url: 'http://sub1.example.com/notify', protocol: 'http-post', whenExpires: future } + ]); + + const generated = await stats.generateStats(); + + assert.equal(generated.feedsChangedLast7Days, 2); + assert.equal(generated.feedsWithSubscribers, 2); + assert.equal(generated.totalActiveSubscriptions, 3); + // sub1.example.com is shared across both feeds — counted once. + assert.equal(generated.uniqueAggregators, 2); + // Only http-post subs exist; https-post and xml-rpc must still be seeded at 0. + assert.deepEqual(generated.protocolBreakdown, { + 'http-post': 3, + 'https-post': 0, + 'xml-rpc': 0 + }); + assert.deepEqual(generated.topFeeds, [ + { + url: 'https://a.example.com/feed.xml', + subscriberCount: 2, + whenLastUpdate: new Date(recent).toISOString(), + feedTitle: 'Alpha' + }, + { + url: 'https://b.example.com/feed.xml', + subscriberCount: 1, + whenLastUpdate: new Date(recent).toISOString(), + feedTitle: 'Bravo' + } + ]); + assert.deepEqual(generated.moreFeeds, []); +}); + +test('generateStats omits feeds whose subscriptions have all expired', async() => { + const past = new Date(Date.now() - DAY_MS).toISOString(); + + jsonStore.setResource('https://stale.example.com/feed.xml', { + feedTitle: 'Stale', + whenLastUpdate: new Date(Date.now() - DAY_MS).toISOString() + }); + jsonStore.setSubscriptions('https://stale.example.com/feed.xml', [ + { url: 'http://gone.example.com/notify', protocol: 'http-post', whenExpires: past } + ]); + + const generated = await stats.generateStats(); + + assert.equal(generated.feedsWithSubscribers, 0); + assert.equal(generated.totalActiveSubscriptions, 0); + assert.deepEqual(generated.topFeeds, []); + assert.deepEqual(generated.protocolBreakdown, { + 'http-post': 0, + 'https-post': 0, + 'xml-rpc': 0 + }); +}); From 31f7f8db835fcf66ab42abc3d7f02e227d54bb83 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 13:56:05 -0500 Subject: [PATCH 36/90] refactor(server): back the stats service with @rsscloud/core (slice 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generateStats() now delegates the aggregation to core.generateStats() instead of re-walking jsonStore.getData() by hand, then maps core's Stats onto the legacy wire shape: feedsChangedLastWindow -> feedsChangedLast7Days and protocolBreakdown reduced to exactly the three known protocols (seeded at 0, merging core's counts, dropping anything outside that set — matching the old `in protocolBreakdown` guard). Atomic stats.json write, scheduleStatsGeneration(), and the getStats() default fallback are unchanged, so the /stats view and /stats.json stay byte-identical. This drops the last direct jsonStore reader among the stats path, leaving the dayjs wrapper and the hand-rolled computation behind. Characterization net (services/stats.test.js, 4 tests) stays green; full Docker e2e suite green (134 passing). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/services/stats.js | 105 ++++++++-------------------------- 1 file changed, 25 insertions(+), 80 deletions(-) diff --git a/apps/server/services/stats.js b/apps/server/services/stats.js index d6b29d9..2cf9570 100644 --- a/apps/server/services/stats.js +++ b/apps/server/services/stats.js @@ -1,9 +1,11 @@ const fs = require('fs'); const path = require('path'); -const { URL } = require('url'); const config = require('../config'); -const getDayjs = require('./dayjs-wrapper'); -const jsonStore = require('./json-store'); +const { core } = require('../core'); + +// Protocols the legacy stats shape always reports, even at zero. core only +// includes protocols it actually saw, so we seed these and merge core's counts. +const KNOWN_PROTOCOLS = ['http-post', 'https-post', 'xml-rpc']; function getStatsFilePath() { return config.statsFilePath; @@ -27,86 +29,29 @@ function getStats() { } } -async function generateStats() { - const dayjs = await getDayjs(); - const now = dayjs().utc().format(); - const cutoff = dayjs() - .utc() - .subtract(config.feedsChangedWindowDays, 'days') - .toDate(); - - const data = jsonStore.getData(); - - let feedsChangedLast7Days = 0; - let totalActiveSubscriptions = 0; - const hostnames = new Set(); - const protocolBreakdown = { 'http-post': 0, 'https-post': 0, 'xml-rpc': 0 }; - const feedCounts = []; - - for (const [feedUrl, entry] of Object.entries(data)) { - let lastUpdate = null; - if (entry.resource?.whenLastUpdate) { - lastUpdate = new Date(entry.resource.whenLastUpdate); - if (lastUpdate >= cutoff) { - feedsChangedLast7Days++; - } - } - - // Process active subscribers - let activeCount = 0; - for (const sub of entry.subscribers || []) { - if (sub.whenExpires > now) { - activeCount++; - totalActiveSubscriptions++; - - // Collect unique hostnames - try { - hostnames.add(new URL(sub.url).hostname); - } catch { - // skip invalid URLs - } - - // Protocol breakdown - if (sub.protocol in protocolBreakdown) { - protocolBreakdown[sub.protocol]++; - } - } - } - - if (activeCount > 0) { - const whenLastUpdate = - lastUpdate && lastUpdate.getTime() > 0 - ? lastUpdate.toISOString() - : null; - const feedTitle = entry.resource?.feedTitle || null; - feedCounts.push({ - url: feedUrl, - subscriberCount: activeCount, - whenLastUpdate, - feedTitle - }); - } - } - - // Top most subscribed feeds (include all ties at the boundary) - feedCounts.sort((a, b) => b.subscriberCount - a.subscriberCount); - let topFeeds = feedCounts.slice(0, 10); - if (topFeeds.length === 10) { - const threshold = topFeeds[9].subscriberCount; - topFeeds = feedCounts.filter(f => f.subscriberCount >= threshold); +// Map core's Stats onto the legacy wire shape the view + /stats.json expose: +// rename feedsChangedLastWindow, and report exactly the three known protocols +// (seeded at 0, dropping any core might include outside that set). +function toLegacyStats(coreStats) { + const protocolBreakdown = {}; + for (const protocol of KNOWN_PROTOCOLS) { + protocolBreakdown[protocol] = + coreStats.protocolBreakdown[protocol] ?? 0; } - const moreFeeds = feedCounts.slice(topFeeds.length); - - const stats = { - generatedAt: dayjs().utc().format(), - feedsChangedLast7Days, - feedsWithSubscribers: feedCounts.length, - uniqueAggregators: hostnames.size, - totalActiveSubscriptions, - topFeeds, - moreFeeds, + return { + generatedAt: coreStats.generatedAt, + feedsChangedLast7Days: coreStats.feedsChangedLastWindow, + feedsWithSubscribers: coreStats.feedsWithSubscribers, + uniqueAggregators: coreStats.uniqueAggregators, + totalActiveSubscriptions: coreStats.totalActiveSubscriptions, + topFeeds: coreStats.topFeeds, + moreFeeds: coreStats.moreFeeds, protocolBreakdown }; +} + +async function generateStats() { + const stats = toLegacyStats(await core.generateStats()); // Write atomically const filePath = getStatsFilePath(); From 6d9eebf10964ec8d513bba313260130763acfdb9 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 14:14:58 -0500 Subject: [PATCH 37/90] test(server): characterize the OPML export Extract the inline /feeds.opml handler into services/feeds-opml.js (verbatim, still reading jsonStore.getData()) so the export is unit testable, and pin its behavior with characterization tests: full metadata -> outline attributes, case-insensitive sort with feed-URL fallback, and a subscribed-but-never-pinged feed still listed. The controller keeps owning the text/x-opml response; config/builder/ dayjs-wrapper requires move into the service. Sets up the slice-2 swap onto core's store.list() with the behavior already under test. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/controllers/index.js | 44 +----------- apps/server/services/feeds-opml.js | 51 ++++++++++++++ apps/server/services/feeds-opml.test.js | 92 +++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 42 deletions(-) create mode 100644 apps/server/services/feeds-opml.js create mode 100644 apps/server/services/feeds-opml.test.js diff --git a/apps/server/controllers/index.js b/apps/server/controllers/index.js index b0c447e..7ef67c7 100644 --- a/apps/server/controllers/index.js +++ b/apps/server/controllers/index.js @@ -1,10 +1,8 @@ const express = require('express'), - builder = require('xmlbuilder'), fs = require('fs'), md = require('markdown-it')(), - config = require('../config'), - getDayjs = require('../services/dayjs-wrapper'), jsonStore = require('../services/json-store'), + { generateOpml } = require('../services/feeds-opml'), { ping, pleaseNotify, rpc2 } = require('@rsscloud/express'), { core } = require('../core'), router = new express.Router(); @@ -54,46 +52,8 @@ router.get('/subscriptions.json', (req, res) => { router.get('/feeds.opml', async(req, res, next) => { try { - const dayjs = await getDayjs(); - const nowIso = dayjs().utc().format(); - - const data = jsonStore.getData(); - const outlines = []; - - for (const [feedUrl, entry] of Object.entries(data)) { - const r = entry.resource || {}; - const text = r.feedTitle || feedUrl; - const outline = { - type: r.feedType || 'rss', - text, - xmlUrl: feedUrl - }; - if (r.feedTitle) outline.title = r.feedTitle; - if (r.feedDescription) outline.description = r.feedDescription; - if (r.feedHtmlUrl) outline.htmlUrl = r.feedHtmlUrl; - if (r.feedLanguage) outline.language = r.feedLanguage; - outlines.push(outline); - } - - outlines.sort((a, b) => - a.text.toLowerCase().localeCompare(b.text.toLowerCase()) - ); - - const opml = builder.create('opml', { - version: '1.0', - encoding: 'UTF-8' - }); - opml.att('version', '2.0'); - const head = opml.ele('head'); - head.ele('title', {}, `rssCloud Server feeds (${config.domain})`); - head.ele('dateCreated', {}, nowIso); - const body = opml.ele('body'); - for (const o of outlines) { - body.ele('outline', o); - } - res.set('Content-Type', 'text/x-opml; charset=utf-8'); - res.send(opml.end({ pretty: true })); + res.send(await generateOpml()); } catch (err) { next(err); } diff --git a/apps/server/services/feeds-opml.js b/apps/server/services/feeds-opml.js new file mode 100644 index 0000000..b714b7a --- /dev/null +++ b/apps/server/services/feeds-opml.js @@ -0,0 +1,51 @@ +const builder = require('xmlbuilder'); +const config = require('../config'); +const getDayjs = require('./dayjs-wrapper'); +const jsonStore = require('./json-store'); + +// Builds the `/feeds.opml` document: every tracked feed as an , +// sorted case-insensitively by display text. The controller owns the HTTP +// response (Content-Type + error forwarding); this returns the XML string. +async function generateOpml() { + const dayjs = await getDayjs(); + const nowIso = dayjs().utc().format(); + + const data = jsonStore.getData(); + const outlines = []; + + for (const [feedUrl, entry] of Object.entries(data)) { + const r = entry.resource || {}; + const text = r.feedTitle || feedUrl; + const outline = { + type: r.feedType || 'rss', + text, + xmlUrl: feedUrl + }; + if (r.feedTitle) outline.title = r.feedTitle; + if (r.feedDescription) outline.description = r.feedDescription; + if (r.feedHtmlUrl) outline.htmlUrl = r.feedHtmlUrl; + if (r.feedLanguage) outline.language = r.feedLanguage; + outlines.push(outline); + } + + outlines.sort((a, b) => + a.text.toLowerCase().localeCompare(b.text.toLowerCase()) + ); + + const opml = builder.create('opml', { + version: '1.0', + encoding: 'UTF-8' + }); + opml.att('version', '2.0'); + const head = opml.ele('head'); + head.ele('title', {}, `rssCloud Server feeds (${config.domain})`); + head.ele('dateCreated', {}, nowIso); + const body = opml.ele('body'); + for (const o of outlines) { + body.ele('outline', o); + } + + return opml.end({ pretty: true }); +} + +module.exports = { generateOpml }; diff --git a/apps/server/services/feeds-opml.test.js b/apps/server/services/feeds-opml.test.js new file mode 100644 index 0000000..6be5cf3 --- /dev/null +++ b/apps/server/services/feeds-opml.test.js @@ -0,0 +1,92 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const xml2js = require('xml2js'); + +const config = require('../config'); +const jsonStore = require('./json-store'); +const { generateOpml } = require('./feeds-opml'); + +async function parseOpml(xml) { + return new xml2js.Parser().parseStringPromise(xml); +} + +test.beforeEach(() => { + jsonStore.clear(); +}); + +test('generateOpml renders a feed with full metadata as an outline', async() => { + jsonStore.setResource('https://a.example.com/feed.xml', { + feedType: 'atom', + feedTitle: 'Alpha', + feedDescription: 'The Alpha feed', + feedHtmlUrl: 'https://a.example.com/', + feedLanguage: 'en-us' + }); + + const result = await parseOpml(await generateOpml()); + + assert.equal(result.opml.$.version, '2.0'); + assert.equal( + result.opml.head[0].title[0], + `rssCloud Server feeds (${config.domain})` + ); + const dateCreated = result.opml.head[0].dateCreated[0]; + assert.ok(!Number.isNaN(Date.parse(dateCreated))); + + const outlines = result.opml.body[0].outline; + assert.equal(outlines.length, 1); + assert.deepEqual(outlines[0].$, { + type: 'atom', + text: 'Alpha', + xmlUrl: 'https://a.example.com/feed.xml', + title: 'Alpha', + description: 'The Alpha feed', + htmlUrl: 'https://a.example.com/', + language: 'en-us' + }); +}); + +test('generateOpml sorts case-insensitively and falls back to the feed URL', async() => { + // Untitled feed: text falls back to the URL, type defaults to rss, and no + // title/description/htmlUrl/language attributes are emitted. + jsonStore.setResource('https://apple.example.com/feed.xml', {}); + jsonStore.setResource('https://b.example.com/feed.xml', { + feedTitle: 'banana' + }); + jsonStore.setResource('https://z.example.com/feed.xml', { + feedTitle: 'Cherry' + }); + + const result = await parseOpml(await generateOpml()); + const outlines = result.opml.body[0].outline; + + assert.deepEqual( + outlines.map(o => o.$.text), + ['banana', 'Cherry', 'https://apple.example.com/feed.xml'] + ); + assert.deepEqual(outlines[2].$, { + type: 'rss', + text: 'https://apple.example.com/feed.xml', + xmlUrl: 'https://apple.example.com/feed.xml' + }); +}); + +test('generateOpml lists a subscribed feed that was never pinged', async() => { + jsonStore.setSubscriptions('https://new.example.com/feed.xml', [ + { + url: 'http://sub.example.com/notify', + protocol: 'http-post', + whenExpires: new Date(Date.now() + 86400000).toISOString() + } + ]); + + const result = await parseOpml(await generateOpml()); + const outlines = result.opml.body[0].outline; + + assert.equal(outlines.length, 1); + assert.deepEqual(outlines[0].$, { + type: 'rss', + text: 'https://new.example.com/feed.xml', + xmlUrl: 'https://new.example.com/feed.xml' + }); +}); From f5ca20cccf16cdc0145397f20b0a0aed2fa6d32b Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 14:17:21 -0500 Subject: [PATCH 38/90] refactor(server): build OPML from the core store (slice 7) Switch services/feeds-opml.js from reading json-store's flat resource.feed* fields to core's store.list(), reading the nested resource.feed shape. A feed that was subscribed but never pinged has a null resource from the store, so the display text falls back to the feed URL (previously `entry.resource || {}`). Output is byte-identical; the characterization tests from the prior slice stay green and the full e2e suite passes (134). The server's only remaining direct json-store.getData() read is now /subscriptions.json. No core changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/services/feeds-opml.js | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/server/services/feeds-opml.js b/apps/server/services/feeds-opml.js index b714b7a..80e7d63 100644 --- a/apps/server/services/feeds-opml.js +++ b/apps/server/services/feeds-opml.js @@ -1,30 +1,32 @@ const builder = require('xmlbuilder'); const config = require('../config'); const getDayjs = require('./dayjs-wrapper'); -const jsonStore = require('./json-store'); +const { store } = require('../core'); // Builds the `/feeds.opml` document: every tracked feed as an , // sorted case-insensitively by display text. The controller owns the HTTP // response (Content-Type + error forwarding); this returns the XML string. +// Reads the core store, whose `resource.feed` metadata is null/absent for a +// feed that has never been pinged (so text falls back to the feed URL). async function generateOpml() { const dayjs = await getDayjs(); const nowIso = dayjs().utc().format(); - const data = jsonStore.getData(); + const entries = await store.list(); const outlines = []; - for (const [feedUrl, entry] of Object.entries(data)) { - const r = entry.resource || {}; - const text = r.feedTitle || feedUrl; + for (const { feedUrl, resource } of entries) { + const feed = (resource && resource.feed) || {}; + const text = feed.title || feedUrl; const outline = { - type: r.feedType || 'rss', + type: feed.type || 'rss', text, xmlUrl: feedUrl }; - if (r.feedTitle) outline.title = r.feedTitle; - if (r.feedDescription) outline.description = r.feedDescription; - if (r.feedHtmlUrl) outline.htmlUrl = r.feedHtmlUrl; - if (r.feedLanguage) outline.language = r.feedLanguage; + if (feed.title) outline.title = feed.title; + if (feed.description) outline.description = feed.description; + if (feed.htmlUrl) outline.htmlUrl = feed.htmlUrl; + if (feed.language) outline.language = feed.language; outlines.push(outline); } From 6ffbc279d43123fdb45ba5f6fb6d33c236793721 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 15:27:33 -0500 Subject: [PATCH 39/90] test(server): characterize remove-expired-subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the store-state outcomes of removeExpiredSubscriptions() — which feeds and subscriptions survive a sweep — ahead of backing it with @rsscloud/core. These assert the behavior preserved across the swap; the result-shape and counter semantics change in the refactor and are asserted there. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../remove-expired-subscriptions.test.js | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 apps/server/services/remove-expired-subscriptions.test.js diff --git a/apps/server/services/remove-expired-subscriptions.test.js b/apps/server/services/remove-expired-subscriptions.test.js new file mode 100644 index 0000000..42756cc --- /dev/null +++ b/apps/server/services/remove-expired-subscriptions.test.js @@ -0,0 +1,100 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const jsonStore = require('./json-store'); +const removeExpiredSubscriptions = require('./remove-expired-subscriptions'); + +const DAY_MS = 24 * 60 * 60 * 1000; +const iso = offsetMs => new Date(Date.now() + offsetMs).toISOString(); + +const expired = () => iso(-DAY_MS); +const active = () => iso(DAY_MS); +const withinWindow = () => iso(-DAY_MS); +const beyondWindow = () => iso(-10 * DAY_MS); + +function subscription(overrides = {}) { + return { + url: 'http://sub.example.com/notify', + protocol: 'http-post', + whenExpires: active(), + ctConsecutiveErrors: 0, + ...overrides + }; +} + +test.beforeEach(() => { + jsonStore.clear(); +}); + +test('removes an expired subscription and prunes the now-empty feed', async() => { + const feed = 'https://a.example.com/feed.xml'; + jsonStore.setSubscriptions(feed, [ + subscription({ whenExpires: expired() }) + ]); + + const result = await removeExpiredSubscriptions(); + + assert.equal(result.subscriptionsRemoved, 1); + assert.ok(!Object.prototype.hasOwnProperty.call(jsonStore.getData(), feed)); +}); + +test('clears an expired subscription but retains a recently-updated feed', async() => { + const feed = 'https://b.example.com/feed.xml'; + jsonStore.setResource(feed, { + feedTitle: 'Bravo', + whenLastUpdate: withinWindow() + }); + jsonStore.setSubscriptions(feed, [ + subscription({ whenExpires: expired() }) + ]); + + const result = await removeExpiredSubscriptions(); + + assert.equal(result.subscriptionsRemoved, 1); + const data = jsonStore.getData(); + assert.ok(Object.prototype.hasOwnProperty.call(data, feed)); + assert.deepEqual(data[feed].subscribers, []); +}); + +test('removes a feed whose resource is older than the retention window', async() => { + const feed = 'https://c.example.com/feed.xml'; + jsonStore.setResource(feed, { + feedTitle: 'Charlie', + whenLastUpdate: beyondWindow() + }); + jsonStore.setSubscriptions(feed, [ + subscription({ whenExpires: expired() }) + ]); + + await removeExpiredSubscriptions(); + + assert.ok(!Object.prototype.hasOwnProperty.call(jsonStore.getData(), feed)); +}); + +test('leaves active subscriptions untouched', async() => { + const feed = 'https://d.example.com/feed.xml'; + jsonStore.setResource(feed, { + feedTitle: 'Delta', + whenLastUpdate: withinWindow() + }); + jsonStore.setSubscriptions(feed, [subscription({ whenExpires: active() })]); + + const result = await removeExpiredSubscriptions(); + + assert.equal(result.subscriptionsRemoved, 0); + const data = jsonStore.getData(); + assert.ok(Object.prototype.hasOwnProperty.call(data, feed)); + assert.equal(data[feed].subscribers.length, 1); +}); + +test('removes an orphaned resource with no subscriptions', async() => { + const feed = 'https://e.example.com/feed.xml'; + jsonStore.setResource(feed, { + feedTitle: 'Echo', + whenLastUpdate: beyondWindow() + }); + + await removeExpiredSubscriptions(); + + assert.ok(!Object.prototype.hasOwnProperty.call(jsonStore.getData(), feed)); +}); From ff4dca46c2b278dff873f7d089eda72dcb8cc73d Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 15:30:24 -0500 Subject: [PATCH 40/90] refactor(server): back remove-expired-subscriptions with @rsscloud/core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removeExpiredSubscriptions() now delegates to core.removeExpired() instead of sweeping json-store by hand. core reads/writes the shared store through the adapter, so the scheduled job and the /test/removeExpired endpoint produce the same store effects (all 134 e2e green, incl. the 10 RemoveExpiredSubscriptions cases). Three intentional divergences from the retired sweep (PLAN slice E): - returns core's MaintenanceResult (feedsProcessed/feedsDeleted) rather than the legacy documentsProcessed/documentsDeleted/urlsFixed shape — the result is internal-only (logged + echoed by /test), asserted by nobody; - drops the IPv4-mapped-IPv6 callback rewrite: new subs are normalized at subscribe time, so only stale persisted URLs went uncleaned; - treats ctConsecutiveErrors >= maxConsecutiveErrors as exhausted (was strict >), matching core's delivery filter — drops an exhausted sub one error sooner. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../services/remove-expired-subscriptions.js | 151 +++--------------- .../remove-expired-subscriptions.test.js | 36 +++++ 2 files changed, 54 insertions(+), 133 deletions(-) diff --git a/apps/server/services/remove-expired-subscriptions.js b/apps/server/services/remove-expired-subscriptions.js index 7b8a71e..6474f7a 100644 --- a/apps/server/services/remove-expired-subscriptions.js +++ b/apps/server/services/remove-expired-subscriptions.js @@ -1,136 +1,21 @@ -const getDayjs = require('./dayjs-wrapper'); -const jsonStore = require('./json-store'); -const config = require('../config'); - -function shouldRetainEmptyEntry(entry, cutoff, dayjs) { - if (!entry.resource || !entry.resource.whenLastUpdate) { - return false; - } - return dayjs(entry.resource.whenLastUpdate).isAfter(cutoff); -} - -async function removeExpiredSubscriptions() { - try { - const dayjs = await getDayjs(); - const cutoff = dayjs() - .utc() - .subtract(config.feedsChangedWindowDays, 'days'); - - let totalRemoved = 0; - let documentsProcessed = 0; - let documentsDeleted = 0; - - const storeData = jsonStore.getData(); - - for (const [feedUrl, entry] of Object.entries(storeData)) { - documentsProcessed++; - - if ( - !entry.subscribers || - !Array.isArray(entry.subscribers) || - entry.subscribers.length === 0 - ) { - if (shouldRetainEmptyEntry(entry, cutoff, dayjs)) { - continue; - } - jsonStore.removeEntry(feedUrl); - documentsDeleted++; - continue; - } - - // Filter out expired and errored subscriptions - const validSubscriptions = entry.subscribers.filter( - subscription => { - if (dayjs(subscription.whenExpires).isBefore(dayjs())) { - totalRemoved++; - return false; - } - - if ( - subscription.ctConsecutiveErrors > - config.maxConsecutiveErrors - ) { - totalRemoved++; - return false; - } - - return true; - } - ); - - if (validSubscriptions.length !== entry.subscribers.length) { - if (validSubscriptions.length === 0) { - if (shouldRetainEmptyEntry(entry, cutoff, dayjs)) { - jsonStore.setSubscriptions(feedUrl, []); - } else { - jsonStore.removeEntry(feedUrl); - documentsDeleted++; - } - } else { - jsonStore.setSubscriptions(feedUrl, validSubscriptions); - } - } - } - - // Fix IPv4-mapped IPv6 addresses in subscription URLs (e.g. [::ffff:1.2.3.4] -> 1.2.3.4) - let urlsFixed = 0; - const currentData = jsonStore.getData(); - - for (const [feedUrl, entry] of Object.entries(currentData)) { - if ( - !entry.subscribers || - !entry.subscribers.some( - sub => sub.url && sub.url.includes('::ffff:') - ) - ) { - continue; - } - - let changed = false; - const fixedSubscribers = entry.subscribers.map(sub => { - const fixed = sub.url.replace(/\[::ffff:([^\]]+)\]/, '$1'); - if (fixed !== sub.url) { - changed = true; - urlsFixed++; - return Object.assign({}, sub, { url: fixed }); - } - return sub; - }); - - if (changed) { - jsonStore.setSubscriptions(feedUrl, fixedSubscribers); - } - } - - // Find resources with no corresponding subscription and remove them - let orphanedResourcesRemoved = 0; - const latestData = jsonStore.getData(); - - for (const [feedUrl, entry] of Object.entries(latestData)) { - if ( - entry.resource && - Object.keys(entry.resource).length > 0 && - (!entry.subscribers || entry.subscribers.length === 0) - ) { - if (shouldRetainEmptyEntry(entry, cutoff, dayjs)) { - continue; - } - jsonStore.removeEntry(feedUrl); - orphanedResourcesRemoved++; - } - } - - return { - subscriptionsRemoved: totalRemoved, - documentsProcessed, - documentsDeleted, - urlsFixed, - orphanedResourcesRemoved - }; - } catch (error) { - console.error('Error removing expired subscriptions:', error); - throw error; - } +// Drops expired/errored subscriptions and prunes empty feeds. The protocol +// logic lives in @rsscloud/core; this is a thin adapter over core.removeExpired() +// that the server schedules (app.js) and the /test/* API drives. Callers own +// their own error handling, and core reads/writes the shared store, so the +// effects land in the same json-store the legacy /test/getData reads. +// +// Differs from the retired hand-rolled sweep by design (see PLAN slice E): +// - returns core's MaintenanceResult (feedsProcessed/feedsDeleted) instead of +// the legacy documentsProcessed/documentsDeleted/urlsFixed shape; +// - drops the IPv4-mapped-IPv6 callback rewrite (new subs are normalized at +// subscribe time, so only stale persisted URLs went uncleaned); +// - treats ctConsecutiveErrors >= maxConsecutiveErrors as exhausted (was a +// strict >), matching core's delivery filter. + +const { core } = require('../core'); + +function removeExpiredSubscriptions() { + return core.removeExpired(); } module.exports = removeExpiredSubscriptions; diff --git a/apps/server/services/remove-expired-subscriptions.test.js b/apps/server/services/remove-expired-subscriptions.test.js index 42756cc..42e0512 100644 --- a/apps/server/services/remove-expired-subscriptions.test.js +++ b/apps/server/services/remove-expired-subscriptions.test.js @@ -1,6 +1,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); +const config = require('../config'); const jsonStore = require('./json-store'); const removeExpiredSubscriptions = require('./remove-expired-subscriptions'); @@ -98,3 +99,38 @@ test('removes an orphaned resource with no subscriptions', async() => { assert.ok(!Object.prototype.hasOwnProperty.call(jsonStore.getData(), feed)); }); + +test('returns the core MaintenanceResult shape', async() => { + const feed = 'https://f.example.com/feed.xml'; + jsonStore.setSubscriptions(feed, [ + subscription({ whenExpires: expired() }) + ]); + + const result = await removeExpiredSubscriptions(); + + assert.deepEqual(result, { + subscriptionsRemoved: 1, + feedsProcessed: 1, + feedsDeleted: 1, + orphanedResourcesRemoved: 0 + }); +}); + +test('removes a subscription that has reached the consecutive-error limit', async() => { + const feed = 'https://g.example.com/feed.xml'; + jsonStore.setResource(feed, { + feedTitle: 'Golf', + whenLastUpdate: withinWindow() + }); + jsonStore.setSubscriptions(feed, [ + subscription({ + whenExpires: active(), + ctConsecutiveErrors: config.maxConsecutiveErrors + }) + ]); + + const result = await removeExpiredSubscriptions(); + + assert.equal(result.subscriptionsRemoved, 1); + assert.deepEqual(jsonStore.getData()[feed].subscribers, []); +}); From 8cd7b6adcce0a6c4c0eb15d175555aa924f22231 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 15:33:50 -0500 Subject: [PATCH 41/90] refactor(server): delete the orphaned legacy notify/parse services With removeExpired (slice E) and stats (slice 6) now on @rsscloud/core, nothing external requires the hand-rolled protocol cluster anymore. The live doors come from @rsscloud/express; these 12 modules only required each other. Deleted: ping, please-notify, notify-one, notify-one-challenge, notify-subscribers, error-response, log-event, parse-feed, init-resource, init-subscription, get-random-password, app-messages. Kept: dayjs-wrapper (still used by app.js + feeds-opml). Completes PLAN step A. 26 server unit tests + 134 e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/services/app-messages.js | 32 ----- apps/server/services/error-response.js | 10 -- apps/server/services/get-random-password.js | 12 -- apps/server/services/init-resource.js | 17 --- apps/server/services/init-subscription.js | 44 ------ apps/server/services/log-event.js | 24 ---- apps/server/services/notify-one-challenge.js | 54 ------- apps/server/services/notify-one.js | 104 -------------- apps/server/services/notify-subscribers.js | 100 ------------- apps/server/services/parse-feed.js | 124 ---------------- apps/server/services/ping.js | 141 ------------------- apps/server/services/please-notify.js | 126 ----------------- 12 files changed, 788 deletions(-) delete mode 100644 apps/server/services/app-messages.js delete mode 100644 apps/server/services/error-response.js delete mode 100644 apps/server/services/get-random-password.js delete mode 100644 apps/server/services/init-resource.js delete mode 100644 apps/server/services/init-subscription.js delete mode 100644 apps/server/services/log-event.js delete mode 100644 apps/server/services/notify-one-challenge.js delete mode 100644 apps/server/services/notify-one.js delete mode 100644 apps/server/services/notify-subscribers.js delete mode 100644 apps/server/services/parse-feed.js delete mode 100644 apps/server/services/ping.js delete mode 100644 apps/server/services/please-notify.js diff --git a/apps/server/services/app-messages.js b/apps/server/services/app-messages.js deleted file mode 100644 index 7cdff22..0000000 --- a/apps/server/services/app-messages.js +++ /dev/null @@ -1,32 +0,0 @@ -module.exports = { - error: { - subscription: { - missingParams: params => - `The following parameters were missing from the request body: ${params}.`, - invalidProtocol: protocol => - `Can't accept the subscription because the protocol, ${protocol}, is unsupported.`, - readResource: url => - `The subscription was cancelled because there was an error reading the resource at URL ${url}.`, - noResources: 'No resources specified.', - failedHandler: - 'The subscription was cancelled because the call failed when we tested the handler.' - }, - ping: { - tooRecent: (minSeconds, lastPingSeconds) => - `Can't accept the request because the minimum seconds between pings is ${minSeconds} and you pinged us ${lastPingSeconds} seconds ago.`, - readResource: url => - `The ping was cancelled because there was an error reading the resource at URL ${url}.` - }, - rpc: { - notEnoughParams: method => - `Can't call "${method}" because there aren't enough parameters.`, - tooManyParams: method => - `Can't call "${method}" because there are too many parameters.` - } - }, - success: { - subscription: - 'Thanks for the registration. It worked. When the resource updates we\'ll notify you. Don\'t forget to re-register after 24 hours, your subscription will expire in 25. Keep on truckin!', - ping: 'Thanks for the ping.' - } -}; diff --git a/apps/server/services/error-response.js b/apps/server/services/error-response.js deleted file mode 100644 index 5e48ee4..0000000 --- a/apps/server/services/error-response.js +++ /dev/null @@ -1,10 +0,0 @@ -function ErrorResponse(message, code) { - this.message = message; - this.code = code; -} - -ErrorResponse.prototype = Object.create(Error.prototype); -ErrorResponse.prototype.constructor = ErrorResponse; -ErrorResponse.prototype.name = 'ErrorResponse'; - -module.exports = ErrorResponse; diff --git a/apps/server/services/get-random-password.js b/apps/server/services/get-random-password.js deleted file mode 100644 index e55bd71..0000000 --- a/apps/server/services/get-random-password.js +++ /dev/null @@ -1,12 +0,0 @@ -const crypto = require('crypto'); - -function getRandomPassword(len) { - return crypto - .randomBytes(Math.ceil((len * 3) / 4)) - .toString('base64') // convert to base64 format - .slice(0, len) // return required number of characters - .replace(/\+/g, '0') // replace '+' with '0' - .replace(/\//g, '0'); // replace '/' with '0' -} - -module.exports = getRandomPassword; diff --git a/apps/server/services/init-resource.js b/apps/server/services/init-resource.js deleted file mode 100644 index 2a7f008..0000000 --- a/apps/server/services/init-resource.js +++ /dev/null @@ -1,17 +0,0 @@ -const getDayjs = require('./dayjs-wrapper'); - -async function initResource(resource) { - const dayjs = await getDayjs(); - const defaultResource = { - lastSize: 0, - lastHash: '', - ctChecks: 0, - whenLastCheck: new Date(dayjs.utc('0', 'x').format()), - ctUpdates: 0, - whenLastUpdate: new Date(dayjs.utc('0', 'x').format()) - }; - - return Object.assign({}, defaultResource, resource); -} - -module.exports = initResource; diff --git a/apps/server/services/init-subscription.js b/apps/server/services/init-subscription.js deleted file mode 100644 index c4d5c51..0000000 --- a/apps/server/services/init-subscription.js +++ /dev/null @@ -1,44 +0,0 @@ -const config = require('../config'), - getDayjs = require('./dayjs-wrapper'); - -async function initSubscription( - subscriptions, - notifyProcedure, - apiurl, - protocol -) { - const dayjs = await getDayjs(); - const defaultSubscription = { - ctUpdates: 0, - whenLastUpdate: new Date(dayjs.utc('0', 'x').format()), - ctErrors: 0, - ctConsecutiveErrors: 0, - whenLastError: new Date(dayjs.utc('0', 'x').format()), - whenExpires: new Date( - dayjs() - .utc() - .add(config.ctSecsResourceExpire, 'seconds') - .format() - ), - url: apiurl, - notifyProcedure, - protocol - }, - index = subscriptions.pleaseNotify.findIndex(subscription => { - return subscription.url === apiurl; - }); - - if (-1 === index) { - subscriptions.pleaseNotify.push(defaultSubscription); - } else { - subscriptions.pleaseNotify[index] = Object.assign( - {}, - defaultSubscription, - subscriptions.pleaseNotify[index] - ); - } - - return subscriptions; -} - -module.exports = initSubscription; diff --git a/apps/server/services/log-event.js b/apps/server/services/log-event.js deleted file mode 100644 index 2f44f5a..0000000 --- a/apps/server/services/log-event.js +++ /dev/null @@ -1,24 +0,0 @@ -const getDayjs = require('./dayjs-wrapper'), - websocket = require('./websocket'); - -async function logEvent(eventtype, data, startticks, req) { - const dayjs = await getDayjs(); - let secs, time; - - time = dayjs(); - secs = (parseInt(time.format('x'), 10) - parseInt(startticks, 10)) / 1000; - - if (undefined === req) { - req = { headers: false }; - } - - websocket.broadcast({ - eventtype, - data, - secs, - time: new Date(time.utc().format()), - headers: req.headers - }); -} - -module.exports = logEvent; diff --git a/apps/server/services/notify-one-challenge.js b/apps/server/services/notify-one-challenge.js deleted file mode 100644 index b2432ba..0000000 --- a/apps/server/services/notify-one-challenge.js +++ /dev/null @@ -1,54 +0,0 @@ -const config = require('../config'), - ErrorResponse = require('./error-response'), - notifyOne = require('./notify-one'), - getRandomPassword = require('./get-random-password'), - querystring = require('querystring'); - -async function notifyOneChallengeRest(apiurl, resourceUrl) { - const challenge = getRandomPassword(20); - const testUrl = - apiurl + - '?' + - querystring.stringify({ - url: resourceUrl, - challenge: challenge - }); - - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(), - config.requestTimeout - ); - - try { - const res = await fetch(testUrl, { - method: 'GET', - signal: controller.signal - }); - - clearTimeout(timeoutId); - - const body = await res.text(); - - if (res.status < 200 || res.status > 299 || body !== challenge) { - throw new ErrorResponse('Notification Failed'); - } - } catch (err) { - clearTimeout(timeoutId); - if (err.name === 'AbortError') { - throw new ErrorResponse('Notification Failed - Timeout'); - } - throw err; - } -} - -function notifyOneChallenge(notifyProcedure, apiurl, protocol, resourceUrl) { - if ('xml-rpc' === protocol) { - // rssCloud.root originally didn't support this flow - return notifyOne(notifyProcedure, apiurl, protocol, resourceUrl); - } - - return notifyOneChallengeRest(apiurl, resourceUrl); -} - -module.exports = notifyOneChallenge; diff --git a/apps/server/services/notify-one.js b/apps/server/services/notify-one.js deleted file mode 100644 index a2920aa..0000000 --- a/apps/server/services/notify-one.js +++ /dev/null @@ -1,104 +0,0 @@ -const builder = require('xmlbuilder'), - config = require('../config'), - ErrorResponse = require('./error-response'), - { URL } = require('url'); - -async function notifyOneRest(apiurl, resourceUrl) { - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(), - config.requestTimeout - ); - - try { - const formData = new URLSearchParams(); - formData.append('url', resourceUrl); - - const res = await fetch(apiurl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: formData, - signal: controller.signal, - redirect: 'manual' - }); - - clearTimeout(timeoutId); - - // Handle redirects manually - if (res.status >= 300 && res.status < 400) { - const location = res.headers.get('location'); - if (location) { - const redirectUrl = new URL(location, apiurl); - return notifyOneRest(redirectUrl.toString(), resourceUrl); - } - } - - if (res.status < 200 || res.status > 299) { - throw new ErrorResponse('Notification Failed'); - } - - return true; - } catch (err) { - clearTimeout(timeoutId); - if (err.name === 'AbortError') { - throw new ErrorResponse('Notification Failed - Timeout'); - } - throw err; - } -} - -async function notifyOneRpc(notifyProcedure, apiurl, resourceUrl) { - const xmldoc = builder - .create({ - methodCall: { - methodName: notifyProcedure, - params: { - param: [{ value: resourceUrl }] - } - } - }) - .end({ pretty: true }); - - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(), - config.requestTimeout - ); - - try { - const res = await fetch(apiurl, { - method: 'POST', - headers: { - 'Content-Type': 'text/xml' - }, - body: xmldoc, - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (res.status < 200 || res.status > 299) { - throw new ErrorResponse('Notification Failed'); - } - - return true; - } catch (err) { - clearTimeout(timeoutId); - if (err.name === 'AbortError') { - throw new ErrorResponse('Notification Failed - Timeout'); - } - throw err; - } -} - -function notifyOne(notifyProcedure, apiurl, protocol, resourceUrl) { - if ('xml-rpc' === protocol) { - return notifyOneRpc(notifyProcedure, apiurl, resourceUrl); - } - - return notifyOneRest(apiurl, resourceUrl); -} - -module.exports = notifyOne; diff --git a/apps/server/services/notify-subscribers.js b/apps/server/services/notify-subscribers.js deleted file mode 100644 index d5edb3f..0000000 --- a/apps/server/services/notify-subscribers.js +++ /dev/null @@ -1,100 +0,0 @@ -const config = require('../config'), - getDayjs = require('./dayjs-wrapper'), - jsonStore = require('./json-store'), - logEvent = require('./log-event'), - notifyOne = require('./notify-one'); - -function fetchSubscriptions(resourceUrl) { - return jsonStore.getSubscriptions(resourceUrl); -} - -function upsertSubscriptions(subscriptions) { - jsonStore.setSubscriptions(subscriptions._id, subscriptions.pleaseNotify); -} - -async function notifyOneSubscriber(resourceUrl, subscription) { - const dayjs = await getDayjs(); - const apiurl = subscription.url, - startticks = dayjs().format('x'), - notifyProcedure = subscription.notifyProcedure, - protocol = subscription.protocol; - - try { - await notifyOne(notifyProcedure, apiurl, protocol, resourceUrl); - - subscription.ctUpdates += 1; - subscription.ctConsecutiveErrors = 0; - subscription.whenLastUpdate = new Date(dayjs().utc().format()); - - await logEvent( - 'Notify', - { - subscriberUrl: apiurl, - notifyProcedure: notifyProcedure, - protocol: protocol, - resourceUrl: resourceUrl, - subscription: { - totalUpdates: subscription.ctUpdates, - consecutiveErrors: subscription.ctConsecutiveErrors, - totalErrors: subscription.ctErrors - } - }, - startticks - ); - } catch (err) { - console.error(err.message); - - subscription.ctErrors += 1; - subscription.ctConsecutiveErrors += 1; - subscription.whenLastError = new Date(dayjs().utc().format()); - - await logEvent( - 'NotifyFailed', - { - subscriberUrl: apiurl, - notifyProcedure: notifyProcedure, - protocol: protocol, - resourceUrl: resourceUrl, - subscription: { - totalUpdates: subscription.ctUpdates, - consecutiveErrors: subscription.ctConsecutiveErrors, - totalErrors: subscription.ctErrors - }, - error: err.message - }, - startticks - ); - } -} - -async function filterSubscribers(subscription) { - const dayjs = await getDayjs(); - if (dayjs().isAfter(subscription.whenExpires)) { - return false; - } - - if (subscription.ctConsecutiveErrors >= config.maxConsecutiveErrors) { - return false; - } - - return true; -} - -async function notifySubscribers(resourceUrl) { - const subscriptions = await fetchSubscriptions(resourceUrl); - - const validSubscriptions = []; - for (const subscription of subscriptions.pleaseNotify) { - if (await filterSubscribers(subscription)) { - validSubscriptions.push(subscription); - } - } - - await Promise.all( - validSubscriptions.map(notifyOneSubscriber.bind(null, resourceUrl)) - ); - - upsertSubscriptions(subscriptions); -} - -module.exports = notifySubscribers; diff --git a/apps/server/services/parse-feed.js b/apps/server/services/parse-feed.js deleted file mode 100644 index 92b67e3..0000000 --- a/apps/server/services/parse-feed.js +++ /dev/null @@ -1,124 +0,0 @@ -const xml2js = require('xml2js'), - config = require('../config'); - -const parser = new xml2js.Parser({ - explicitArray: false, - mergeAttrs: false, - trim: true -}); - -function extractText(node) { - if (node === null || node === undefined) return null; - if (typeof node === 'string') { - const trimmed = node.trim(); - return trimmed.length ? trimmed : null; - } - if (typeof node === 'object') { - if (typeof node._ === 'string') { - const trimmed = node._.trim(); - return trimmed.length ? trimmed : null; - } - if (typeof node['#text'] === 'string') { - const trimmed = node['#text'].trim(); - return trimmed.length ? trimmed : null; - } - } - return null; -} - -function pickAtomHtmlLink(linkNode) { - if (!linkNode) return null; - - if (typeof linkNode === 'string') { - return extractText(linkNode); - } - - const candidates = Array.isArray(linkNode) ? linkNode : [linkNode]; - let fallback = null; - - for (const link of candidates) { - if (typeof link === 'string') { - if (!fallback) fallback = link.trim() || null; - continue; - } - if (typeof link !== 'object') continue; - - const attrs = link.$ || {}; - const href = attrs.href; - if (!href) continue; - - const rel = attrs.rel; - const type = attrs.type; - const isHtmlish = - !type || - type.startsWith('text/html') || - type === 'application/xhtml+xml'; - - if ((!rel || rel === 'alternate') && isHtmlish) { - return href; - } - if (!fallback && (!rel || rel === 'alternate')) { - fallback = href; - } - } - - return fallback; -} - -function fromRss(channel) { - return { - type: 'rss', - title: extractText(channel.title), - description: extractText(channel.description), - htmlUrl: extractText(channel.link), - language: extractText(channel.language) - }; -} - -function fromRdf(channel) { - return { - type: 'rss', - title: extractText(channel.title), - description: extractText(channel.description), - htmlUrl: extractText(channel.link), - language: - extractText(channel.language) || extractText(channel['dc:language']) - }; -} - -function fromAtom(feed) { - const lang = feed.$ && (feed.$['xml:lang'] || feed.$.lang); - return { - type: 'atom', - title: extractText(feed.title), - description: extractText(feed.subtitle) || extractText(feed.tagline), - htmlUrl: pickAtomHtmlLink(feed.link), - language: lang ? String(lang).trim() || null : null - }; -} - -async function parseFeed(body) { - if (!body || typeof body !== 'string') return null; - if (body.length > config.maxResourceSize) return null; - - let parsed; - try { - parsed = await parser.parseStringPromise(body); - } catch { - return null; - } - if (!parsed) return null; - - if (parsed.rss && parsed.rss.channel) { - return fromRss(parsed.rss.channel); - } - if (parsed['rdf:RDF'] && parsed['rdf:RDF'].channel) { - return fromRdf(parsed['rdf:RDF'].channel); - } - if (parsed.feed) { - return fromAtom(parsed.feed); - } - return null; -} - -module.exports = parseFeed; diff --git a/apps/server/services/ping.js b/apps/server/services/ping.js deleted file mode 100644 index 5248224..0000000 --- a/apps/server/services/ping.js +++ /dev/null @@ -1,141 +0,0 @@ -const appMessage = require('./app-messages'), - config = require('../config'), - crypto = require('crypto'), - ErrorResponse = require('./error-response'), - getDayjs = require('./dayjs-wrapper'), - initResource = require('./init-resource'), - jsonStore = require('./json-store'), - logEvent = require('./log-event'), - notifySubscribers = require('./notify-subscribers'), - parseFeed = require('./parse-feed'); - -async function checkPingFrequency(resource) { - let ctsecs, - minsecs = config.minSecsBetweenPings; - if (0 < minsecs) { - const dayjs = await getDayjs(); - ctsecs = dayjs().diff(resource.whenLastCheck, 'seconds'); - if (ctsecs < minsecs) { - throw new ErrorResponse( - appMessage.error.ping.tooRecent(minsecs, ctsecs), - 'PING_TOO_RECENT' - ); - } - } -} - -function md5Hash(value) { - return crypto.createHash('md5').update(value).digest('hex'); -} - -async function checkForResourceChange(resource, resourceUrl, startticks) { - let res; - let body = ''; - - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(), - config.requestTimeout - ); - - try { - res = await fetch(resourceUrl, { - method: 'GET', - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (res.status >= 200 && res.status <= 299) { - body = await res.text(); - } - } catch { - clearTimeout(timeoutId); - res = { status: 404 }; - } - - const dayjs = await getDayjs(); - resource.ctChecks += 1; - resource.whenLastCheck = new Date(dayjs().utc().format()); - - if (res.status < 200 || res.status > 299) { - throw new ErrorResponse( - appMessage.error.ping.readResource(resourceUrl) - ); - } - - const hash = md5Hash(body); - - const changed = - resource.lastHash !== hash || resource.lastSize !== body.length; - - resource.lastHash = hash; - resource.lastSize = body.length; - - if (body && (changed || !resource.feedTitle)) { - const meta = await parseFeed(body); - if (meta) { - if (meta.type) resource.feedType = meta.type; - if (meta.title) resource.feedTitle = meta.title; - if (meta.description) resource.feedDescription = meta.description; - if (meta.htmlUrl) resource.feedHtmlUrl = meta.htmlUrl; - if (meta.language) resource.feedLanguage = meta.language; - } - } - - await logEvent( - 'Ping', - { - resourceUrl: resourceUrl, - changed: changed, - hash: resource.lastHash, - size: resource.lastSize, - stats: { - totalChecks: resource.ctChecks, - totalUpdates: resource.ctUpdates - } - }, - startticks - ); - - return changed; -} - -function fetchResource(resourceUrl) { - return jsonStore.getResource(resourceUrl) || { _id: resourceUrl }; -} - -function upsertResource(resource) { - jsonStore.setResource(resource._id, resource); -} - -async function notifySubscribersIfDirty(changed, resource, resourceUrl) { - if (changed) { - const dayjs = await getDayjs(); - resource.ctUpdates += 1; - resource.whenLastUpdate = new Date(dayjs().utc().format()); - return await notifySubscribers(resourceUrl); - } -} - -async function ping(resourceUrl) { - const dayjs = await getDayjs(); - const startticks = dayjs().format('x'), - resource = await initResource(await fetchResource(resourceUrl)); - - await checkPingFrequency(resource); - const changed = await checkForResourceChange( - resource, - resourceUrl, - startticks - ); - await notifySubscribersIfDirty(changed, resource, resourceUrl); - upsertResource(resource); - - return { - success: true, - msg: appMessage.success.ping - }; -} - -module.exports = ping; diff --git a/apps/server/services/please-notify.js b/apps/server/services/please-notify.js deleted file mode 100644 index 756ef02..0000000 --- a/apps/server/services/please-notify.js +++ /dev/null @@ -1,126 +0,0 @@ -const appMessages = require('./app-messages'), - config = require('../config'), - ErrorResponse = require('./error-response'), - getDayjs = require('./dayjs-wrapper'), - initSubscription = require('./init-subscription'), - jsonStore = require('./json-store'), - logEvent = require('./log-event'), - notifyOne = require('./notify-one'), - notifyOneChallenge = require('./notify-one-challenge'), - ping = require('./ping'); - -function fetchSubscriptions(resourceUrl) { - return jsonStore.getSubscriptions(resourceUrl); -} - -function upsertSubscriptions(subscriptions) { - jsonStore.setSubscriptions(subscriptions._id, subscriptions.pleaseNotify); -} - -async function notifyApiUrl( - notifyProcedure, - apiurl, - protocol, - resourceUrl, - diffDomain -) { - const dayjs = await getDayjs(); - const subscriptions = await fetchSubscriptions(resourceUrl), - startticks = dayjs().format('x'); - - await initSubscription(subscriptions, notifyProcedure, apiurl, protocol); - - try { - if (diffDomain) { - await notifyOneChallenge( - notifyProcedure, - apiurl, - protocol, - resourceUrl - ); - } else { - await notifyOne(notifyProcedure, apiurl, protocol, resourceUrl); - } - - const index = subscriptions.pleaseNotify.findIndex(subscription => { - return subscription.url === apiurl; - }); - - subscriptions.pleaseNotify[index].ctUpdates += 1; - subscriptions.pleaseNotify[index].ctConsecutiveErrors = 0; - subscriptions.pleaseNotify[index].whenLastUpdate = new Date( - dayjs().utc().format() - ); - subscriptions.pleaseNotify[index].whenExpires = dayjs() - .utc() - .add(config.ctSecsResourceExpire, 'seconds') - .format(); - - upsertSubscriptions(subscriptions); - - await logEvent( - 'Subscribe', - { - subscriberUrl: apiurl, - notifyProcedure: notifyProcedure, - resourceUrl: resourceUrl, - diffDomain: diffDomain - }, - startticks - ); - } catch { - throw new ErrorResponse(appMessages.error.subscription.failedHandler); - } -} - -async function pleaseNotify( - notifyProcedure, - apiurl, - protocol, - urlList, - diffDomain -) { - if (0 === urlList.length) { - throw new ErrorResponse(appMessages.error.subscription.noResources); - } - - const results = await Promise.allSettled( - urlList.map(async resourceUrl => { - try { - await ping(resourceUrl); - } catch (err) { - if (err.code !== 'PING_TOO_RECENT') { - throw new ErrorResponse( - appMessages.error.subscription.readResource(resourceUrl) - ); - } - } - await notifyApiUrl( - notifyProcedure, - apiurl, - protocol, - resourceUrl, - diffDomain - ); - }) - ); - - // Check if all operations failed - const rejectedResults = results.filter( - result => result.status === 'rejected' - ); - if ( - rejectedResults.length === results.length && - rejectedResults.length > 0 - ) { - // If all operations failed, throw the last error - throw rejectedResults[rejectedResults.length - 1].reason; - } - - return { - success: true, - msg: appMessages.success.subscription - }; -} - -module.exports = pleaseNotify; From b5599151d4c8eceb5caa81e5cba9ff9676e76591 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 17:18:00 -0500 Subject: [PATCH 42/90] refactor(server): route /test/* + /subscriptions.json through the core store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Funnels the last two direct json-store consumers in the controllers through core's async Store interface, ahead of swapping the backing to createFileStore (PLAN slice B). The /test/* harness and the /subscriptions.json raw-data view still speak the legacy wire shape (e2e drives them unchanged), so the legacy<-> core mappers are extracted into services/legacy-store-shape.js — one source of truth, also consumed by core-store-adapter.js (its 9 tests prove the extraction is behaviour-identical) and unit-tested directly. After this, only core.js (via the adapter) and app.js touch json-store. Backing is unchanged — still json-store via the adapter — so this isolates mapper fidelity from the upcoming backing swap. 32 server unit + 134 e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/controllers/index.js | 14 +- apps/server/controllers/test.js | 57 +++++--- apps/server/services/core-store-adapter.js | 117 ++------------- apps/server/services/legacy-store-shape.js | 137 ++++++++++++++++++ .../services/legacy-store-shape.test.js | 117 +++++++++++++++ 5 files changed, 311 insertions(+), 131 deletions(-) create mode 100644 apps/server/services/legacy-store-shape.js create mode 100644 apps/server/services/legacy-store-shape.test.js diff --git a/apps/server/controllers/index.js b/apps/server/controllers/index.js index 7ef67c7..9b57578 100644 --- a/apps/server/controllers/index.js +++ b/apps/server/controllers/index.js @@ -1,10 +1,10 @@ const express = require('express'), fs = require('fs'), md = require('markdown-it')(), - jsonStore = require('../services/json-store'), { generateOpml } = require('../services/feeds-opml'), + { toLegacyData } = require('../services/legacy-store-shape'), { ping, pleaseNotify, rpc2 } = require('@rsscloud/express'), - { core } = require('../core'), + { core, store } = require('../core'), router = new express.Router(); // Core-backed protocol front doors (@rsscloud/express driving @rsscloud/core). @@ -45,9 +45,13 @@ router.get('/stats.json', (req, res) => { res.send(JSON.stringify(getStats(), null, 2)); }); -router.get('/subscriptions.json', (req, res) => { - res.set('Content-Type', 'application/json'); - res.send(JSON.stringify(jsonStore.getData(), null, 2)); +router.get('/subscriptions.json', async(req, res, next) => { + try { + res.set('Content-Type', 'application/json'); + res.send(JSON.stringify(toLegacyData(await store.list()), null, 2)); + } catch (err) { + next(err); + } }); router.get('/feeds.opml', async(req, res, next) => { diff --git a/apps/server/controllers/test.js b/apps/server/controllers/test.js index ceb634e..d7012f4 100644 --- a/apps/server/controllers/test.js +++ b/apps/server/controllers/test.js @@ -1,5 +1,12 @@ const express = require('express'), - jsonStore = require('../services/json-store'), + { store } = require('../core'), + { + toCoreResource, + toLegacyResource, + toCoreSubscription, + toLegacySubscription, + toLegacyData + } = require('../services/legacy-store-shape'), removeExpiredSubscriptions = require('../services/remove-expired-subscriptions'), router = new express.Router(); @@ -9,62 +16,74 @@ console.warn( router.use(express.json()); -router.post('/clear', (req, res) => { +router.post('/clear', async(req, res) => { try { - jsonStore.clear(); + for (const { feedUrl } of await store.list()) { + await store.remove(feedUrl); + } res.json({ success: true }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); -router.post('/setResource', (req, res) => { +router.post('/setResource', async(req, res) => { try { const { feedUrl, resource } = req.body; - jsonStore.setResource(feedUrl, resource); + await store.putResource(feedUrl, toCoreResource(feedUrl, resource)); res.json({ success: true }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); -router.post('/getResource', (req, res) => { +router.post('/getResource', async(req, res) => { try { const { feedUrl } = req.body; - const resource = jsonStore.getResource(feedUrl); - res.json({ success: true, found: resource !== null, resource }); + const resource = await store.getResource(feedUrl); + res.json({ + success: true, + found: resource !== null, + resource: + resource !== null + ? { _id: feedUrl, ...toLegacyResource(resource) } + : null + }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); -router.post('/setSubscriptions', (req, res) => { +router.post('/setSubscriptions', async(req, res) => { try { const { feedUrl, pleaseNotify } = req.body; - jsonStore.setSubscriptions(feedUrl, pleaseNotify); + await store.putSubscriptions(feedUrl, pleaseNotify.map(toCoreSubscription)); res.json({ success: true }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); -router.post('/getSubscriptions', (req, res) => { +router.post('/getSubscriptions', async(req, res) => { try { const { feedUrl } = req.body; - const data = jsonStore.getData(); - const found = - Object.prototype.hasOwnProperty.call(data, feedUrl) && - Array.isArray(data[feedUrl].subscribers); - const subscriptions = jsonStore.getSubscriptions(feedUrl); - res.json({ success: true, found, subscriptions }); + const entry = (await store.list()).find(e => e.feedUrl === feedUrl); + const pleaseNotify = entry + ? entry.subscriptions.map(toLegacySubscription) + : []; + res.json({ + success: true, + found: entry !== undefined, + subscriptions: { _id: feedUrl, pleaseNotify } + }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); -router.post('/getData', (req, res) => { +router.post('/getData', async(req, res) => { try { - res.json({ success: true, data: jsonStore.getData() }); + res.json({ success: true, data: toLegacyData(await store.list()) }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } diff --git a/apps/server/services/core-store-adapter.js b/apps/server/services/core-store-adapter.js index 27ce10e..8d483d2 100644 --- a/apps/server/services/core-store-adapter.js +++ b/apps/server/services/core-store-adapter.js @@ -2,113 +2,16 @@ // during the migration onto @rsscloud/core. core writes/reads through this // adapter while the legacy services and the /test/* API keep using json-store // directly, so both share one in-memory store. The legacy<->core shape mapping -// here mirrors packages/core/src/store/file-store.ts (the eventual replacement); -// keep them in sync until json-store is retired and core uses createFileStore. - -function toCoreFeed(raw) { - const feed = {}; - if (raw.feedType != null) feed.type = raw.feedType; - if (raw.feedTitle != null) feed.title = raw.feedTitle; - if (raw.feedDescription != null) feed.description = raw.feedDescription; - if (raw.feedHtmlUrl != null) feed.htmlUrl = raw.feedHtmlUrl; - if (raw.feedLanguage != null) feed.language = raw.feedLanguage; - return Object.keys(feed).length > 0 ? feed : undefined; -} - -function toCoreResource(feedUrl, raw) { - // A missing entry or a resource with no real fields (json-store returns - // `{ _id }` for an empty `{}` resource) is "no resource". - if (raw == null) return null; - if (Object.keys(raw).filter(key => key !== '_id').length === 0) return null; - const resource = { - url: feedUrl, - lastHash: raw.lastHash ?? '', - lastSize: raw.lastSize ?? 0, - ctChecks: raw.ctChecks ?? 0, - whenLastCheck: new Date(raw.whenLastCheck ?? 0), - ctUpdates: raw.ctUpdates ?? 0, - whenLastUpdate: new Date(raw.whenLastUpdate ?? 0) - }; - const feed = toCoreFeed(raw); - if (feed !== undefined) resource.feed = feed; - return resource; -} - -function toLegacyResource(resource) { - const out = { - lastSize: resource.lastSize, - lastHash: resource.lastHash, - ctChecks: resource.ctChecks, - whenLastCheck: resource.whenLastCheck.toISOString(), - ctUpdates: resource.ctUpdates, - whenLastUpdate: resource.whenLastUpdate.toISOString() - }; - const feed = resource.feed; - if (feed !== undefined) { - if (feed.type != null) out.feedType = feed.type; - if (feed.title != null) out.feedTitle = feed.title; - if (feed.description != null) out.feedDescription = feed.description; - if (feed.htmlUrl != null) out.feedHtmlUrl = feed.htmlUrl; - if (feed.language != null) out.feedLanguage = feed.language; - } - return out; -} - -const EPOCH_ISO = new Date(0).toISOString(); - -// Epoch ("never happened" on disk) maps to `null` in the core model. -function toNullableDate(value) { - const date = new Date(value ?? 0); - return date.getTime() === 0 ? null : date; -} - -// `null` ("never") serializes back to the epoch string the legacy reader uses. -function fromNullableDate(value) { - return value === null ? EPOCH_ISO : value.toISOString(); -} - -function toCoreSubscription(raw) { - const whenExpires = new Date(raw.whenExpires ?? 0); - const subscription = { - url: raw.url, - protocol: raw.protocol, - ctUpdates: raw.ctUpdates ?? 0, - ctErrors: raw.ctErrors ?? 0, - ctConsecutiveErrors: raw.ctConsecutiveErrors ?? 0, - // Legacy records carry no creation time; synthesize from expiry. - whenCreated: - raw.whenCreated != null ? new Date(raw.whenCreated) : whenExpires, - whenLastUpdate: toNullableDate(raw.whenLastUpdate), - whenLastError: toNullableDate(raw.whenLastError), - whenExpires - }; - if (typeof raw.notifyProcedure === 'string') { - subscription.notifyProcedure = raw.notifyProcedure; - } - if (raw.details !== undefined) { - subscription.details = raw.details; - } - return subscription; -} - -function toLegacySubscription(subscription) { - const out = { - ctUpdates: subscription.ctUpdates, - whenLastUpdate: fromNullableDate(subscription.whenLastUpdate), - ctErrors: subscription.ctErrors, - ctConsecutiveErrors: subscription.ctConsecutiveErrors, - whenLastError: fromNullableDate(subscription.whenLastError), - whenExpires: subscription.whenExpires.toISOString(), - url: subscription.url, - // REST subs carry no procedure; the legacy shape records that as `false`. - notifyProcedure: subscription.notifyProcedure ?? false, - protocol: subscription.protocol - }; - if (subscription.details !== undefined) { - out.details = subscription.details; - } - return out; -} +// lives in legacy-store-shape.js (shared with the /test/* + /subscriptions.json +// seams); keep it in sync with packages/core/src/store/file-store.ts until +// json-store is retired and core uses createFileStore. + +const { + toCoreResource, + toLegacyResource, + toCoreSubscription, + toLegacySubscription +} = require('./legacy-store-shape'); function createJsonStoreAdapter(jsonStore) { return { diff --git a/apps/server/services/legacy-store-shape.js b/apps/server/services/legacy-store-shape.js new file mode 100644 index 0000000..29d6005 --- /dev/null +++ b/apps/server/services/legacy-store-shape.js @@ -0,0 +1,137 @@ +// The legacy on-disk / json-store wire shape (keyed by feed URL, flat feed +// fields, string dates, `_id`, `pleaseNotify`) mapped to and from core's model. +// +// core's `Store` interface speaks the core model (Date objects, nested `feed`, +// `subscriptions`). Two server seams still speak the legacy shape and need this +// translation: the `/test/*` harness the e2e suite drives, and the +// `/subscriptions.json` raw-data view. core-store-adapter.js consumes the same +// mappers so there is a single source of truth; this mirrors +// packages/core/src/store/file-store.ts (core's own copy at the disk boundary). + +function toCoreFeed(raw) { + const feed = {}; + if (raw.feedType != null) feed.type = raw.feedType; + if (raw.feedTitle != null) feed.title = raw.feedTitle; + if (raw.feedDescription != null) feed.description = raw.feedDescription; + if (raw.feedHtmlUrl != null) feed.htmlUrl = raw.feedHtmlUrl; + if (raw.feedLanguage != null) feed.language = raw.feedLanguage; + return Object.keys(feed).length > 0 ? feed : undefined; +} + +function toCoreResource(feedUrl, raw) { + // A missing entry or a resource with no real fields (json-store returns + // `{ _id }` for an empty `{}` resource) is "no resource". + if (raw == null) return null; + if (Object.keys(raw).filter(key => key !== '_id').length === 0) return null; + const resource = { + url: feedUrl, + lastHash: raw.lastHash ?? '', + lastSize: raw.lastSize ?? 0, + ctChecks: raw.ctChecks ?? 0, + whenLastCheck: new Date(raw.whenLastCheck ?? 0), + ctUpdates: raw.ctUpdates ?? 0, + whenLastUpdate: new Date(raw.whenLastUpdate ?? 0) + }; + const feed = toCoreFeed(raw); + if (feed !== undefined) resource.feed = feed; + return resource; +} + +function toLegacyResource(resource) { + const out = { + lastSize: resource.lastSize, + lastHash: resource.lastHash, + ctChecks: resource.ctChecks, + whenLastCheck: resource.whenLastCheck.toISOString(), + ctUpdates: resource.ctUpdates, + whenLastUpdate: resource.whenLastUpdate.toISOString() + }; + const feed = resource.feed; + if (feed !== undefined) { + if (feed.type != null) out.feedType = feed.type; + if (feed.title != null) out.feedTitle = feed.title; + if (feed.description != null) out.feedDescription = feed.description; + if (feed.htmlUrl != null) out.feedHtmlUrl = feed.htmlUrl; + if (feed.language != null) out.feedLanguage = feed.language; + } + return out; +} + +const EPOCH_ISO = new Date(0).toISOString(); + +// Epoch ("never happened" on disk) maps to `null` in the core model. +function toNullableDate(value) { + const date = new Date(value ?? 0); + return date.getTime() === 0 ? null : date; +} + +// `null` ("never") serializes back to the epoch string the legacy reader uses. +function fromNullableDate(value) { + return value === null ? EPOCH_ISO : value.toISOString(); +} + +function toCoreSubscription(raw) { + const whenExpires = new Date(raw.whenExpires ?? 0); + const subscription = { + url: raw.url, + protocol: raw.protocol, + ctUpdates: raw.ctUpdates ?? 0, + ctErrors: raw.ctErrors ?? 0, + ctConsecutiveErrors: raw.ctConsecutiveErrors ?? 0, + // Legacy records carry no creation time; synthesize from expiry. + whenCreated: + raw.whenCreated != null ? new Date(raw.whenCreated) : whenExpires, + whenLastUpdate: toNullableDate(raw.whenLastUpdate), + whenLastError: toNullableDate(raw.whenLastError), + whenExpires + }; + if (typeof raw.notifyProcedure === 'string') { + subscription.notifyProcedure = raw.notifyProcedure; + } + if (raw.details !== undefined) { + subscription.details = raw.details; + } + return subscription; +} + +function toLegacySubscription(subscription) { + const out = { + ctUpdates: subscription.ctUpdates, + whenLastUpdate: fromNullableDate(subscription.whenLastUpdate), + ctErrors: subscription.ctErrors, + ctConsecutiveErrors: subscription.ctConsecutiveErrors, + whenLastError: fromNullableDate(subscription.whenLastError), + whenExpires: subscription.whenExpires.toISOString(), + url: subscription.url, + // REST subs carry no procedure; the legacy shape records that as `false`. + notifyProcedure: subscription.notifyProcedure ?? false, + protocol: subscription.protocol + }; + if (subscription.details !== undefined) { + out.details = subscription.details; + } + return out; +} + +// Rebuild the legacy nested dump (`{ feedUrl: { resource, subscribers } }`) the +// json-store exposed via getData(), from core's FeedEntry[] (store.list()). A +// subscriptions-only entry (core resource `null`) maps back to an empty `{}` +// resource, matching the legacy shape. +function toLegacyData(entries) { + const data = {}; + for (const entry of entries) { + data[entry.feedUrl] = { + resource: entry.resource ? toLegacyResource(entry.resource) : {}, + subscribers: entry.subscriptions.map(toLegacySubscription) + }; + } + return data; +} + +module.exports = { + toCoreResource, + toLegacyResource, + toCoreSubscription, + toLegacySubscription, + toLegacyData +}; diff --git a/apps/server/services/legacy-store-shape.test.js b/apps/server/services/legacy-store-shape.test.js new file mode 100644 index 0000000..cc1022e --- /dev/null +++ b/apps/server/services/legacy-store-shape.test.js @@ -0,0 +1,117 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + toCoreResource, + toLegacyResource, + toCoreSubscription, + toLegacySubscription, + toLegacyData +} = require('./legacy-store-shape'); + +const EPOCH_ISO = new Date(0).toISOString(); + +test('toCoreResource treats a missing or empty resource as no resource', () => { + assert.equal(toCoreResource('https://a/feed', null), null); + assert.equal(toCoreResource('https://a/feed', undefined), null); + assert.equal(toCoreResource('https://a/feed', {}), null); + // json-store hands back `{ _id }` for an empty resource — still "none". + assert.equal(toCoreResource('https://a/feed', { _id: 'https://a/feed' }), null); +}); + +test('a populated resource round-trips legacy -> core -> legacy', () => { + const legacy = { + lastSize: 100, + lastHash: 'abc', + ctChecks: 3, + whenLastCheck: '2026-06-01T00:00:00.000Z', + ctUpdates: 2, + whenLastUpdate: '2026-06-02T00:00:00.000Z', + feedTitle: 'Alpha', + feedType: 'rss' + }; + + const core = toCoreResource('https://a/feed', legacy); + assert.equal(core.url, 'https://a/feed'); + assert.ok(core.whenLastCheck instanceof Date); + assert.deepEqual(core.feed, { title: 'Alpha', type: 'rss' }); + + assert.deepEqual(toLegacyResource(core), legacy); +}); + +test('a subscription round-trips legacy -> core -> legacy', () => { + const legacy = { + ctUpdates: 0, + whenLastUpdate: EPOCH_ISO, + ctErrors: 0, + ctConsecutiveErrors: 2, + whenLastError: EPOCH_ISO, + whenExpires: '2026-07-01T00:00:00.000Z', + url: 'http://sub.example.com/notify', + notifyProcedure: false, + protocol: 'http-post' + }; + + const core = toCoreSubscription(legacy); + // Epoch ("never") becomes null in the core model. + assert.equal(core.whenLastUpdate, null); + assert.equal(core.whenLastError, null); + assert.equal(core.ctConsecutiveErrors, 2); + assert.ok(core.whenExpires instanceof Date); + + assert.deepEqual(toLegacySubscription(core), legacy); +}); + +test('toLegacySubscription defaults a missing notifyProcedure to false', () => { + const core = toCoreSubscription({ + url: 'http://sub/notify', + protocol: 'http-post', + whenExpires: '2026-07-01T00:00:00.000Z' + }); + assert.equal(core.notifyProcedure, undefined); + assert.equal(toLegacySubscription(core).notifyProcedure, false); +}); + +test('toLegacyData rebuilds the nested dump, mapping a null resource to {}', () => { + const entries = [ + { + feedUrl: 'https://a/feed', + resource: toCoreResource('https://a/feed', { + lastSize: 1, + lastHash: 'h', + ctChecks: 1, + whenLastCheck: '2026-06-01T00:00:00.000Z', + ctUpdates: 1, + whenLastUpdate: '2026-06-01T00:00:00.000Z' + }), + subscriptions: [ + toCoreSubscription({ + url: 'http://sub/notify', + protocol: 'http-post', + whenExpires: '2026-07-01T00:00:00.000Z' + }) + ] + }, + { + feedUrl: 'https://b/feed', + resource: null, + subscriptions: [] + } + ]; + + const data = toLegacyData(entries); + + assert.deepEqual(Object.keys(data), ['https://a/feed', 'https://b/feed']); + assert.equal(data['https://a/feed'].resource.lastHash, 'h'); + assert.equal(data['https://a/feed'].subscribers.length, 1); + assert.equal( + data['https://a/feed'].subscribers[0].url, + 'http://sub/notify' + ); + // Subscriptions-only feed: empty resource object, empty subscribers. + assert.deepEqual(data['https://b/feed'], { resource: {}, subscribers: [] }); +}); + +test('toLegacyData returns an empty object for no entries', () => { + assert.deepEqual(toLegacyData([]), {}); +}); From 3ef87e0b59e8f194c4200537d09077f0b208a89c Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 17:24:28 -0500 Subject: [PATCH 43/90] test(server): seed the service unit tests via the core store interface stats, feeds-opml, and remove-expired tests seeded the shared store by poking the json-store singleton directly. Route them through the core `store` instead (seed via legacy-store-shape mappers + putResource/putSubscriptions, clear via list()+remove(), inspect via list()), so they follow the backing when core swaps to createFileStore next. Each points DATA_FILE_PATH at a temp file to keep the file store isolated once it lands. Backing is unchanged (adapter over json-store), so the suite stays green: 32 server unit tests pass. No production code touched. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/services/feeds-opml.test.js | 46 ++++++++-- .../remove-expired-subscriptions.test.js | 86 ++++++++++++------- apps/server/services/stats.test.js | 39 ++++++--- 3 files changed, 118 insertions(+), 53 deletions(-) diff --git a/apps/server/services/feeds-opml.test.js b/apps/server/services/feeds-opml.test.js index 6be5cf3..52dee27 100644 --- a/apps/server/services/feeds-opml.test.js +++ b/apps/server/services/feeds-opml.test.js @@ -1,21 +1,49 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const xml2js = require('xml2js'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +// generateOpml reads the core store; point DATA_FILE_PATH at a throwaway temp +// file (config snapshots env at require time) so the file store stays isolated +// once it backs core. +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rsscloud-opml-')); +process.env.DATA_FILE_PATH = path.join(tmpDir, 'subscriptions.json'); const config = require('../config'); -const jsonStore = require('./json-store'); +const { store } = require('../core'); +const { toCoreResource, toCoreSubscription } = require('./legacy-store-shape'); const { generateOpml } = require('./feeds-opml'); async function parseOpml(xml) { return new xml2js.Parser().parseStringPromise(xml); } -test.beforeEach(() => { - jsonStore.clear(); -}); +async function seedResource(feedUrl, resource) { + const core = toCoreResource(feedUrl, resource); + if (core === null) { + // No real resource fields: a subscriptions-only (never-pinged) entry. + await store.putSubscriptions(feedUrl, []); + } else { + await store.putResource(feedUrl, core); + } +} + +async function seedSubscriptions(feedUrl, subscriptions) { + await store.putSubscriptions(feedUrl, subscriptions.map(toCoreSubscription)); +} + +async function clearStore() { + for (const { feedUrl } of await store.list()) { + await store.remove(feedUrl); + } +} + +test.beforeEach(clearStore); test('generateOpml renders a feed with full metadata as an outline', async() => { - jsonStore.setResource('https://a.example.com/feed.xml', { + await seedResource('https://a.example.com/feed.xml', { feedType: 'atom', feedTitle: 'Alpha', feedDescription: 'The Alpha feed', @@ -49,11 +77,11 @@ test('generateOpml renders a feed with full metadata as an outline', async() => test('generateOpml sorts case-insensitively and falls back to the feed URL', async() => { // Untitled feed: text falls back to the URL, type defaults to rss, and no // title/description/htmlUrl/language attributes are emitted. - jsonStore.setResource('https://apple.example.com/feed.xml', {}); - jsonStore.setResource('https://b.example.com/feed.xml', { + await seedResource('https://apple.example.com/feed.xml', {}); + await seedResource('https://b.example.com/feed.xml', { feedTitle: 'banana' }); - jsonStore.setResource('https://z.example.com/feed.xml', { + await seedResource('https://z.example.com/feed.xml', { feedTitle: 'Cherry' }); @@ -72,7 +100,7 @@ test('generateOpml sorts case-insensitively and falls back to the feed URL', asy }); test('generateOpml lists a subscribed feed that was never pinged', async() => { - jsonStore.setSubscriptions('https://new.example.com/feed.xml', [ + await seedSubscriptions('https://new.example.com/feed.xml', [ { url: 'http://sub.example.com/notify', protocol: 'http-post', diff --git a/apps/server/services/remove-expired-subscriptions.test.js b/apps/server/services/remove-expired-subscriptions.test.js index 42e0512..9cd4b89 100644 --- a/apps/server/services/remove-expired-subscriptions.test.js +++ b/apps/server/services/remove-expired-subscriptions.test.js @@ -1,8 +1,19 @@ const test = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +// The store is the core singleton (json-store-backed via the adapter today, +// createFileStore tomorrow). Point DATA_FILE_PATH at a throwaway temp file so +// the file store stays isolated once it backs core — config snapshots env at +// require time, so set it before requiring anything. +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rsscloud-rmexp-')); +process.env.DATA_FILE_PATH = path.join(tmpDir, 'subscriptions.json'); const config = require('../config'); -const jsonStore = require('./json-store'); +const { store } = require('../core'); +const { toCoreResource, toCoreSubscription } = require('./legacy-store-shape'); const removeExpiredSubscriptions = require('./remove-expired-subscriptions'); const DAY_MS = 24 * 60 * 60 * 1000; @@ -23,88 +34,96 @@ function subscription(overrides = {}) { }; } -test.beforeEach(() => { - jsonStore.clear(); -}); +async function seedResource(feedUrl, resource) { + await store.putResource(feedUrl, toCoreResource(feedUrl, resource)); +} + +async function seedSubscriptions(feedUrl, subscriptions) { + await store.putSubscriptions(feedUrl, subscriptions.map(toCoreSubscription)); +} + +async function clearStore() { + for (const { feedUrl } of await store.list()) { + await store.remove(feedUrl); + } +} + +async function entryFor(feedUrl) { + return (await store.list()).find(e => e.feedUrl === feedUrl); +} + +test.beforeEach(clearStore); test('removes an expired subscription and prunes the now-empty feed', async() => { const feed = 'https://a.example.com/feed.xml'; - jsonStore.setSubscriptions(feed, [ - subscription({ whenExpires: expired() }) - ]); + await seedSubscriptions(feed, [subscription({ whenExpires: expired() })]); const result = await removeExpiredSubscriptions(); assert.equal(result.subscriptionsRemoved, 1); - assert.ok(!Object.prototype.hasOwnProperty.call(jsonStore.getData(), feed)); + assert.equal(await entryFor(feed), undefined); }); test('clears an expired subscription but retains a recently-updated feed', async() => { const feed = 'https://b.example.com/feed.xml'; - jsonStore.setResource(feed, { + await seedResource(feed, { feedTitle: 'Bravo', whenLastUpdate: withinWindow() }); - jsonStore.setSubscriptions(feed, [ - subscription({ whenExpires: expired() }) - ]); + await seedSubscriptions(feed, [subscription({ whenExpires: expired() })]); const result = await removeExpiredSubscriptions(); assert.equal(result.subscriptionsRemoved, 1); - const data = jsonStore.getData(); - assert.ok(Object.prototype.hasOwnProperty.call(data, feed)); - assert.deepEqual(data[feed].subscribers, []); + const entry = await entryFor(feed); + assert.ok(entry); + assert.deepEqual(entry.subscriptions, []); }); test('removes a feed whose resource is older than the retention window', async() => { const feed = 'https://c.example.com/feed.xml'; - jsonStore.setResource(feed, { + await seedResource(feed, { feedTitle: 'Charlie', whenLastUpdate: beyondWindow() }); - jsonStore.setSubscriptions(feed, [ - subscription({ whenExpires: expired() }) - ]); + await seedSubscriptions(feed, [subscription({ whenExpires: expired() })]); await removeExpiredSubscriptions(); - assert.ok(!Object.prototype.hasOwnProperty.call(jsonStore.getData(), feed)); + assert.equal(await entryFor(feed), undefined); }); test('leaves active subscriptions untouched', async() => { const feed = 'https://d.example.com/feed.xml'; - jsonStore.setResource(feed, { + await seedResource(feed, { feedTitle: 'Delta', whenLastUpdate: withinWindow() }); - jsonStore.setSubscriptions(feed, [subscription({ whenExpires: active() })]); + await seedSubscriptions(feed, [subscription({ whenExpires: active() })]); const result = await removeExpiredSubscriptions(); assert.equal(result.subscriptionsRemoved, 0); - const data = jsonStore.getData(); - assert.ok(Object.prototype.hasOwnProperty.call(data, feed)); - assert.equal(data[feed].subscribers.length, 1); + const entry = await entryFor(feed); + assert.ok(entry); + assert.equal(entry.subscriptions.length, 1); }); test('removes an orphaned resource with no subscriptions', async() => { const feed = 'https://e.example.com/feed.xml'; - jsonStore.setResource(feed, { + await seedResource(feed, { feedTitle: 'Echo', whenLastUpdate: beyondWindow() }); await removeExpiredSubscriptions(); - assert.ok(!Object.prototype.hasOwnProperty.call(jsonStore.getData(), feed)); + assert.equal(await entryFor(feed), undefined); }); test('returns the core MaintenanceResult shape', async() => { const feed = 'https://f.example.com/feed.xml'; - jsonStore.setSubscriptions(feed, [ - subscription({ whenExpires: expired() }) - ]); + await seedSubscriptions(feed, [subscription({ whenExpires: expired() })]); const result = await removeExpiredSubscriptions(); @@ -118,11 +137,11 @@ test('returns the core MaintenanceResult shape', async() => { test('removes a subscription that has reached the consecutive-error limit', async() => { const feed = 'https://g.example.com/feed.xml'; - jsonStore.setResource(feed, { + await seedResource(feed, { feedTitle: 'Golf', whenLastUpdate: withinWindow() }); - jsonStore.setSubscriptions(feed, [ + await seedSubscriptions(feed, [ subscription({ whenExpires: active(), ctConsecutiveErrors: config.maxConsecutiveErrors @@ -132,5 +151,6 @@ test('removes a subscription that has reached the consecutive-error limit', asyn const result = await removeExpiredSubscriptions(); assert.equal(result.subscriptionsRemoved, 1); - assert.deepEqual(jsonStore.getData()[feed].subscribers, []); + const entry = await entryFor(feed); + assert.deepEqual(entry.subscriptions, []); }); diff --git a/apps/server/services/stats.test.js b/apps/server/services/stats.test.js index 30a7839..87bb9f6 100644 --- a/apps/server/services/stats.test.js +++ b/apps/server/services/stats.test.js @@ -4,17 +4,34 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -// stats.js reads config.statsFilePath, and config snapshots process.env at -// require time, so point it at a throwaway temp file before requiring anything. +// stats.js reads config.statsFilePath and core reads DATA_FILE_PATH; config +// snapshots process.env at require time, so point both at throwaway temp files +// before requiring anything. const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rsscloud-stats-')); process.env.STATS_FILE_PATH = path.join(tmpDir, 'stats.json'); +process.env.DATA_FILE_PATH = path.join(tmpDir, 'subscriptions.json'); const config = require('../config'); -const jsonStore = require('./json-store'); +const { store } = require('../core'); +const { toCoreResource, toCoreSubscription } = require('./legacy-store-shape'); const stats = require('./stats'); -test.beforeEach(() => { - jsonStore.clear(); +async function seedResource(feedUrl, resource) { + await store.putResource(feedUrl, toCoreResource(feedUrl, resource)); +} + +async function seedSubscriptions(feedUrl, subscriptions) { + await store.putSubscriptions(feedUrl, subscriptions.map(toCoreSubscription)); +} + +async function clearStore() { + for (const { feedUrl } of await store.list()) { + await store.remove(feedUrl); + } +} + +test.beforeEach(async() => { + await clearStore(); fs.rmSync(config.statsFilePath, { force: true }); }); @@ -59,21 +76,21 @@ test('generateStats aggregates active subscriptions into the legacy shape', asyn const future = new Date(Date.now() + DAY_MS).toISOString(); const past = new Date(Date.now() - DAY_MS).toISOString(); - jsonStore.setResource('https://a.example.com/feed.xml', { + await seedResource('https://a.example.com/feed.xml', { feedTitle: 'Alpha', whenLastUpdate: recent }); - jsonStore.setSubscriptions('https://a.example.com/feed.xml', [ + await seedSubscriptions('https://a.example.com/feed.xml', [ { url: 'http://sub1.example.com/notify', protocol: 'http-post', whenExpires: future }, { url: 'http://sub2.example.com/notify', protocol: 'http-post', whenExpires: future }, { url: 'http://gone.example.com/notify', protocol: 'http-post', whenExpires: past } ]); - jsonStore.setResource('https://b.example.com/feed.xml', { + await seedResource('https://b.example.com/feed.xml', { feedTitle: 'Bravo', whenLastUpdate: recent }); - jsonStore.setSubscriptions('https://b.example.com/feed.xml', [ + await seedSubscriptions('https://b.example.com/feed.xml', [ { url: 'http://sub1.example.com/notify', protocol: 'http-post', whenExpires: future } ]); @@ -110,11 +127,11 @@ test('generateStats aggregates active subscriptions into the legacy shape', asyn test('generateStats omits feeds whose subscriptions have all expired', async() => { const past = new Date(Date.now() - DAY_MS).toISOString(); - jsonStore.setResource('https://stale.example.com/feed.xml', { + await seedResource('https://stale.example.com/feed.xml', { feedTitle: 'Stale', whenLastUpdate: new Date(Date.now() - DAY_MS).toISOString() }); - jsonStore.setSubscriptions('https://stale.example.com/feed.xml', [ + await seedSubscriptions('https://stale.example.com/feed.xml', [ { url: 'http://gone.example.com/notify', protocol: 'http-post', whenExpires: past } ]); From eb788b60aa111b41784a350b261a005f0c472b80 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Wed, 10 Jun 2026 17:27:19 -0500 Subject: [PATCH 44/90] refactor(server): back core with createFileStore; retire json-store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swaps the persistence backbone from the hand-rolled json-store (+ the bridging adapter) to @rsscloud/core's createFileStore — the endgame of the migration (PLAN slice B). core.js fronts the async store with a small deferred proxy so the module stays synchronously requireable: every Store call awaits a one-time file load, transparent to core, @rsscloud/express, and every require('./core') consumer. app.js drops jsonStore.initialize and re-points the SIGINT/SIGTERM/ uncaughtException/unhandledRejection hooks from jsonStore.shutdown() to store.close() (a final durable flush). Deletes services/json-store.js, services/core-store-adapter.js, and the adapter's test. The legacy<->core mappers (services/legacy-store-shape.js) stay, backing the /test/* harness + /subscriptions.json view. 23 server unit tests + 134 e2e green; /subscriptions.json + /test/* keep their wire shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/app.js | 13 +- apps/server/core.js | 49 +++- apps/server/services/core-store-adapter.js | 57 ----- .../services/core-store-adapter.test.js | 227 ------------------ apps/server/services/json-store.js | 113 --------- 5 files changed, 42 insertions(+), 417 deletions(-) delete mode 100644 apps/server/services/core-store-adapter.js delete mode 100644 apps/server/services/core-store-adapter.test.js delete mode 100644 apps/server/services/json-store.js diff --git a/apps/server/app.js b/apps/server/app.js index 39aba0d..f8e8645 100644 --- a/apps/server/app.js +++ b/apps/server/app.js @@ -5,12 +5,11 @@ const config = require('./config'), express = require('express'), exphbs = require('express-handlebars'), getDayjs = require('./services/dayjs-wrapper'), - jsonStore = require('./services/json-store'), stats = require('./services/stats'), morgan = require('morgan'), removeExpiredSubscriptions = require('./services/remove-expired-subscriptions'), websocket = require('./services/websocket'), - { events: coreEvents } = require('./core'), + { events: coreEvents, store } = require('./core'), bridgeCoreEvents = require('./services/core-event-bridge'); let app, hbs, server, dayjs; @@ -94,7 +93,7 @@ app.use( app.use(require('./controllers')); async function gracefulShutdown() { - jsonStore.shutdown(); + await store.close(); process.exit(); } @@ -107,8 +106,7 @@ process.on('uncaughtException', error => { 'Uncaught exception, flushing data store before exit:', error ); - jsonStore.shutdown(); - process.exit(1); + store.close().finally(() => process.exit(1)); }); process.on('unhandledRejection', reason => { @@ -116,15 +114,12 @@ process.on('unhandledRejection', reason => { 'Unhandled promise rejection, flushing data store before exit:', reason ); - jsonStore.shutdown(); - process.exit(1); + store.close().finally(() => process.exit(1)); }); async function startServer() { await initializeDayjs(); - jsonStore.initialize(config.dataFilePath); - // Start cleanup scheduling scheduleCleanupTasks(); diff --git a/apps/server/core.js b/apps/server/core.js index 301a972..28dccb5 100644 --- a/apps/server/core.js +++ b/apps/server/core.js @@ -1,22 +1,15 @@ // Composition root for @rsscloud/core. Builds the protocol-neutral engine the -// server's front doors will run on, wiring server config + the REST/XML-RPC -// delivery plugins to a Store. -// -// During the migration (PLAN: "Endpoint migration onto @rsscloud/express") the -// Store is an adapter over the legacy synchronous json-store, so core and the -// not-yet-migrated legacy services + /test/* API share one in-memory store. -// At the end of the migration this becomes `await createFileStore({ filePath })` -// and the adapter + json-store are deleted. +// server's front doors run on, wiring server config + the REST/XML-RPC delivery +// plugins to a file-backed Store. const { createRssCloudCore, createRestProtocolPlugin, createXmlRpcProtocolPlugin, + createFileStore, resolveConfig } = require('@rsscloud/core'); const config = require('./config'); -const jsonStore = require('./services/json-store'); -const createJsonStoreAdapter = require('./services/core-store-adapter'); const coreConfig = resolveConfig({ minSecsBetweenPings: config.minSecsBetweenPings, @@ -27,7 +20,41 @@ const coreConfig = resolveConfig({ feedsChangedWindowDays: config.feedsChangedWindowDays }); -const store = createJsonStoreAdapter(jsonStore); +// createFileStore is async, but core.js is required synchronously — the +// @rsscloud/express middleware factories need a concrete `core` at mount time. +// Kick off the file store and front it with a proxy whose every call awaits the +// one-time load. The Store interface is already all-async, so this is +// transparent to core and to every require('./core') consumer; the first +// requests simply await initialization. flush()/close() are surfaced for the +// graceful-shutdown hooks in app.js. +const storeReady = createFileStore({ filePath: config.dataFilePath }); + +const store = { + async getResource(feedUrl) { + return (await storeReady).getResource(feedUrl); + }, + async putResource(feedUrl, resource) { + return (await storeReady).putResource(feedUrl, resource); + }, + async getSubscriptions(feedUrl) { + return (await storeReady).getSubscriptions(feedUrl); + }, + async putSubscriptions(feedUrl, subscriptions) { + return (await storeReady).putSubscriptions(feedUrl, subscriptions); + }, + async list() { + return (await storeReady).list(); + }, + async remove(feedUrl) { + return (await storeReady).remove(feedUrl); + }, + async flush() { + return (await storeReady).flush(); + }, + async close() { + return (await storeReady).close(); + } +}; const plugins = [ createRestProtocolPlugin({ requestTimeoutMs: config.requestTimeout }), diff --git a/apps/server/services/core-store-adapter.js b/apps/server/services/core-store-adapter.js deleted file mode 100644 index 8d483d2..0000000 --- a/apps/server/services/core-store-adapter.js +++ /dev/null @@ -1,57 +0,0 @@ -// Bridges core's async `Store` interface to the legacy synchronous `json-store` -// during the migration onto @rsscloud/core. core writes/reads through this -// adapter while the legacy services and the /test/* API keep using json-store -// directly, so both share one in-memory store. The legacy<->core shape mapping -// lives in legacy-store-shape.js (shared with the /test/* + /subscriptions.json -// seams); keep it in sync with packages/core/src/store/file-store.ts until -// json-store is retired and core uses createFileStore. - -const { - toCoreResource, - toLegacyResource, - toCoreSubscription, - toLegacySubscription -} = require('./legacy-store-shape'); - -function createJsonStoreAdapter(jsonStore) { - return { - async getResource(feedUrl) { - return toCoreResource(feedUrl, jsonStore.getResource(feedUrl)); - }, - - async putResource(feedUrl, resource) { - jsonStore.setResource(feedUrl, toLegacyResource(resource)); - }, - - async getSubscriptions(feedUrl) { - return jsonStore - .getSubscriptions(feedUrl) - .pleaseNotify.map(toCoreSubscription); - }, - - async putSubscriptions(feedUrl, subscriptions) { - jsonStore.setSubscriptions( - feedUrl, - subscriptions.map(toLegacySubscription) - ); - }, - - async list() { - return Object.entries(jsonStore.getData()).map( - ([feedUrl, entry]) => ({ - feedUrl, - resource: toCoreResource(feedUrl, entry.resource), - subscriptions: (entry.subscribers ?? []).map( - toCoreSubscription - ) - }) - ); - }, - - async remove(feedUrl) { - jsonStore.removeEntry(feedUrl); - } - }; -} - -module.exports = createJsonStoreAdapter; diff --git a/apps/server/services/core-store-adapter.test.js b/apps/server/services/core-store-adapter.test.js deleted file mode 100644 index 0ff91dd..0000000 --- a/apps/server/services/core-store-adapter.test.js +++ /dev/null @@ -1,227 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const jsonStore = require('./json-store'); -const createJsonStoreAdapter = require('./core-store-adapter'); - -test('round-trips a resource through the legacy store', async() => { - jsonStore.clear(); - const store = createJsonStoreAdapter(jsonStore); - - const resource = { - url: 'https://example.com/feed.xml', - lastHash: 'abc123', - lastSize: 42, - ctChecks: 3, - whenLastCheck: new Date('2026-06-01T12:00:00.000Z'), - ctUpdates: 2, - whenLastUpdate: new Date('2026-06-02T08:30:00.000Z'), - feed: { - type: 'rss', - title: 'Example', - description: 'An example feed', - htmlUrl: 'https://example.com', - language: 'en' - } - }; - - await store.putResource(resource.url, resource); - - assert.deepStrictEqual(await store.getResource(resource.url), resource); -}); - -test('getResource returns null for an unknown feed', async() => { - jsonStore.clear(); - const store = createJsonStoreAdapter(jsonStore); - - assert.equal(await store.getResource('https://example.com/unknown'), null); -}); - -test('getResource returns null for a subscriptions-only entry', async() => { - jsonStore.clear(); - const store = createJsonStoreAdapter(jsonStore); - - // setSubscriptions creates an entry with an empty `{}` resource. - jsonStore.setSubscriptions('https://example.com/feed.xml', []); - - assert.equal( - await store.getResource('https://example.com/feed.xml'), - null - ); -}); - -test('round-trips a REST subscription through the legacy store', async() => { - jsonStore.clear(); - const store = createJsonStoreAdapter(jsonStore); - const feedUrl = 'https://example.com/feed.xml'; - - const subscription = { - url: 'https://aggregator.example/callback', - protocol: 'https-post', - ctUpdates: 1, - ctErrors: 0, - ctConsecutiveErrors: 0, - whenCreated: new Date('2026-06-01T00:00:00.000Z'), - whenLastUpdate: new Date('2026-06-02T00:00:00.000Z'), - whenLastError: null, - whenExpires: new Date('2026-06-03T00:00:00.000Z') - }; - - await store.putSubscriptions(feedUrl, [subscription]); - - assert.deepStrictEqual(await store.getSubscriptions(feedUrl), [ - { - ...subscription, - // REST subs carry no notifyProcedure (stored as `false`, dropped on read). - // whenCreated isn't persisted; it's synthesized from whenExpires. - whenCreated: new Date('2026-06-03T00:00:00.000Z') - } - ]); -}); - -test('getSubscriptions returns an empty array for an unknown feed', async() => { - jsonStore.clear(); - const store = createJsonStoreAdapter(jsonStore); - - assert.deepStrictEqual( - await store.getSubscriptions('https://example.com/unknown'), - [] - ); -}); - -test('round-trips an XML-RPC subscription, preserving procedure and details', async() => { - jsonStore.clear(); - const store = createJsonStoreAdapter(jsonStore); - const feedUrl = 'https://example.com/feed.xml'; - - const subscription = { - url: 'https://aggregator.example/rpc', - protocol: 'xml-rpc', - notifyProcedure: 'river.feedUpdated', - ctUpdates: 5, - ctErrors: 2, - ctConsecutiveErrors: 1, - whenCreated: new Date('2026-06-01T00:00:00.000Z'), - whenLastUpdate: new Date('2026-06-02T00:00:00.000Z'), - whenLastError: new Date('2026-06-02T06:00:00.000Z'), - whenExpires: new Date('2026-06-03T00:00:00.000Z'), - details: { secret: 's3cret', leaseSeconds: 86400 } - }; - - await store.putSubscriptions(feedUrl, [subscription]); - - assert.deepStrictEqual(await store.getSubscriptions(feedUrl), [ - { ...subscription, whenCreated: new Date('2026-06-03T00:00:00.000Z') } - ]); -}); - -test('list returns every tracked feed as a core FeedEntry', async() => { - jsonStore.clear(); - const store = createJsonStoreAdapter(jsonStore); - - const resource = { - url: 'https://example.com/a.xml', - lastHash: 'h', - lastSize: 10, - ctChecks: 1, - whenLastCheck: new Date('2026-06-01T00:00:00.000Z'), - ctUpdates: 1, - whenLastUpdate: new Date('2026-06-01T00:00:00.000Z') - }; - const subscription = { - url: 'https://aggregator.example/cb', - protocol: 'https-post', - ctUpdates: 0, - ctErrors: 0, - ctConsecutiveErrors: 0, - whenCreated: new Date('2026-06-03T00:00:00.000Z'), - whenLastUpdate: null, - whenLastError: null, - whenExpires: new Date('2026-06-03T00:00:00.000Z') - }; - - await store.putResource(resource.url, resource); - await store.putSubscriptions(resource.url, [subscription]); - // A subscriptions-only feed: resource maps to null. - jsonStore.setSubscriptions('https://example.com/b.xml', []); - - assert.deepStrictEqual(await store.list(), [ - { feedUrl: resource.url, resource, subscriptions: [subscription] }, - { - feedUrl: 'https://example.com/b.xml', - resource: null, - subscriptions: [] - } - ]); -}); - -test('remove deletes the feed entry entirely', async() => { - jsonStore.clear(); - const store = createJsonStoreAdapter(jsonStore); - const feedUrl = 'https://example.com/feed.xml'; - - jsonStore.setSubscriptions(feedUrl, []); - await store.remove(feedUrl); - - assert.deepStrictEqual(await store.list(), []); - assert.equal(await store.getResource(feedUrl), null); -}); - -test('core writes surface in json-store in the legacy on-disk shape', async() => { - jsonStore.clear(); - const store = createJsonStoreAdapter(jsonStore); - const feedUrl = 'https://example.com/feed.xml'; - - await store.putResource(feedUrl, { - url: feedUrl, - lastHash: 'h', - lastSize: 5, - ctChecks: 1, - whenLastCheck: new Date('2026-06-01T00:00:00.000Z'), - ctUpdates: 1, - whenLastUpdate: new Date('2026-06-01T00:00:00.000Z'), - feed: { type: 'rss', title: 'A' } - }); - await store.putSubscriptions(feedUrl, [ - { - url: 'https://aggregator.example/cb', - protocol: 'https-post', - ctUpdates: 0, - ctErrors: 0, - ctConsecutiveErrors: 0, - whenCreated: new Date('2026-06-03T00:00:00.000Z'), - whenLastUpdate: null, - whenLastError: null, - whenExpires: new Date('2026-06-03T00:00:00.000Z') - } - ]); - - // What the legacy readers (/test/getData, /subscriptions.json, stats, - // remove-expired) see when they read json-store directly. - assert.deepStrictEqual(jsonStore.getData(), { - [feedUrl]: { - resource: { - lastSize: 5, - lastHash: 'h', - ctChecks: 1, - whenLastCheck: '2026-06-01T00:00:00.000Z', - ctUpdates: 1, - whenLastUpdate: '2026-06-01T00:00:00.000Z', - feedType: 'rss', - feedTitle: 'A' - }, - subscribers: [ - { - ctUpdates: 0, - whenLastUpdate: '1970-01-01T00:00:00.000Z', - ctErrors: 0, - ctConsecutiveErrors: 0, - whenLastError: '1970-01-01T00:00:00.000Z', - whenExpires: '2026-06-03T00:00:00.000Z', - url: 'https://aggregator.example/cb', - notifyProcedure: false, - protocol: 'https-post' - } - ] - } - }); -}); diff --git a/apps/server/services/json-store.js b/apps/server/services/json-store.js deleted file mode 100644 index b4ff927..0000000 --- a/apps/server/services/json-store.js +++ /dev/null @@ -1,113 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -let data = {}; -let filePath = null; -let flushTimer = null; -let flushing = false; - -function initialize(dataFilePath, flushIntervalMs = 60000) { - filePath = dataFilePath; - - const dir = path.dirname(filePath); - fs.mkdirSync(dir, { recursive: true }); - - if (fs.existsSync(filePath)) { - try { - data = JSON.parse(fs.readFileSync(filePath, 'utf8')); - } catch { - data = {}; - } - } - - flushTimer = setInterval(() => flush(), flushIntervalMs); -} - -function setResource(feedUrl, resourceObj) { - if (!data[feedUrl]) { - data[feedUrl] = { resource: {}, subscribers: [] }; - } - const clean = Object.assign({}, resourceObj); - delete clean._id; - delete clean.flDirty; - data[feedUrl].resource = clean; -} - -function setSubscriptions(feedUrl, pleaseNotifyArray) { - if (!data[feedUrl]) { - data[feedUrl] = { resource: {}, subscribers: [] }; - } - data[feedUrl].subscribers = pleaseNotifyArray.map(sub => { - const clean = Object.assign({}, sub); - delete clean._id; - return clean; - }); -} - -function removeEntry(feedUrl) { - delete data[feedUrl]; -} - -function getResource(feedUrl) { - if (!data[feedUrl] || !data[feedUrl].resource) { - return null; - } - return Object.assign({ _id: feedUrl }, data[feedUrl].resource); -} - -function getSubscriptions(feedUrl) { - if (!data[feedUrl] || !data[feedUrl].subscribers) { - return { _id: feedUrl, pleaseNotify: [] }; - } - return { - _id: feedUrl, - pleaseNotify: data[feedUrl].subscribers.map(sub => - Object.assign({}, sub) - ) - }; -} - -function getData() { - return data; -} - -function clear() { - data = {}; -} - -function flush() { - if (!filePath || flushing) { - return; - } - flushing = true; - try { - const tmpPath = filePath + '.tmp'; - fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2)); - fs.renameSync(tmpPath, filePath); - } catch (error) { - console.error('Error flushing json-store to disk:', error); - } finally { - flushing = false; - } -} - -function shutdown() { - if (flushTimer) { - clearInterval(flushTimer); - flushTimer = null; - } - flush(); -} - -module.exports = { - initialize, - setResource, - setSubscriptions, - removeEntry, - getResource, - getSubscriptions, - getData, - clear, - flush, - shutdown -}; From 3fd3df638bbba9c6d66de89609b7661f2edce175 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Thu, 11 Jun 2026 08:54:01 -0500 Subject: [PATCH 45/90] docs: add TODO.md tracking open work and future projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the local migration scratchpad to a committed backlog now that the apps/server → @rsscloud/core migration is complete. Captures the optional follow-ups (unify the on-disk format with the domain model, injectable core for the service tests, async store construction in core) and the two bigger future projects (WebSub hub support; a client app + @rsscloud/client package). Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..a00c112 --- /dev/null +++ b/TODO.md @@ -0,0 +1,114 @@ +# TODO — rsscloud-server: open work + +Outstanding + future work only. The `apps/server` → `@rsscloud/core` migration is +done; its history lives in git (`refactor(server):` commits), not here. Per +CLAUDE.md: build with the `tdd` skill (red-green vertical slices); Conventional +Commits enforced. + +## Small follow-ups (optional, none blocking) + +- [ ] **Unify the on-disk format with the domain model** (versioned-file migration). + Two layers do the same legacy↔core translation today — + `packages/core/.../file-store.ts` (disk↔core) and + `apps/server/services/legacy-store-shape.js` (legacy-wire↔core, for `/test/*` + + `/subscriptions.json`). Persisting core's model instead **deletes + `legacy-store-shape.js`**, moves `/subscriptions.json` + the e2e `/test/*` + helpers onto the core model, and drops the per-read mapping in `list()` / + `getResource()` (in-memory becomes the core model directly). `file-store.ts` is + left doing only date (de)serialization + a one-way legacy importer. + + *Migration flow (self-completing, no manual step):* + - Load precedence: `subscriptions.v2.json` → `subscriptions.v1.json` / + `subscriptions.json` (legacy, **converted** on load) → empty. + - All writes go to `subscriptions.v2.json`; the legacy file is never rewritten + (left as a "new format exists" signal + pre-migration backup). Future boots + read v2 directly. + - The converter already exists — `file-store.ts`'s current `readResource` / + `readSubscription` (legacy→core) become the v1 import path; only the *writer* + flips to v2. Keep the v1 importer until a later major drops it. + - Config: derive paths from `DATA_FILE_PATH` (write `…/subscriptions.v2.json`, + fall back to `.v1.json` / the bare name). Log once on migration. + + *Caveat:* forward-only — once v2 runs, the legacy file goes stale, so rolling + back to old code loses post-migration writes. If both exist, v2 wins (document + it). + +- [ ] **Injectable core for the service unit tests.** `stats` / `feeds-opml` / + `remove-expired` reach the production `core`/`store` singleton (tests isolate + via a temp `DATA_FILE_PATH`). If finer isolation is wanted, make them factories + that take an injected core — built with `createInMemoryStore` in tests. Likely + folds into the unify work (touches the same tests). + +- [ ] **Let core own async store construction** (drop the server-side proxy). + `core.js` wraps `createFileStore(...)`'s promise in a hand-rolled deferred-store + proxy because `createRssCloudCore` needs a concrete `store` at sync `require` + time (express mounts need a concrete `core`). Instead, have `createRssCloudCore` + accept `Store | Promise` and own the resolve-once internally (expose a + ready `core.store`), so the server is just + `createRssCloudCore({ store: createFileStore({ filePath }) })`. **Keep + construction async on purpose** (decided 2026-06-11): a future MySQL/DB store + needs async init, so the async factory contract stays and core becomes the one + reusable home for the deferral. Pairs with the unify work (both core changes). + +## WebSub hub support (bigger — spans core + express) + +Make the server act as a [WebSub](https://www.w3.org/TR/websub/) **hub** (the W3C +successor to PubSubHubbub, rssCloud's cousin). Needs new protocol logic in +`@rsscloud/core` **and** a new `@rsscloud/express` middleware, plus a delivery model +the notification plugins don't cover. Sketch, not a spec. + +*What it adds over rssCloud's notify-only model:* +- **Subscribe request:** form-encoded POST — `hub.callback`, `hub.mode` + (subscribe|unsubscribe), `hub.topic`, optional `hub.lease_seconds` + `hub.secret`. + Hub replies `202` (async verify) or 4xx. +- **Intent verification:** hub GETs the callback with `hub.challenge`; the subscriber + echoes it. Same shape as the rssCloud REST challenge core already does — reuse it. +- **Content distribution (the big new piece):** on update the hub POSTs the *actual + feed content* to each callback — topic `Content-Type`, `Link` rel=hub/self, and + `X-Hub-Signature: sha256=HMAC(secret, body)`. The REST/XML-RPC plugins send a + notification, not content, so this needs a new delivery plugin. +- **Leases:** `hub.lease_seconds` + renewal (distinct from `ctSecsResourceExpire`). + +*Pieces:* a core subscribe/unsubscribe dispatcher + content-delivery plugin (new +`Subscription` fields `secret` / `leaseSeconds` / `callback`+`topic` / mode; likely a +`websub` protocol value); a `websub({ core })` express factory branching on +`hub.mode`; mount the hub at a stable URL (publishers reference it via +`` in their own feeds — the hub doesn't host the source). + +*Open questions:* sync vs async intent verification (spec prefers async `202`); which +HMAC algos to require; content source on publish (fetch vs publisher-pushed). The new +subscription fields ride better on the domain-model disk format — do the unify +follow-up first. + +*First slice:* core `subscribe` happy path (parse, verify intent, persist) + the +express `websub` factory + an e2e callback handshake. Defer content distribution, +HMAC, and leases. + +## Client app + `@rsscloud/client` package (bigger) + +Pull `apps/server/client.js` into two layers, mirroring how `apps/server` consumes +`@rsscloud/core`. It already works against the live server — this is extraction + +packaging, not a behaviour change. + +*`@rsscloud/client` (`packages/client`)* — the **subscriber+publisher end** of the +protocol (core is the hub end); reusable + published: +- **Subscriber:** send `pleaseNotify` (REST + XML-RPC), do the http-post challenge + echo, receive/parse notifications (http-post + XML-RPC `rssCloud.notify`). +- **Publisher:** send `ping` (REST + XML-RPC); optional helper to emit a feed with the + `` element. The wire builders inline in `client.js` today move here. + +*`apps/client` (private, like `apps/e2e`)* — the interactive dev harness on the +package: the existing Express UI (Subscribe/Ping controls + request log, serving test +feeds). The manual counterpart to the automated e2e. + +*Notes:* +- **Wire format** is now known in core (hub side) and the e2e helpers; decide a shared + module vs. independent reimplementation in the client (leaning independent, per the + keep-e2e-independent convention). +- **WebSub-ready:** grows a WebSub subscriber/publisher once that lands. +- **Workspace:** `apps/client` private (not release-tracked); `packages/client` + release-tracked + 100% coverage, like `@rsscloud/core`. + +*First slice:* lift the wire builders + subscribe/ping calls into `packages/client` +with tests, thin `client.js` to a UI shell on the package, then relocate it to +`apps/client`. From 4e8f15254c7817965b0fb200302a389b99edbf6c Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Thu, 11 Jun 2026 09:00:32 -0500 Subject: [PATCH 46/90] ci: track @rsscloud/express in release-please + add node-workspace @rsscloud/express (publishable, private:false) was created during the core migration but never added to release-please, so it was stuck at 0.0.0 with no Release PR, changelog, or tag. Add it to release-please-config.json + the manifest. Also enable the node-workspace plugin so internal workspace:* deps stay in lockstep and a dependency bump cascades a release to its dependents (core -> express -> server). Update CLAUDE.md's tracked-packages note. Co-Authored-By: Claude Opus 4.8 (1M context) --- .release-please-manifest.json | 3 ++- CLAUDE.md | 2 +- release-please-config.json | 7 +++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 48f6c97..4464681 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,5 @@ { "apps/server": "4.0.0", - "packages/core": "0.0.0" + "packages/core": "0.0.0", + "packages/express": "0.0.0" } diff --git a/CLAUDE.md b/CLAUDE.md index 2dd4195..7b15eee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,6 @@ A handful of server-internal helpers (RPC builders, dayjs wrapper, `init-subscri ## Releases -Conventional Commits are enforced by commitlint (via husky). Pushes to `main` trigger [release-please](https://github.com/googleapis/release-please) which opens or updates a Release PR per tracked package (`apps/server`, `packages/core`). `apps/e2e` is private and not tracked. Merging the Release PR cuts the release and git tag. +Conventional Commits are enforced by commitlint (via husky). Pushes to `main` trigger [release-please](https://github.com/googleapis/release-please) which opens or updates a Release PR per tracked package (`apps/server`, `packages/core`, `packages/express`). The `node-workspace` plugin keeps internal `workspace:*` deps in lockstep, cascading a release to dependents when a dependency bumps (`core` → `express` → `server`). `apps/e2e` is private and not tracked. Merging the Release PR cuts the release and git tag. `fix:` → patch, `feat:` → minor, `feat!:` / `BREAKING CHANGE:` → major. Other types (`chore:`, `docs:`, `style:`, `refactor:`, `test:`, `ci:`, `build:`) don't trigger releases. diff --git a/release-please-config.json b/release-please-config.json index 2d27a62..052c414 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,5 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "plugins": ["node-workspace"], "packages": { "apps/server": { "release-type": "node", @@ -11,6 +12,12 @@ "component": "core", "changelog-path": "CHANGELOG.md", "package-name": "@rsscloud/core" + }, + "packages/express": { + "release-type": "node", + "component": "express", + "changelog-path": "CHANGELOG.md", + "package-name": "@rsscloud/express" } } } From 646cc23bfe573f3d82b7b89acbba0985303aed92 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Thu, 11 Jun 2026 13:13:32 -0500 Subject: [PATCH 47/90] refactor(core): collapse the duplicated subscribe-request assembly into one builder Both the REST and XML-RPC dispatchers carried their own copies of glueUrlParts, VALID_PROTOCOLS, scheme derivation, and the domain->diffDomain rule. Extract a deep buildSubscribeRequest(SubscribeParams) in core that both delegate to; each dispatcher keeps only its wire extraction and its presence/arity check. Deliberate behaviour change (ADR-0001): an empty-string XML-RPC domain is now treated as absent (caller address, diffDomain:false) instead of taking the explicit-domain branch and building a malformed http://:port/path. A regression test pins the new behaviour. Adds CONTEXT.md (domain vocabulary) and docs/adr/0001; updates TODO.md to point future WebSub work at the shared builder seam. Package stays at 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 76 ++++++++ TODO.md | 8 +- docs/adr/0001-unify-empty-domain-handling.md | 24 +++ .../src/protocols/rest-dispatcher.test.ts | 20 --- .../core/src/protocols/rest-dispatcher.ts | 76 +++----- .../src/protocols/subscribe-request.test.ts | 162 ++++++++++++++++++ .../core/src/protocols/subscribe-request.ts | 77 +++++++++ .../src/protocols/xml-rpc-dispatcher.test.ts | 25 ++- .../core/src/protocols/xml-rpc-dispatcher.ts | 82 +++------ 9 files changed, 404 insertions(+), 146 deletions(-) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-unify-empty-domain-handling.md create mode 100644 packages/core/src/protocols/subscribe-request.test.ts create mode 100644 packages/core/src/protocols/subscribe-request.ts diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..f0ab676 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,76 @@ +# rssCloud Server + +The notification context of the [rssCloud](http://rsscloud.org/) protocol: subscribers +register a callback for a feed, publishers signal when a feed changes, and the server +fans notifications out to subscribers. `@rsscloud/core` is the protocol-neutral engine +(the **hub** end); the transports and HTTP edge wrap it. + +## Language + +**Resource**: +A feed or document the server watches, together with its change-detection state +(last hash, size, check/update counts). One per feed URL. +_Avoid_: feed (reserve "feed" for the parsed metadata on a resource), document, page. + +**Subscription**: +A subscriber's standing request to be notified when a **Resource** changes — its +callback URL, protocol, error counters, and expiry. Many per **Resource**. +_Avoid_: subscriber (that's the remote party), registration, listener. + +**Feed entry**: +One **Resource** plus its **Subscription**s — the unit the store reads and writes. +_Avoid_: record, row, document. + +**Ping**: +A publisher's change signal for a **Resource** (`/ping`, `rssCloud.ping`). Triggers a +re-fetch, change detection, and fan-out. Always answered with success once well-formed. +_Avoid_: notify (that's the outbound direction), update, poke. + +**pleaseNotify**: +A subscriber's call to establish or renew a **Subscription** (`/pleaseNotify`, +`rssCloud.pleaseNotify`). Distinct from the outbound notification it sets up. +_Avoid_: subscribe request (the verb is "pleaseNotify"; `SubscribeRequest` is the DTO it maps to). + +**Front door**: +An HTTP entry point a remote party calls — `/ping`, `/pleaseNotify`, `/RPC2`. The same +use case (subscribe, ping) is reachable through more than one front door. +_Avoid_: endpoint, route, controller. + +**Dispatcher**: +The wire-protocol adapter behind a **Front door** (REST or XML-RPC). It parses the +request off its transport, drives **core**, and renders the response in that +transport's voice — including the exact legacy wording. core speaks error *codes*; +the dispatcher chooses the *words* (wire-parity convention). +_Avoid_: handler, controller, parser. + +**SubscribeParams**: +The wire-neutral fields a **Dispatcher** has already pulled off its transport +(`resourceUrls`, `port`, `path`, `protocol`, `clientAddress`, optional `domain` / +`notifyProcedure`) — the input to **buildSubscribeRequest**. Not yet a `SubscribeRequest`. +_Avoid_: SubscribeRequest (that's the assembled DTO core consumes), raw body, fields. + +**buildSubscribeRequest**: +The single deep assembler shared by both **Dispatcher**s: takes **SubscribeParams** and +produces a `SubscribeRequest` — validating the protocol, deriving the scheme, gluing the +callback URL (`::ffff:` strip, IPv6 bracketing, path slash), resolving `diffDomain`, and +gating `notifyProcedure`. The one place the callback-URL assembly rules live. +_Avoid_: mapper, glueUrlParts (that's one step inside it). + +**Protocol plugin**: +The delivery adapter for a notification protocol (`http-post`, `https-post`, `xml-rpc`): +verifies a new **Subscription** and delivers notifications. Selected by the +**Subscription**'s protocol. +_Avoid_: transport, notifier, driver. + +**diffDomain**: +A **Subscription** whose callback host differs from the caller's address, requiring the +challenge handshake at verify time. Set by **buildSubscribeRequest** from the presence of +an explicit `domain`. +_Avoid_: cross-origin, external, remote. + +## Example dialogue + +> **Dev:** When a `pleaseNotify` comes in over XML-RPC, who decides the callback is `diffDomain`? +> **Domain expert:** The dispatcher just pulls the positional params into **SubscribeParams** and hands them to **buildSubscribeRequest**. The builder is the one that sees an explicit `domain`, sets `diffDomain`, and glues the callback URL. Same builder the REST front door uses. +> **Dev:** So if the protocol's unsupported, that's the builder too? +> **Domain expert:** Right — protocol validation moved inside the builder, so both front doors fault the same way. The dispatcher only owns its own wire's presence check and the wording it renders back. diff --git a/TODO.md b/TODO.md index a00c112..bfa9e6a 100644 --- a/TODO.md +++ b/TODO.md @@ -3,7 +3,8 @@ Outstanding + future work only. The `apps/server` → `@rsscloud/core` migration is done; its history lives in git (`refactor(server):` commits), not here. Per CLAUDE.md: build with the `tdd` skill (red-green vertical slices); Conventional -Commits enforced. +Commits enforced. Architecture decisions are recorded in `docs/adr/`; domain +vocabulary in `CONTEXT.md`. ## Small follow-ups (optional, none blocking) @@ -73,7 +74,10 @@ the notification plugins don't cover. Sketch, not a spec. `Subscription` fields `secret` / `leaseSeconds` / `callback`+`topic` / mode; likely a `websub` protocol value); a `websub({ core })` express factory branching on `hub.mode`; mount the hub at a stable URL (publishers reference it via -`` in their own feeds — the hub doesn't host the source). +`` in their own feeds — the hub doesn't host the source). The +REST/XML-RPC subscribe parsing now shares `buildSubscribeRequest(SubscribeParams)` in +core (one callback-assembly seam); a WebSub `hub.*` parser can build a `SubscribeRequest` +through it rather than re-deriving callback/scheme/`diffDomain` logic. *Open questions:* sync vs async intent verification (spec prefers async `202`); which HMAC algos to require; content source on publish (fetch vs publisher-pushed). The new diff --git a/docs/adr/0001-unify-empty-domain-handling.md b/docs/adr/0001-unify-empty-domain-handling.md new file mode 100644 index 0000000..e4ad7ad --- /dev/null +++ b/docs/adr/0001-unify-empty-domain-handling.md @@ -0,0 +1,24 @@ +# Unify empty-domain handling across the REST and XML-RPC front doors + +When `buildSubscribeRequest` absorbed the callback-URL assembly that the two +dispatchers had each copied, it adopted a single rule: an **absent or empty-string** +`domain` means "no explicit domain" — use the caller's address and set `diffDomain: +false`. This deliberately changes the XML-RPC `pleaseNotify` path, which previously +treated only `undefined` as absent and so took an empty-string `domain` (`params[5] === +''`) down the *explicit-domain* branch, building a malformed callback like +`http://:5337/RPC2` with `diffDomain: true`. + +## Status + +accepted + +## Why this is a deliberate parity deviation + +The project's wire-parity convention holds the dispatchers byte-compatible with Dave +Winer's original rssCloud server. This change breaks that for one input +(empty-string XML-RPC domain), so it is recorded here to stop a future parity pass from +"restoring" the old behaviour. The divergence between the two front doors was an +unguarded latent inconsistency — no test pinned it, and the REST front door already +collapsed `'' | null | undefined` to absent — not an intended feature. A single rule is +the correct behaviour and removes the malformed-URL path. A regression test pins the new +XML-RPC behaviour. diff --git a/packages/core/src/protocols/rest-dispatcher.test.ts b/packages/core/src/protocols/rest-dispatcher.test.ts index d737395..4ce0dd2 100644 --- a/packages/core/src/protocols/rest-dispatcher.test.ts +++ b/packages/core/src/protocols/rest-dispatcher.test.ts @@ -252,26 +252,6 @@ describe('createRestDispatcher pleaseNotify', () => { ]); }); - it('infers https from port 443 and brackets a bare IPv6 domain', async () => { - const core = fakeCore(); - const dispatcher = createRestDispatcher({ core }); - - await dispatcher.pleaseNotify( - { - domain: '::1', - port: '443', - path: '/cb', - protocol: 'http-post', - url1: 'http://feed.example/rss' - }, - { clientAddress: '203.0.113.5', format: 'json' } - ); - - expect(core.subscribeCalls[0]?.callbackUrl).toBe( - 'https://[::1]:443/cb' - ); - }); - it('renders success:false listing every missing required param', async () => { const core = fakeCore(); const dispatcher = createRestDispatcher({ core }); diff --git a/packages/core/src/protocols/rest-dispatcher.ts b/packages/core/src/protocols/rest-dispatcher.ts index 5a0e37b..fdd60a2 100644 --- a/packages/core/src/protocols/rest-dispatcher.ts +++ b/packages/core/src/protocols/rest-dispatcher.ts @@ -1,7 +1,6 @@ import { Builder } from 'xml2js'; import type { RssCloudCore } from '../engine/core.js'; import type { PingRequest, SubscribeRequest } from '../engine/dto.js'; -import type { Protocol } from '../engine/protocol.js'; import { RssCloudError } from '../errors.js'; import { appMessages, @@ -9,6 +8,10 @@ import { subscriptionFailureMessage, subscriptionRequestErrorMessage } from './app-messages.js'; +import { + buildSubscribeRequest, + type SubscribeParams +} from './subscribe-request.js'; /** Negotiated response format the adapter resolved from the `Accept` header. */ export type RestResponseFormat = 'xml' | 'json' | null; @@ -73,9 +76,6 @@ function mapPing(body: Record): PingRequest { return { resourceUrl: String(body['url']) }; } -/** Protocols a subscriber may register under. */ -const VALID_PROTOCOLS = ['http-post', 'https-post', 'xml-rpc']; - /** Collect every `url*` body key (any case) into a resource list. */ function parseUrlList(body: Record): string[] { const urls: string[] = []; @@ -87,29 +87,13 @@ function parseUrlList(body: Record): string[] { return urls; } -/** Assemble a callback URL from its parts the way the legacy `glueUrlParts` did. */ -function glueUrlParts( - scheme: string, - client: string, - port: string, - path: string -): string { - let host = client; - if (host.startsWith('::ffff:')) { - host = host.slice(7); - } - if (host.includes(':')) { - host = `[${host}]`; - } - - const normalizedPath = path.startsWith('/') ? path : `/${path}`; - return `${scheme}://${host}:${port}${normalizedPath}`; -} - /** * Map the REST `pleaseNotify` body (`port`, `path`, `protocol`, any `url*`, - * optional `domain`/`notifyProcedure`) into a `SubscribeRequest`. Throws - * (→ failure) on missing required params or an unsupported protocol. + * optional `domain`/`notifyProcedure`) into a `SubscribeRequest`. Owns only the + * form-wire extraction and the missing-param check; the callback-URL assembly, + * protocol validation, and `diffDomain`/scheme rules live in + * {@link buildSubscribeRequest}. Throws (→ failure) on a missing required param + * or an unsupported protocol. */ function mapPleaseNotify( body: Record, @@ -131,42 +115,22 @@ function mapPleaseNotify( ); } - const protocol = String(body['protocol']); - if (!VALID_PROTOCOLS.includes(protocol)) { - throw new Error( - appMessages.error.subscription.invalidProtocol(protocol) - ); - } - - const port = String(body['port']); - const path = String(body['path']); - const domain = body['domain']; - - let client: string; - let diffDomain: boolean; - if (domain === undefined || domain === null || domain === '') { - client = clientAddress; - diffDomain = false; - } else { - client = String(domain); - diffDomain = true; - } - - const scheme = - protocol === 'https-post' || port === '443' ? 'https' : 'http'; - - const request: SubscribeRequest = { + const params: SubscribeParams = { resourceUrls: parseUrlList(body), - callbackUrl: glueUrlParts(scheme, client, port, path), - protocol: protocol as Protocol, - diffDomain + port: String(body['port']), + path: String(body['path']), + protocol: String(body['protocol']), + clientAddress }; - if (body['notifyProcedure'] && protocol === 'xml-rpc') { - request.notifyProcedure = String(body['notifyProcedure']); + if (body['domain'] !== undefined) { + params.domain = String(body['domain']); + } + if (body['notifyProcedure']) { + params.notifyProcedure = String(body['notifyProcedure']); } - return request; + return buildSubscribeRequest(params); } /** Serialize a result as a `` document. */ diff --git a/packages/core/src/protocols/subscribe-request.test.ts b/packages/core/src/protocols/subscribe-request.test.ts new file mode 100644 index 0000000..2bc151d --- /dev/null +++ b/packages/core/src/protocols/subscribe-request.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; +import { appMessages } from './app-messages.js'; +import { buildSubscribeRequest } from './subscribe-request.js'; + +describe('buildSubscribeRequest', () => { + it('builds a same-domain http-post request from the caller address', () => { + const request = buildSubscribeRequest({ + resourceUrls: ['http://feed.example/rss'], + port: '5337', + path: '/feedupdated', + protocol: 'http-post', + clientAddress: '203.0.113.5' + }); + + expect(request).toEqual({ + resourceUrls: ['http://feed.example/rss'], + callbackUrl: 'http://203.0.113.5:5337/feedupdated', + protocol: 'http-post', + diffDomain: false + }); + }); + + it('uses an explicit domain as the callback host and marks it diffDomain', () => { + const request = buildSubscribeRequest({ + resourceUrls: ['http://feed.example/rss'], + port: '5337', + path: '/feedupdated', + protocol: 'http-post', + clientAddress: '203.0.113.5', + domain: 'sub.example.com' + }); + + expect(request.callbackUrl).toBe('http://sub.example.com:5337/feedupdated'); + expect(request.diffDomain).toBe(true); + }); + + it('infers https from the https-post protocol', () => { + const request = buildSubscribeRequest({ + resourceUrls: ['http://feed.example/rss'], + port: '8080', + path: '/cb', + protocol: 'https-post', + clientAddress: '203.0.113.5' + }); + + expect(request.callbackUrl).toBe('https://203.0.113.5:8080/cb'); + }); + + it('infers https from port 443 even for http-post', () => { + const request = buildSubscribeRequest({ + resourceUrls: ['http://feed.example/rss'], + port: '443', + path: '/cb', + protocol: 'http-post', + clientAddress: '203.0.113.5' + }); + + expect(request.callbackUrl).toBe('https://203.0.113.5:443/cb'); + }); + + it('strips a ::ffff: prefix from an IPv4-mapped client address', () => { + const request = buildSubscribeRequest({ + resourceUrls: ['http://feed.example/rss'], + port: '8080', + path: '/cb', + protocol: 'http-post', + clientAddress: '::ffff:198.51.100.7' + }); + + expect(request.callbackUrl).toBe('http://198.51.100.7:8080/cb'); + }); + + it('brackets a bare IPv6 host', () => { + const request = buildSubscribeRequest({ + resourceUrls: ['http://feed.example/rss'], + port: '5337', + path: '/cb', + protocol: 'http-post', + clientAddress: '203.0.113.5', + domain: '::1' + }); + + expect(request.callbackUrl).toBe('http://[::1]:5337/cb'); + }); + + it('adds a leading slash to a path that lacks one', () => { + const request = buildSubscribeRequest({ + resourceUrls: ['http://feed.example/rss'], + port: '8080', + path: 'callback', + protocol: 'http-post', + clientAddress: '203.0.113.5' + }); + + expect(request.callbackUrl).toBe('http://203.0.113.5:8080/callback'); + }); + + it('throws the unsupported-protocol message for a protocol outside the set', () => { + expect(() => + buildSubscribeRequest({ + resourceUrls: ['http://feed.example/rss'], + port: '80', + path: '/cb', + protocol: 'ftp', + clientAddress: '203.0.113.5' + }) + ).toThrow(appMessages.error.subscription.invalidProtocol('ftp')); + }); + + it('keeps a notifyProcedure for the xml-rpc protocol', () => { + const request = buildSubscribeRequest({ + resourceUrls: ['http://feed.example/rss'], + port: '5337', + path: '/RPC2', + protocol: 'xml-rpc', + clientAddress: '203.0.113.5', + notifyProcedure: 'river.feedUpdated' + }); + + expect(request.notifyProcedure).toBe('river.feedUpdated'); + }); + + it('drops a notifyProcedure when the protocol is not xml-rpc', () => { + const request = buildSubscribeRequest({ + resourceUrls: ['http://feed.example/rss'], + port: '5337', + path: '/cb', + protocol: 'http-post', + clientAddress: '203.0.113.5', + notifyProcedure: 'river.feedUpdated' + }); + + expect(request.notifyProcedure).toBeUndefined(); + }); + + it('omits a blank notifyProcedure even for xml-rpc', () => { + const request = buildSubscribeRequest({ + resourceUrls: ['http://feed.example/rss'], + port: '5337', + path: '/RPC2', + protocol: 'xml-rpc', + clientAddress: '203.0.113.5', + notifyProcedure: '' + }); + + expect(request.notifyProcedure).toBeUndefined(); + }); + + it('treats an empty-string domain as absent — caller address, not diffDomain (ADR-0001)', () => { + const request = buildSubscribeRequest({ + resourceUrls: ['http://feed.example/rss'], + port: '5337', + path: '/RPC2', + protocol: 'xml-rpc', + clientAddress: '203.0.113.5', + domain: '' + }); + + expect(request.callbackUrl).toBe('http://203.0.113.5:5337/RPC2'); + expect(request.diffDomain).toBe(false); + }); +}); diff --git a/packages/core/src/protocols/subscribe-request.ts b/packages/core/src/protocols/subscribe-request.ts new file mode 100644 index 0000000..31fd863 --- /dev/null +++ b/packages/core/src/protocols/subscribe-request.ts @@ -0,0 +1,77 @@ +import type { SubscribeRequest } from '../engine/dto.js'; +import type { Protocol } from '../engine/protocol.js'; +import { appMessages } from './app-messages.js'; + +/** Protocols a subscriber may register under. */ +const VALID_PROTOCOLS = ['http-post', 'https-post', 'xml-rpc']; + +/** + * The wire-neutral subscribe fields a front door has already pulled off its + * transport — the input to {@link buildSubscribeRequest}. Each dispatcher does + * its own extraction (form keys vs. positional params) and presence/arity + * validation, then hands the shared builder these fields. + */ +export interface SubscribeParams { + /** Feeds/topics the subscriber wants notifications for. */ + resourceUrls: string[]; + /** Callback port. */ + port: string; + /** Callback path. */ + path: string; + /** Requested delivery protocol (validated by the builder). */ + protocol: string; + /** Caller address, used as the callback host when no `domain` is given. */ + clientAddress: string; + /** Explicit callback host; absent/empty means "use the caller address". */ + domain?: string; + /** XML-RPC notify method, honoured only for the `xml-rpc` protocol. */ + notifyProcedure?: string; +} + +/** Assemble a callback URL from its parts the way the legacy `glueUrlParts` did. */ +function glueUrlParts( + scheme: string, + client: string, + port: string, + path: string +): string { + let host = client; + if (host.startsWith('::ffff:')) { + host = host.slice(7); + } + if (host.includes(':')) { + host = `[${host}]`; + } + + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${scheme}://${host}:${port}${normalizedPath}`; +} + +/** Assemble a {@link SubscribeRequest} from the wire-neutral subscribe fields. */ +export function buildSubscribeRequest( + params: SubscribeParams +): SubscribeRequest { + if (!VALID_PROTOCOLS.includes(params.protocol)) { + throw new Error( + appMessages.error.subscription.invalidProtocol(params.protocol) + ); + } + + // An absent or empty domain means "use the caller address" (ADR-0001). + const explicitDomain = params.domain !== undefined && params.domain !== ''; + const host = explicitDomain ? params.domain! : params.clientAddress; + const scheme = + params.protocol === 'https-post' || params.port === '443' + ? 'https' + : 'http'; + const request: SubscribeRequest = { + resourceUrls: params.resourceUrls, + callbackUrl: glueUrlParts(scheme, host, params.port, params.path), + protocol: params.protocol as Protocol, + diffDomain: explicitDomain + }; + if (params.notifyProcedure && params.protocol === 'xml-rpc') { + request.notifyProcedure = params.notifyProcedure; + } + return request; +} diff --git a/packages/core/src/protocols/xml-rpc-dispatcher.test.ts b/packages/core/src/protocols/xml-rpc-dispatcher.test.ts index 83a26cf..ced68ad 100644 --- a/packages/core/src/protocols/xml-rpc-dispatcher.test.ts +++ b/packages/core/src/protocols/xml-rpc-dispatcher.test.ts @@ -173,24 +173,33 @@ describe('createXmlRpcDispatcher pleaseNotify', () => { ]); }); - it('infers https from port 443 and strips a ::ffff: client prefix', async () => { + it('treats an empty-string domain as absent: client address, not diffDomain (ADR-0001)', async () => { + // Deliberate parity deviation: the legacy server took an empty-string + // domain down the explicit-domain branch (http://:5337/RPC2, + // diffDomain:true). The shared builder unifies empty with absent. const core = fakeCore(); const dispatcher = createXmlRpcDispatcher({ core }); await dispatcher.dispatch( methodCall('rssCloud.pleaseNotify', [ '', - '443', - '/cb', + '5337', + '/RPC2', 'http-post', - 'http://feed.example/rss' + 'http://feed.example/rss', + '' ]), - { clientAddress: '::ffff:198.51.100.7' } + { clientAddress: '203.0.113.5' } ); - expect(core.subscribeCalls[0]?.callbackUrl).toBe( - 'https://198.51.100.7:443/cb' - ); + expect(core.subscribeCalls).toEqual([ + { + resourceUrls: ['http://feed.example/rss'], + callbackUrl: 'http://203.0.113.5:5337/RPC2', + protocol: 'http-post', + diffDomain: false + } + ]); }); it('brackets a bare IPv6 domain, coerces an array urlList, omits a blank xml-rpc procedure', async () => { diff --git a/packages/core/src/protocols/xml-rpc-dispatcher.ts b/packages/core/src/protocols/xml-rpc-dispatcher.ts index f8e61b9..31585d3 100644 --- a/packages/core/src/protocols/xml-rpc-dispatcher.ts +++ b/packages/core/src/protocols/xml-rpc-dispatcher.ts @@ -1,12 +1,15 @@ import type { RssCloudCore } from '../engine/core.js'; import type { PingRequest, SubscribeRequest } from '../engine/dto.js'; -import type { Protocol } from '../engine/protocol.js'; import { appMessages, errorMessage, subscriptionFailureMessage, subscriptionRequestErrorMessage } from './app-messages.js'; +import { + buildSubscribeRequest, + type SubscribeParams +} from './subscribe-request.js'; import { parseMethodCall, serializeFault, @@ -32,32 +35,13 @@ export interface XmlRpcDispatcher { /** rssCloud faults are always faultCode 4. */ const FAULT_CODE = 4; -/** Protocols a subscriber may register under. */ -const VALID_PROTOCOLS = ['http-post', 'https-post', 'xml-rpc']; - -/** Assemble a callback URL from its parts the way the legacy `glueUrlParts` did. */ -function glueUrlParts( - scheme: string, - client: string, - port: string, - path: string -): string { - let host = client; - if (host.startsWith('::ffff:')) { - host = host.slice(7); - } - if (host.includes(':')) { - host = `[${host}]`; - } - - const normalizedPath = path.startsWith('/') ? path : `/${path}`; - return `${scheme}://${host}:${port}${normalizedPath}`; -} - /** * Map `pleaseNotify` positional params * (`notifyProcedure, port, path, protocol, urlList[, domain]`) into a - * `SubscribeRequest`. Throws (→ fault) on bad arity or an unsupported protocol. + * `SubscribeRequest`. Owns only the positional-wire extraction and the arity + * check; the callback-URL assembly, protocol validation, and `diffDomain`/scheme + * rules live in {@link buildSubscribeRequest}. Throws (→ fault) on bad arity or + * an unsupported protocol. */ function mapPleaseNotify( params: unknown[], @@ -70,47 +54,25 @@ function mapPleaseNotify( throw new Error(appMessages.error.rpc.tooManyParams('pleaseNotify')); } - const protocol = String(params[3]); - if (!VALID_PROTOCOLS.includes(protocol)) { - throw new Error( - appMessages.error.subscription.invalidProtocol(protocol) - ); - } - - const port = String(params[1]); - const path = String(params[2]); const urlList = params[4]; - const domain = params[5]; - - const resourceUrls = Array.isArray(urlList) - ? urlList.map((url) => String(url)) - : [String(urlList)]; - - let client: string; - let diffDomain: boolean; - if (domain === undefined) { - client = clientAddress; - diffDomain = false; - } else { - client = String(domain); - diffDomain = true; - } - - const scheme = - protocol === 'https-post' || port === '443' ? 'https' : 'http'; - - const request: SubscribeRequest = { - resourceUrls, - callbackUrl: glueUrlParts(scheme, client, port, path), - protocol: protocol as Protocol, - diffDomain + const subscribeParams: SubscribeParams = { + resourceUrls: Array.isArray(urlList) + ? urlList.map((url) => String(url)) + : [String(urlList)], + port: String(params[1]), + path: String(params[2]), + protocol: String(params[3]), + clientAddress }; - if (protocol === 'xml-rpc' && params[0]) { - request.notifyProcedure = String(params[0]); + if (params[5] !== undefined) { + subscribeParams.domain = String(params[5]); + } + if (params[0]) { + subscribeParams.notifyProcedure = String(params[0]); } - return request; + return buildSubscribeRequest(subscribeParams); } /** Map the single `ping` param into a `PingRequest`. Throws (→ fault) on bad arity. */ From 3528c55218422124b80a969ff9ccb1a9e2b8b9fb Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Thu, 11 Jun 2026 14:14:10 -0500 Subject: [PATCH 48/90] feat(core): own async store construction and teardown createRssCloudCore now accepts `store: Store | Promise`, resolving it once internally behind a Store facade that defers each call until the load completes. The host still gets a concrete `core` synchronously, so the @rsscloud/express mounts keep working at require time. It also exposes `core.store` (the ready facade, for read-side consumers) and `core.close()` (awaits construction, then flushes + closes the store via an optional `ClosableStore` lifecycle probed structurally; a no-op for stores without `close`, e.g. the in-memory store). This retires the hand-rolled 8-method deferred-store proxy in apps/server/core.js, which collapses to `createRssCloudCore({ store: createFileStore({ filePath }), ... })`. app.js shutdown hooks now call `core.close()` instead of `store.close()`. Keeping store construction async is deliberate (a future DB store needs async init); the deferral is now a reusable core capability rather than host plumbing. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 11 ---- apps/server/app.js | 8 +-- apps/server/core.js | 50 ++++------------- packages/core/src/engine/core.ts | 19 ++++++- packages/core/src/engine/create-core.test.ts | 59 ++++++++++++++++++++ packages/core/src/engine/create-core.ts | 37 +++++++++++- 6 files changed, 129 insertions(+), 55 deletions(-) diff --git a/TODO.md b/TODO.md index bfa9e6a..0ff90b3 100644 --- a/TODO.md +++ b/TODO.md @@ -40,17 +40,6 @@ vocabulary in `CONTEXT.md`. that take an injected core — built with `createInMemoryStore` in tests. Likely folds into the unify work (touches the same tests). -- [ ] **Let core own async store construction** (drop the server-side proxy). - `core.js` wraps `createFileStore(...)`'s promise in a hand-rolled deferred-store - proxy because `createRssCloudCore` needs a concrete `store` at sync `require` - time (express mounts need a concrete `core`). Instead, have `createRssCloudCore` - accept `Store | Promise` and own the resolve-once internally (expose a - ready `core.store`), so the server is just - `createRssCloudCore({ store: createFileStore({ filePath }) })`. **Keep - construction async on purpose** (decided 2026-06-11): a future MySQL/DB store - needs async init, so the async factory contract stays and core becomes the one - reusable home for the deferral. Pairs with the unify work (both core changes). - ## WebSub hub support (bigger — spans core + express) Make the server act as a [WebSub](https://www.w3.org/TR/websub/) **hub** (the W3C diff --git a/apps/server/app.js b/apps/server/app.js index f8e8645..35b2d27 100644 --- a/apps/server/app.js +++ b/apps/server/app.js @@ -9,7 +9,7 @@ const config = require('./config'), morgan = require('morgan'), removeExpiredSubscriptions = require('./services/remove-expired-subscriptions'), websocket = require('./services/websocket'), - { events: coreEvents, store } = require('./core'), + { core, events: coreEvents } = require('./core'), bridgeCoreEvents = require('./services/core-event-bridge'); let app, hbs, server, dayjs; @@ -93,7 +93,7 @@ app.use( app.use(require('./controllers')); async function gracefulShutdown() { - await store.close(); + await core.close(); process.exit(); } @@ -106,7 +106,7 @@ process.on('uncaughtException', error => { 'Uncaught exception, flushing data store before exit:', error ); - store.close().finally(() => process.exit(1)); + core.close().finally(() => process.exit(1)); }); process.on('unhandledRejection', reason => { @@ -114,7 +114,7 @@ process.on('unhandledRejection', reason => { 'Unhandled promise rejection, flushing data store before exit:', reason ); - store.close().finally(() => process.exit(1)); + core.close().finally(() => process.exit(1)); }); async function startServer() { diff --git a/apps/server/core.js b/apps/server/core.js index 28dccb5..89e76d8 100644 --- a/apps/server/core.js +++ b/apps/server/core.js @@ -20,47 +20,21 @@ const coreConfig = resolveConfig({ feedsChangedWindowDays: config.feedsChangedWindowDays }); -// createFileStore is async, but core.js is required synchronously — the -// @rsscloud/express middleware factories need a concrete `core` at mount time. -// Kick off the file store and front it with a proxy whose every call awaits the -// one-time load. The Store interface is already all-async, so this is -// transparent to core and to every require('./core') consumer; the first -// requests simply await initialization. flush()/close() are surfaced for the -// graceful-shutdown hooks in app.js. -const storeReady = createFileStore({ filePath: config.dataFilePath }); - -const store = { - async getResource(feedUrl) { - return (await storeReady).getResource(feedUrl); - }, - async putResource(feedUrl, resource) { - return (await storeReady).putResource(feedUrl, resource); - }, - async getSubscriptions(feedUrl) { - return (await storeReady).getSubscriptions(feedUrl); - }, - async putSubscriptions(feedUrl, subscriptions) { - return (await storeReady).putSubscriptions(feedUrl, subscriptions); - }, - async list() { - return (await storeReady).list(); - }, - async remove(feedUrl) { - return (await storeReady).remove(feedUrl); - }, - async flush() { - return (await storeReady).flush(); - }, - async close() { - return (await storeReady).close(); - } -}; - const plugins = [ createRestProtocolPlugin({ requestTimeoutMs: config.requestTimeout }), createXmlRpcProtocolPlugin({ requestTimeoutMs: config.requestTimeout }) ]; -const core = createRssCloudCore({ store, plugins, config: coreConfig }); +// createFileStore is async, but core.js is required synchronously — the +// @rsscloud/express middleware factories need a concrete `core` at mount time. +// core takes the store promise, resolves it once, and defers every operation +// until the load completes, so the host gets a concrete `core` immediately. +// `core.store` is the ready store the read-side controllers use; `core.close()` +// flushes + closes it for the graceful-shutdown hooks in app.js. +const core = createRssCloudCore({ + store: createFileStore({ filePath: config.dataFilePath }), + plugins, + config: coreConfig +}); -module.exports = { core, events: core.events, store }; +module.exports = { core, events: core.events, store: core.store }; diff --git a/packages/core/src/engine/core.ts b/packages/core/src/engine/core.ts index d2c0f62..fe46816 100644 --- a/packages/core/src/engine/core.ts +++ b/packages/core/src/engine/core.ts @@ -20,7 +20,12 @@ import type { Store } from '../store/store.js'; * for each. */ export interface RssCloudCoreOptions { - store: Store; + /** + * Persistence port. May be a concrete {@link Store} or a `Promise` of one, + * letting backends that need async init (file, database) be passed straight + * in — core resolves it once and defers operations until it is ready. + */ + store: Store | Promise; /** The plugin stack, already constructed and ready to use. */ plugins: ProtocolPlugin[]; /** Fully-resolved config (see `ResolveConfig` for defaults). */ @@ -54,6 +59,18 @@ export interface RssCloudCore { /** The observability bus (same instance passed in options, if any). */ readonly events: EventBus; + /** + * The persistence store, ready to use. When constructed from a + * `Promise`, this facade defers each call until the load resolves. + */ + readonly store: Store; + + /** + * Await async store construction and tear the store down (flush + close). + * A no-op for stores without a `close` lifecycle. Call on host shutdown. + */ + close(): Promise; + /** Drop expired/errored subscriptions and prune empty feeds. */ removeExpired(): Promise; /** Compute the activity snapshot. */ diff --git a/packages/core/src/engine/create-core.test.ts b/packages/core/src/engine/create-core.test.ts index fdf786a..7a93771 100644 --- a/packages/core/src/engine/create-core.test.ts +++ b/packages/core/src/engine/create-core.test.ts @@ -1034,3 +1034,62 @@ describe('createRssCloudCore removeExpired', () => { expect(await store.list()).toHaveLength(0); }); }); + +describe('createRssCloudCore async store construction', () => { + it('accepts a Promise, resolving it once for operations', async () => { + const store = createInMemoryStore(); + + const core = createRssCloudCore({ + store: Promise.resolve(store), + plugins: [], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + + const result = await core.ping({ resourceUrl: FEED }); + + expect(result.success).toBe(true); + expect((await store.getResource(FEED))?.ctChecks).toBe(1); + }); + + it('exposes core.store as a Store backed by the resolved store', async () => { + const inner = createInMemoryStore(); + + const core = createRssCloudCore({ + store: Promise.resolve(inner), + plugins: [], + config: resolveConfig(), + fetch: fetchReturning(RSS) + }); + + await core.ping({ resourceUrl: FEED }); + + const entries = await core.store.list(); + expect(entries.map(e => e.feedUrl)).toContain(FEED); + }); + + it('close() awaits construction and closes a store that can close', async () => { + const close = vi.fn(async () => undefined); + const inner = { ...createInMemoryStore(), close }; + + const core = createRssCloudCore({ + store: Promise.resolve(inner), + plugins: [], + config: resolveConfig() + }); + + await core.close(); + + expect(close).toHaveBeenCalledOnce(); + }); + + it('close() is a no-op when the store has no close lifecycle', async () => { + const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [], + config: resolveConfig() + }); + + await expect(core.close()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/core/src/engine/create-core.ts b/packages/core/src/engine/create-core.ts index 8877472..01c1d2c 100644 --- a/packages/core/src/engine/create-core.ts +++ b/packages/core/src/engine/create-core.ts @@ -16,6 +16,7 @@ import type { Protocol } from './protocol.js'; import type { Resource } from './resource.js'; import type { FeedStat, MaintenanceResult, Stats } from './stats.js'; import type { Subscription } from './subscription.js'; +import type { Store } from '../store/store.js'; import type { RssCloudCore, RssCloudCoreOptions @@ -27,6 +28,15 @@ function md5(value: string): string { return createHash('md5').update(value).digest('hex'); } +/** Teardown a Store may optionally implement (e.g. a file-backed store). */ +interface ClosableStore { + close(): Promise; +} + +function isClosable(store: Store): store is Store & ClosableStore { + return typeof (store as Partial).close === 'function'; +} + /** * The protocol-neutral rssCloud engine. Owns change detection and fan-out and * exposes the housekeeping jobs the host schedules; transports are supplied as @@ -35,7 +45,23 @@ function md5(value: string): string { export function createRssCloudCore( options: RssCloudCoreOptions ): RssCloudCore { - const { store, plugins, config } = options; + const { plugins, config } = options; + // Construction may be async (e.g. a file- or DB-backed store): normalize the + // injected store to a resolve-once promise and front it with a Store facade + // whose every call awaits that one-time load. The host gets a concrete `core` + // synchronously; the first operations simply await initialization. + const storeReady = Promise.resolve(options.store); + const store: Store = { + getResource: feedUrl => storeReady.then(s => s.getResource(feedUrl)), + putResource: (feedUrl, resource) => + storeReady.then(s => s.putResource(feedUrl, resource)), + getSubscriptions: feedUrl => + storeReady.then(s => s.getSubscriptions(feedUrl)), + putSubscriptions: (feedUrl, subscriptions) => + storeReady.then(s => s.putSubscriptions(feedUrl, subscriptions)), + list: () => storeReady.then(s => s.list()), + remove: feedUrl => storeReady.then(s => s.remove(feedUrl)) + }; const events = options.events ?? createEventBus(); const doFetch = options.fetch ?? fetch; const now = options.now ?? (() => new Date()); @@ -539,11 +565,20 @@ export function createRssCloudCore( }; } + async function close(): Promise { + const resolved = await storeReady; + if (isClosable(resolved)) { + await resolved.close(); + } + } + return { subscribe, unsubscribe, ping, events, + store, + close, removeExpired, generateStats }; From 2c829c01fea24d6424e4403dfe521fdbe8127828 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Fri, 12 Jun 2026 14:39:35 -0500 Subject: [PATCH 49/90] refactor(server): inject core into stats, opml, and remove-expired services Convert the three store-reaching services from importing the production `core`/`store` singleton at module load to factories that take an injected core: - remove-expired-subscriptions: createRemoveExpiredSubscriptions({ core }) - feeds-opml: createFeedsOpml({ core }) (reads core.store) - stats: createStats({ core }) (stats-file I/O stays a host concern) Production wiring (app.js, controllers/index.js, controllers/stats.js, controllers/test.js) builds each instance from the singleton; their tests now build an isolated in-memory core via createInMemoryStore and seed core-model objects directly, dropping both the legacy-store-shape mappers and the DATA_FILE_PATH temp-file dance. Per-test isolation is now structural, so the clear-between-tests hooks are gone (and the suite runs ~15x faster). This removes 3 of the 5 legacy-store-shape consumers, shrinking the upcoming disk-format unify to the two remaining controller seams. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/app.js | 7 +- apps/server/controllers/index.js | 6 +- apps/server/controllers/stats.js | 4 +- apps/server/controllers/test.js | 6 +- apps/server/services/feeds-opml.js | 85 ++++----- apps/server/services/feeds-opml.test.js | 109 ++++++------ .../services/remove-expired-subscriptions.js | 12 +- .../remove-expired-subscriptions.test.js | 146 ++++++++-------- apps/server/services/stats.js | 50 +++--- apps/server/services/stats.test.js | 165 ++++++++++-------- 10 files changed, 315 insertions(+), 275 deletions(-) diff --git a/apps/server/app.js b/apps/server/app.js index 35b2d27..efe592a 100644 --- a/apps/server/app.js +++ b/apps/server/app.js @@ -5,13 +5,16 @@ const config = require('./config'), express = require('express'), exphbs = require('express-handlebars'), getDayjs = require('./services/dayjs-wrapper'), - stats = require('./services/stats'), + { createStats } = require('./services/stats'), morgan = require('morgan'), - removeExpiredSubscriptions = require('./services/remove-expired-subscriptions'), + createRemoveExpiredSubscriptions = require('./services/remove-expired-subscriptions'), websocket = require('./services/websocket'), { core, events: coreEvents } = require('./core'), bridgeCoreEvents = require('./services/core-event-bridge'); +const stats = createStats({ core }); +const removeExpiredSubscriptions = createRemoveExpiredSubscriptions({ core }); + let app, hbs, server, dayjs; console.log(`${config.appName} ${config.appVersion}`); diff --git a/apps/server/controllers/index.js b/apps/server/controllers/index.js index 9b57578..f06e830 100644 --- a/apps/server/controllers/index.js +++ b/apps/server/controllers/index.js @@ -1,10 +1,13 @@ const express = require('express'), fs = require('fs'), md = require('markdown-it')(), - { generateOpml } = require('../services/feeds-opml'), + { createFeedsOpml } = require('../services/feeds-opml'), + { createStats } = require('../services/stats'), { toLegacyData } = require('../services/legacy-store-shape'), { ping, pleaseNotify, rpc2 } = require('@rsscloud/express'), { core, store } = require('../core'), + { generateOpml } = createFeedsOpml({ core }), + { getStats } = createStats({ core }), router = new express.Router(); // Core-backed protocol front doors (@rsscloud/express driving @rsscloud/core). @@ -40,7 +43,6 @@ router.use('/viewLog', require('./view-log')); router.use('/stats', require('./stats')); router.get('/stats.json', (req, res) => { - const { getStats } = require('../services/stats'); res.set('Content-Type', 'application/json'); res.send(JSON.stringify(getStats(), null, 2)); }); diff --git a/apps/server/controllers/stats.js b/apps/server/controllers/stats.js index 4a879d9..412b61d 100644 --- a/apps/server/controllers/stats.js +++ b/apps/server/controllers/stats.js @@ -1,5 +1,7 @@ const express = require('express'), - { getStats } = require('../services/stats'), + { createStats } = require('../services/stats'), + { core } = require('../core'), + { getStats } = createStats({ core }), router = new express.Router(); router.get('/', function(req, res) { diff --git a/apps/server/controllers/test.js b/apps/server/controllers/test.js index d7012f4..53ad391 100644 --- a/apps/server/controllers/test.js +++ b/apps/server/controllers/test.js @@ -1,5 +1,5 @@ const express = require('express'), - { store } = require('../core'), + { core, store } = require('../core'), { toCoreResource, toLegacyResource, @@ -7,9 +7,11 @@ const express = require('express'), toLegacySubscription, toLegacyData } = require('../services/legacy-store-shape'), - removeExpiredSubscriptions = require('../services/remove-expired-subscriptions'), + createRemoveExpiredSubscriptions = require('../services/remove-expired-subscriptions'), router = new express.Router(); +const removeExpiredSubscriptions = createRemoveExpiredSubscriptions({ core }); + console.warn( '[test-api] ENABLE_TEST_API=true — /test/* endpoints are mounted. Never enable in production.' ); diff --git a/apps/server/services/feeds-opml.js b/apps/server/services/feeds-opml.js index 80e7d63..b9dc9ee 100644 --- a/apps/server/services/feeds-opml.js +++ b/apps/server/services/feeds-opml.js @@ -1,53 +1,56 @@ const builder = require('xmlbuilder'); const config = require('../config'); const getDayjs = require('./dayjs-wrapper'); -const { store } = require('../core'); // Builds the `/feeds.opml` document: every tracked feed as an , // sorted case-insensitively by display text. The controller owns the HTTP // response (Content-Type + error forwarding); this returns the XML string. -// Reads the core store, whose `resource.feed` metadata is null/absent for a -// feed that has never been pinged (so text falls back to the feed URL). -async function generateOpml() { - const dayjs = await getDayjs(); - const nowIso = dayjs().utc().format(); - - const entries = await store.list(); - const outlines = []; - - for (const { feedUrl, resource } of entries) { - const feed = (resource && resource.feed) || {}; - const text = feed.title || feedUrl; - const outline = { - type: feed.type || 'rss', - text, - xmlUrl: feedUrl - }; - if (feed.title) outline.title = feed.title; - if (feed.description) outline.description = feed.description; - if (feed.htmlUrl) outline.htmlUrl = feed.htmlUrl; - if (feed.language) outline.language = feed.language; - outlines.push(outline); - } +// Reads the injected core's store, whose `resource.feed` metadata is null/absent +// for a feed that has never been pinged (so text falls back to the feed URL). +function createFeedsOpml({ core }) { + async function generateOpml() { + const dayjs = await getDayjs(); + const nowIso = dayjs().utc().format(); + + const entries = await core.store.list(); + const outlines = []; + + for (const { feedUrl, resource } of entries) { + const feed = (resource && resource.feed) || {}; + const text = feed.title || feedUrl; + const outline = { + type: feed.type || 'rss', + text, + xmlUrl: feedUrl + }; + if (feed.title) outline.title = feed.title; + if (feed.description) outline.description = feed.description; + if (feed.htmlUrl) outline.htmlUrl = feed.htmlUrl; + if (feed.language) outline.language = feed.language; + outlines.push(outline); + } + + outlines.sort((a, b) => + a.text.toLowerCase().localeCompare(b.text.toLowerCase()) + ); + + const opml = builder.create('opml', { + version: '1.0', + encoding: 'UTF-8' + }); + opml.att('version', '2.0'); + const head = opml.ele('head'); + head.ele('title', {}, `rssCloud Server feeds (${config.domain})`); + head.ele('dateCreated', {}, nowIso); + const body = opml.ele('body'); + for (const o of outlines) { + body.ele('outline', o); + } - outlines.sort((a, b) => - a.text.toLowerCase().localeCompare(b.text.toLowerCase()) - ); - - const opml = builder.create('opml', { - version: '1.0', - encoding: 'UTF-8' - }); - opml.att('version', '2.0'); - const head = opml.ele('head'); - head.ele('title', {}, `rssCloud Server feeds (${config.domain})`); - head.ele('dateCreated', {}, nowIso); - const body = opml.ele('body'); - for (const o of outlines) { - body.ele('outline', o); + return opml.end({ pretty: true }); } - return opml.end({ pretty: true }); + return { generateOpml }; } -module.exports = { generateOpml }; +module.exports = { createFeedsOpml }; diff --git a/apps/server/services/feeds-opml.test.js b/apps/server/services/feeds-opml.test.js index 52dee27..e0e7269 100644 --- a/apps/server/services/feeds-opml.test.js +++ b/apps/server/services/feeds-opml.test.js @@ -1,55 +1,66 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const xml2js = require('xml2js'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); - -// generateOpml reads the core store; point DATA_FILE_PATH at a throwaway temp -// file (config snapshots env at require time) so the file store stays isolated -// once it backs core. -const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rsscloud-opml-')); -process.env.DATA_FILE_PATH = path.join(tmpDir, 'subscriptions.json'); - +const { + createRssCloudCore, + createInMemoryStore, + resolveConfig +} = require('@rsscloud/core'); const config = require('../config'); -const { store } = require('../core'); -const { toCoreResource, toCoreSubscription } = require('./legacy-store-shape'); -const { generateOpml } = require('./feeds-opml'); - -async function parseOpml(xml) { - return new xml2js.Parser().parseStringPromise(xml); +const { createFeedsOpml } = require('./feeds-opml'); + +// A fresh in-memory-backed core + the service under it, isolated per test. +function setup() { + const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [], + config: resolveConfig({}) + }); + return { core, ...createFeedsOpml({ core }) }; } -async function seedResource(feedUrl, resource) { - const core = toCoreResource(feedUrl, resource); - if (core === null) { - // No real resource fields: a subscriptions-only (never-pinged) entry. - await store.putSubscriptions(feedUrl, []); - } else { - await store.putResource(feedUrl, core); - } +function makeResource(feedUrl, feed) { + const resource = { + url: feedUrl, + lastHash: '', + lastSize: 0, + ctChecks: 0, + whenLastCheck: new Date(0), + ctUpdates: 0, + whenLastUpdate: new Date(0) + }; + if (feed) resource.feed = feed; + return resource; } -async function seedSubscriptions(feedUrl, subscriptions) { - await store.putSubscriptions(feedUrl, subscriptions.map(toCoreSubscription)); +function makeSubscription(overrides = {}) { + return { + url: 'http://sub.example.com/notify', + protocol: 'http-post', + ctUpdates: 0, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: new Date(), + whenLastUpdate: null, + whenLastError: null, + whenExpires: new Date(Date.now() + 86400000), + ...overrides + }; } -async function clearStore() { - for (const { feedUrl } of await store.list()) { - await store.remove(feedUrl); - } +async function parseOpml(xml) { + return new xml2js.Parser().parseStringPromise(xml); } -test.beforeEach(clearStore); - test('generateOpml renders a feed with full metadata as an outline', async() => { - await seedResource('https://a.example.com/feed.xml', { - feedType: 'atom', - feedTitle: 'Alpha', - feedDescription: 'The Alpha feed', - feedHtmlUrl: 'https://a.example.com/', - feedLanguage: 'en-us' - }); + const { core, generateOpml } = setup(); + await core.store.putResource('https://a.example.com/feed.xml', makeResource('https://a.example.com/feed.xml', { + type: 'atom', + title: 'Alpha', + description: 'The Alpha feed', + htmlUrl: 'https://a.example.com/', + language: 'en-us' + })); const result = await parseOpml(await generateOpml()); @@ -75,15 +86,12 @@ test('generateOpml renders a feed with full metadata as an outline', async() => }); test('generateOpml sorts case-insensitively and falls back to the feed URL', async() => { + const { core, generateOpml } = setup(); // Untitled feed: text falls back to the URL, type defaults to rss, and no // title/description/htmlUrl/language attributes are emitted. - await seedResource('https://apple.example.com/feed.xml', {}); - await seedResource('https://b.example.com/feed.xml', { - feedTitle: 'banana' - }); - await seedResource('https://z.example.com/feed.xml', { - feedTitle: 'Cherry' - }); + await core.store.putResource('https://apple.example.com/feed.xml', makeResource('https://apple.example.com/feed.xml')); + await core.store.putResource('https://b.example.com/feed.xml', makeResource('https://b.example.com/feed.xml', { title: 'banana' })); + await core.store.putResource('https://z.example.com/feed.xml', makeResource('https://z.example.com/feed.xml', { title: 'Cherry' })); const result = await parseOpml(await generateOpml()); const outlines = result.opml.body[0].outline; @@ -100,13 +108,8 @@ test('generateOpml sorts case-insensitively and falls back to the feed URL', asy }); test('generateOpml lists a subscribed feed that was never pinged', async() => { - await seedSubscriptions('https://new.example.com/feed.xml', [ - { - url: 'http://sub.example.com/notify', - protocol: 'http-post', - whenExpires: new Date(Date.now() + 86400000).toISOString() - } - ]); + const { core, generateOpml } = setup(); + await core.store.putSubscriptions('https://new.example.com/feed.xml', [makeSubscription()]); const result = await parseOpml(await generateOpml()); const outlines = result.opml.body[0].outline; diff --git a/apps/server/services/remove-expired-subscriptions.js b/apps/server/services/remove-expired-subscriptions.js index 6474f7a..260a1cf 100644 --- a/apps/server/services/remove-expired-subscriptions.js +++ b/apps/server/services/remove-expired-subscriptions.js @@ -12,10 +12,12 @@ // - treats ctConsecutiveErrors >= maxConsecutiveErrors as exhausted (was a // strict >), matching core's delivery filter. -const { core } = require('../core'); - -function removeExpiredSubscriptions() { - return core.removeExpired(); +// Built with an injected core so callers (production wiring, the /test/* API) +// supply the singleton while tests supply an in-memory core. +function createRemoveExpiredSubscriptions({ core }) { + return function removeExpiredSubscriptions() { + return core.removeExpired(); + }; } -module.exports = removeExpiredSubscriptions; +module.exports = createRemoveExpiredSubscriptions; diff --git a/apps/server/services/remove-expired-subscriptions.test.js b/apps/server/services/remove-expired-subscriptions.test.js index 9cd4b89..fdb2593 100644 --- a/apps/server/services/remove-expired-subscriptions.test.js +++ b/apps/server/services/remove-expired-subscriptions.test.js @@ -1,129 +1,127 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); - -// The store is the core singleton (json-store-backed via the adapter today, -// createFileStore tomorrow). Point DATA_FILE_PATH at a throwaway temp file so -// the file store stays isolated once it backs core — config snapshots env at -// require time, so set it before requiring anything. -const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rsscloud-rmexp-')); -process.env.DATA_FILE_PATH = path.join(tmpDir, 'subscriptions.json'); - -const config = require('../config'); -const { store } = require('../core'); -const { toCoreResource, toCoreSubscription } = require('./legacy-store-shape'); -const removeExpiredSubscriptions = require('./remove-expired-subscriptions'); - +const { + createRssCloudCore, + createInMemoryStore, + resolveConfig +} = require('@rsscloud/core'); +const createRemoveExpiredSubscriptions = require('./remove-expired-subscriptions'); + +const coreConfig = resolveConfig({}); const DAY_MS = 24 * 60 * 60 * 1000; -const iso = offsetMs => new Date(Date.now() + offsetMs).toISOString(); +const at = offsetMs => new Date(Date.now() + offsetMs); + +const expired = () => at(-DAY_MS); +const active = () => at(DAY_MS); +const withinWindow = () => at(-DAY_MS); +const beyondWindow = () => at(-10 * DAY_MS); + +// A fresh in-memory-backed core + the service under it, fully isolated per test +// (no shared file store, so no clear-between-tests dance). +function setup() { + const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [], + config: coreConfig + }); + return { core, removeExpiredSubscriptions: createRemoveExpiredSubscriptions({ core }) }; +} -const expired = () => iso(-DAY_MS); -const active = () => iso(DAY_MS); -const withinWindow = () => iso(-DAY_MS); -const beyondWindow = () => iso(-10 * DAY_MS); +function makeResource(feedUrl, { whenLastUpdate = new Date(0) } = {}) { + return { + url: feedUrl, + lastHash: '', + lastSize: 0, + ctChecks: 0, + whenLastCheck: new Date(0), + ctUpdates: 0, + whenLastUpdate + }; +} -function subscription(overrides = {}) { +function makeSubscription(overrides = {}) { return { url: 'http://sub.example.com/notify', protocol: 'http-post', - whenExpires: active(), + ctUpdates: 0, + ctErrors: 0, ctConsecutiveErrors: 0, + whenCreated: active(), + whenLastUpdate: null, + whenLastError: null, + whenExpires: active(), ...overrides }; } -async function seedResource(feedUrl, resource) { - await store.putResource(feedUrl, toCoreResource(feedUrl, resource)); -} - -async function seedSubscriptions(feedUrl, subscriptions) { - await store.putSubscriptions(feedUrl, subscriptions.map(toCoreSubscription)); +async function entryFor(core, feedUrl) { + return (await core.store.list()).find(e => e.feedUrl === feedUrl); } -async function clearStore() { - for (const { feedUrl } of await store.list()) { - await store.remove(feedUrl); - } -} - -async function entryFor(feedUrl) { - return (await store.list()).find(e => e.feedUrl === feedUrl); -} - -test.beforeEach(clearStore); - test('removes an expired subscription and prunes the now-empty feed', async() => { + const { core, removeExpiredSubscriptions } = setup(); const feed = 'https://a.example.com/feed.xml'; - await seedSubscriptions(feed, [subscription({ whenExpires: expired() })]); + await core.store.putSubscriptions(feed, [makeSubscription({ whenExpires: expired() })]); const result = await removeExpiredSubscriptions(); assert.equal(result.subscriptionsRemoved, 1); - assert.equal(await entryFor(feed), undefined); + assert.equal(await entryFor(core, feed), undefined); }); test('clears an expired subscription but retains a recently-updated feed', async() => { + const { core, removeExpiredSubscriptions } = setup(); const feed = 'https://b.example.com/feed.xml'; - await seedResource(feed, { - feedTitle: 'Bravo', - whenLastUpdate: withinWindow() - }); - await seedSubscriptions(feed, [subscription({ whenExpires: expired() })]); + await core.store.putResource(feed, makeResource(feed, { whenLastUpdate: withinWindow() })); + await core.store.putSubscriptions(feed, [makeSubscription({ whenExpires: expired() })]); const result = await removeExpiredSubscriptions(); assert.equal(result.subscriptionsRemoved, 1); - const entry = await entryFor(feed); + const entry = await entryFor(core, feed); assert.ok(entry); assert.deepEqual(entry.subscriptions, []); }); test('removes a feed whose resource is older than the retention window', async() => { + const { core, removeExpiredSubscriptions } = setup(); const feed = 'https://c.example.com/feed.xml'; - await seedResource(feed, { - feedTitle: 'Charlie', - whenLastUpdate: beyondWindow() - }); - await seedSubscriptions(feed, [subscription({ whenExpires: expired() })]); + await core.store.putResource(feed, makeResource(feed, { whenLastUpdate: beyondWindow() })); + await core.store.putSubscriptions(feed, [makeSubscription({ whenExpires: expired() })]); await removeExpiredSubscriptions(); - assert.equal(await entryFor(feed), undefined); + assert.equal(await entryFor(core, feed), undefined); }); test('leaves active subscriptions untouched', async() => { + const { core, removeExpiredSubscriptions } = setup(); const feed = 'https://d.example.com/feed.xml'; - await seedResource(feed, { - feedTitle: 'Delta', - whenLastUpdate: withinWindow() - }); - await seedSubscriptions(feed, [subscription({ whenExpires: active() })]); + await core.store.putResource(feed, makeResource(feed, { whenLastUpdate: withinWindow() })); + await core.store.putSubscriptions(feed, [makeSubscription({ whenExpires: active() })]); const result = await removeExpiredSubscriptions(); assert.equal(result.subscriptionsRemoved, 0); - const entry = await entryFor(feed); + const entry = await entryFor(core, feed); assert.ok(entry); assert.equal(entry.subscriptions.length, 1); }); test('removes an orphaned resource with no subscriptions', async() => { + const { core, removeExpiredSubscriptions } = setup(); const feed = 'https://e.example.com/feed.xml'; - await seedResource(feed, { - feedTitle: 'Echo', - whenLastUpdate: beyondWindow() - }); + await core.store.putResource(feed, makeResource(feed, { whenLastUpdate: beyondWindow() })); await removeExpiredSubscriptions(); - assert.equal(await entryFor(feed), undefined); + assert.equal(await entryFor(core, feed), undefined); }); test('returns the core MaintenanceResult shape', async() => { + const { core, removeExpiredSubscriptions } = setup(); const feed = 'https://f.example.com/feed.xml'; - await seedSubscriptions(feed, [subscription({ whenExpires: expired() })]); + await core.store.putSubscriptions(feed, [makeSubscription({ whenExpires: expired() })]); const result = await removeExpiredSubscriptions(); @@ -136,21 +134,19 @@ test('returns the core MaintenanceResult shape', async() => { }); test('removes a subscription that has reached the consecutive-error limit', async() => { + const { core, removeExpiredSubscriptions } = setup(); const feed = 'https://g.example.com/feed.xml'; - await seedResource(feed, { - feedTitle: 'Golf', - whenLastUpdate: withinWindow() - }); - await seedSubscriptions(feed, [ - subscription({ + await core.store.putResource(feed, makeResource(feed, { whenLastUpdate: withinWindow() })); + await core.store.putSubscriptions(feed, [ + makeSubscription({ whenExpires: active(), - ctConsecutiveErrors: config.maxConsecutiveErrors + ctConsecutiveErrors: coreConfig.maxConsecutiveErrors }) ]); const result = await removeExpiredSubscriptions(); assert.equal(result.subscriptionsRemoved, 1); - const entry = await entryFor(feed); + const entry = await entryFor(core, feed); assert.deepEqual(entry.subscriptions, []); }); diff --git a/apps/server/services/stats.js b/apps/server/services/stats.js index 2cf9570..9aa3e37 100644 --- a/apps/server/services/stats.js +++ b/apps/server/services/stats.js @@ -1,7 +1,6 @@ const fs = require('fs'); const path = require('path'); const config = require('../config'); -const { core } = require('../core'); // Protocols the legacy stats shape always reports, even at zero. core only // includes protocols it actually saw, so we seed these and merge core's counts. @@ -50,29 +49,36 @@ function toLegacyStats(coreStats) { }; } -async function generateStats() { - const stats = toLegacyStats(await core.generateStats()); +// Built with an injected core so callers (production wiring) supply the +// singleton while tests supply an in-memory core. getStats/scheduleStatsGeneration +// touch only the stats file (a host concern) and so don't depend on the store. +function createStats({ core }) { + async function generateStats() { + const stats = toLegacyStats(await core.generateStats()); - // Write atomically - const filePath = getStatsFilePath(); - const dir = path.dirname(filePath); - fs.mkdirSync(dir, { recursive: true }); - const tmpPath = filePath + '.tmp'; - fs.writeFileSync(tmpPath, JSON.stringify(stats, null, 2)); - fs.renameSync(tmpPath, filePath); + // Write atomically + const filePath = getStatsFilePath(); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + const tmpPath = filePath + '.tmp'; + fs.writeFileSync(tmpPath, JSON.stringify(stats, null, 2)); + fs.renameSync(tmpPath, filePath); - console.log('Stats generated successfully'); - return stats; -} + console.log('Stats generated successfully'); + return stats; + } + + function scheduleStatsGeneration() { + setInterval(async() => { + try { + await generateStats(); + } catch (error) { + console.error('Error generating stats:', error); + } + }, config.statsIntervalMs); + } -function scheduleStatsGeneration() { - setInterval(async() => { - try { - await generateStats(); - } catch (error) { - console.error('Error generating stats:', error); - } - }, config.statsIntervalMs); + return { generateStats, getStats, scheduleStatsGeneration }; } -module.exports = { generateStats, getStats, scheduleStatsGeneration }; +module.exports = { createStats }; diff --git a/apps/server/services/stats.test.js b/apps/server/services/stats.test.js index 87bb9f6..a22ae4c 100644 --- a/apps/server/services/stats.test.js +++ b/apps/server/services/stats.test.js @@ -3,98 +3,118 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); - -// stats.js reads config.statsFilePath and core reads DATA_FILE_PATH; config -// snapshots process.env at require time, so point both at throwaway temp files -// before requiring anything. +const { + createRssCloudCore, + createInMemoryStore, + resolveConfig +} = require('@rsscloud/core'); + +// generateStats/getStats persist to config.statsFilePath (a host concern, not +// the core store), so still point STATS_FILE_PATH at a throwaway temp file — +// config snapshots env at require time. The store, by contrast, is now an +// injected in-memory core, so no DATA_FILE_PATH dance is needed. const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rsscloud-stats-')); process.env.STATS_FILE_PATH = path.join(tmpDir, 'stats.json'); -process.env.DATA_FILE_PATH = path.join(tmpDir, 'subscriptions.json'); const config = require('../config'); -const { store } = require('../core'); -const { toCoreResource, toCoreSubscription } = require('./legacy-store-shape'); -const stats = require('./stats'); +const { createStats } = require('./stats'); + +const DAY_MS = 24 * 60 * 60 * 1000; -async function seedResource(feedUrl, resource) { - await store.putResource(feedUrl, toCoreResource(feedUrl, resource)); +// A fresh in-memory-backed core + the service under it, isolated per test. +function setup() { + const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [], + config: resolveConfig({}) + }); + return { core, ...createStats({ core }) }; } -async function seedSubscriptions(feedUrl, subscriptions) { - await store.putSubscriptions(feedUrl, subscriptions.map(toCoreSubscription)); +function makeResource(feedUrl, { title, whenLastUpdate = new Date(0) } = {}) { + const resource = { + url: feedUrl, + lastHash: '', + lastSize: 0, + ctChecks: 0, + whenLastCheck: new Date(0), + ctUpdates: 0, + whenLastUpdate + }; + if (title) resource.feed = { title }; + return resource; } -async function clearStore() { - for (const { feedUrl } of await store.list()) { - await store.remove(feedUrl); - } +function makeSubscription(overrides = {}) { + return { + url: 'http://sub.example.com/notify', + protocol: 'http-post', + ctUpdates: 0, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: new Date(), + whenLastUpdate: null, + whenLastError: null, + whenExpires: new Date(Date.now() + DAY_MS), + ...overrides + }; } -test.beforeEach(async() => { - await clearStore(); +const EMPTY_STATS = { + generatedAt: null, + feedsChangedLast7Days: 0, + feedsWithSubscribers: 0, + uniqueAggregators: 0, + totalActiveSubscriptions: 0, + topFeeds: [], + moreFeeds: [], + protocolBreakdown: { 'http-post': 0, 'https-post': 0, 'xml-rpc': 0 } +}; + +test.beforeEach(() => { fs.rmSync(config.statsFilePath, { force: true }); }); test('getStats returns the default shape when no stats file exists', () => { - assert.deepEqual(stats.getStats(), { - generatedAt: null, - feedsChangedLast7Days: 0, - feedsWithSubscribers: 0, - uniqueAggregators: 0, - totalActiveSubscriptions: 0, - topFeeds: [], - moreFeeds: [], - protocolBreakdown: { 'http-post': 0, 'https-post': 0, 'xml-rpc': 0 } - }); + const { getStats } = setup(); + assert.deepEqual(getStats(), EMPTY_STATS); }); test('generateStats persists an empty snapshot getStats reads back', async() => { - const generated = await stats.generateStats(); + const { generateStats, getStats } = setup(); + const generated = await generateStats(); assert.equal(typeof generated.generatedAt, 'string'); assert.ok(!Number.isNaN(Date.parse(generated.generatedAt))); - assert.deepEqual( - { ...generated, generatedAt: null }, - { - generatedAt: null, - feedsChangedLast7Days: 0, - feedsWithSubscribers: 0, - uniqueAggregators: 0, - totalActiveSubscriptions: 0, - topFeeds: [], - moreFeeds: [], - protocolBreakdown: { 'http-post': 0, 'https-post': 0, 'xml-rpc': 0 } - } - ); - assert.deepEqual(stats.getStats(), generated); + assert.deepEqual({ ...generated, generatedAt: null }, EMPTY_STATS); + assert.deepEqual(getStats(), generated); }); -const DAY_MS = 24 * 60 * 60 * 1000; - test('generateStats aggregates active subscriptions into the legacy shape', async() => { - const recent = new Date(Date.now() - DAY_MS).toISOString(); - const future = new Date(Date.now() + DAY_MS).toISOString(); - const past = new Date(Date.now() - DAY_MS).toISOString(); + const { core, generateStats } = setup(); + const recent = new Date(Date.now() - DAY_MS); + const future = new Date(Date.now() + DAY_MS); + const past = new Date(Date.now() - DAY_MS); - await seedResource('https://a.example.com/feed.xml', { - feedTitle: 'Alpha', + await core.store.putResource('https://a.example.com/feed.xml', makeResource('https://a.example.com/feed.xml', { + title: 'Alpha', whenLastUpdate: recent - }); - await seedSubscriptions('https://a.example.com/feed.xml', [ - { url: 'http://sub1.example.com/notify', protocol: 'http-post', whenExpires: future }, - { url: 'http://sub2.example.com/notify', protocol: 'http-post', whenExpires: future }, - { url: 'http://gone.example.com/notify', protocol: 'http-post', whenExpires: past } + })); + await core.store.putSubscriptions('https://a.example.com/feed.xml', [ + makeSubscription({ url: 'http://sub1.example.com/notify', whenExpires: future }), + makeSubscription({ url: 'http://sub2.example.com/notify', whenExpires: future }), + makeSubscription({ url: 'http://gone.example.com/notify', whenExpires: past }) ]); - await seedResource('https://b.example.com/feed.xml', { - feedTitle: 'Bravo', + await core.store.putResource('https://b.example.com/feed.xml', makeResource('https://b.example.com/feed.xml', { + title: 'Bravo', whenLastUpdate: recent - }); - await seedSubscriptions('https://b.example.com/feed.xml', [ - { url: 'http://sub1.example.com/notify', protocol: 'http-post', whenExpires: future } + })); + await core.store.putSubscriptions('https://b.example.com/feed.xml', [ + makeSubscription({ url: 'http://sub1.example.com/notify', whenExpires: future }) ]); - const generated = await stats.generateStats(); + const generated = await generateStats(); assert.equal(generated.feedsChangedLast7Days, 2); assert.equal(generated.feedsWithSubscribers, 2); @@ -111,13 +131,13 @@ test('generateStats aggregates active subscriptions into the legacy shape', asyn { url: 'https://a.example.com/feed.xml', subscriberCount: 2, - whenLastUpdate: new Date(recent).toISOString(), + whenLastUpdate: recent.toISOString(), feedTitle: 'Alpha' }, { url: 'https://b.example.com/feed.xml', subscriberCount: 1, - whenLastUpdate: new Date(recent).toISOString(), + whenLastUpdate: recent.toISOString(), feedTitle: 'Bravo' } ]); @@ -125,17 +145,18 @@ test('generateStats aggregates active subscriptions into the legacy shape', asyn }); test('generateStats omits feeds whose subscriptions have all expired', async() => { - const past = new Date(Date.now() - DAY_MS).toISOString(); - - await seedResource('https://stale.example.com/feed.xml', { - feedTitle: 'Stale', - whenLastUpdate: new Date(Date.now() - DAY_MS).toISOString() - }); - await seedSubscriptions('https://stale.example.com/feed.xml', [ - { url: 'http://gone.example.com/notify', protocol: 'http-post', whenExpires: past } + const { core, generateStats } = setup(); + const past = new Date(Date.now() - DAY_MS); + + await core.store.putResource('https://stale.example.com/feed.xml', makeResource('https://stale.example.com/feed.xml', { + title: 'Stale', + whenLastUpdate: new Date(Date.now() - DAY_MS) + })); + await core.store.putSubscriptions('https://stale.example.com/feed.xml', [ + makeSubscription({ url: 'http://gone.example.com/notify', whenExpires: past }) ]); - const generated = await stats.generateStats(); + const generated = await generateStats(); assert.equal(generated.feedsWithSubscribers, 0); assert.equal(generated.totalActiveSubscriptions, 0); From 9876a447cd87eb9aff6885746e7091299d79c2c7 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Fri, 12 Jun 2026 14:46:46 -0500 Subject: [PATCH 50/90] docs: drop completed injectable-core follow-up; note reduced unify surface The three service unit tests now build injected in-memory cores, so the "Injectable core for the service unit tests" follow-up is done. Record on the unify item that controllers/index.js and controllers/test.js are the only remaining legacy-store-shape.js consumers. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index 0ff90b3..d95827c 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,11 @@ vocabulary in `CONTEXT.md`. `getResource()` (in-memory becomes the core model directly). `file-store.ts` is left doing only date (de)serialization + a one-way legacy importer. + The three service unit tests (`stats` / `feeds-opml` / `remove-expired`) already + build injected in-memory cores and seed the core model directly, so + `controllers/index.js` (`/subscriptions.json`) and `controllers/test.js` + (`/test/*`) are now the only remaining `legacy-store-shape.js` consumers. + *Migration flow (self-completing, no manual step):* - Load precedence: `subscriptions.v2.json` → `subscriptions.v1.json` / `subscriptions.json` (legacy, **converted** on load) → empty. @@ -34,12 +39,6 @@ vocabulary in `CONTEXT.md`. back to old code loses post-migration writes. If both exist, v2 wins (document it). -- [ ] **Injectable core for the service unit tests.** `stats` / `feeds-opml` / - `remove-expired` reach the production `core`/`store` singleton (tests isolate - via a temp `DATA_FILE_PATH`). If finer isolation is wanted, make them factories - that take an injected core — built with `createInMemoryStore` in tests. Likely - folds into the unify work (touches the same tests). - ## WebSub hub support (bigger — spans core + express) Make the server act as a [WebSub](https://www.w3.org/TR/websub/) **hub** (the W3C From 1fb33d3fdc0c2350e7dfb0d58b6f547507e640a1 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Fri, 12 Jun 2026 15:05:47 -0500 Subject: [PATCH 51/90] feat(core): persist the domain model as a versioned v2 file format Replace the legacy disk shape with a self-describing v2 envelope ({ version: 2, feeds: { [url]: { resource, subscriptions } } }) that stores the core model directly: ISO dates, null for "never" (no more epoch sentinel), nested feed metadata, and whenCreated now round-trips. file-store holds the core model in memory, so the per-call legacy mapping in get/list is gone. New store-codec module (resourceToJson/resourceFromJson, subscriptionToJson/subscriptionFromJson) is the single source of truth for (de)serialization, exported for hosts that expose the model over the wire. Migration is self-completing and forward-only: - Load precedence: subscriptions.v2.json -> subscriptions.v1.json / subscriptions.json (legacy, converted on load) -> empty. - All writes target subscriptions.v2.json; the legacy file is read once and left intact as a pre-migration backup. If both exist, v2 wins. - The former read-side legacy mapping survives as the one-way v1 importer; the write-side legacy mapping is deleted. - New optional onMigrate(info) hook fires once on legacy import for host logging. Caveat: once v2 is written the legacy file goes stale, so rolling back to old code loses post-migration writes. packages/core stays at 100% coverage (207 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/index.ts | 8 + packages/core/src/store/file-store.test.ts | 508 ++++++++++++-------- packages/core/src/store/file-store.ts | 279 +++++++---- packages/core/src/store/store-codec.test.ts | 139 ++++++ packages/core/src/store/store-codec.ts | 119 +++++ 5 files changed, 752 insertions(+), 301 deletions(-) create mode 100644 packages/core/src/store/store-codec.test.ts create mode 100644 packages/core/src/store/store-codec.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c7d43e4..5962985 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,6 +37,14 @@ export { type FileStore, type FileStoreOptions } from './store/file-store.js'; +export { + resourceToJson, + resourceFromJson, + subscriptionToJson, + subscriptionFromJson, + type JsonResource, + type JsonSubscription +} from './store/store-codec.js'; // Contracts export type { BuiltInProtocol, Protocol } from './engine/protocol.js'; diff --git a/packages/core/src/store/file-store.test.ts b/packages/core/src/store/file-store.test.ts index 685990b..aa11d67 100644 --- a/packages/core/src/store/file-store.test.ts +++ b/packages/core/src/store/file-store.test.ts @@ -9,6 +9,8 @@ import type { Subscription } from '../engine/subscription.js'; let dir: string; let filePath: string; +let v2Path: string; +let v1Path: string; let stores: FileStore[]; beforeEach(async () => { @@ -16,6 +18,8 @@ beforeEach(async () => { stores = []; dir = await mkdtemp(join(tmpdir(), 'rsscloud-file-store-')); filePath = join(dir, 'subscriptions.json'); + v2Path = join(dir, 'subscriptions.v2.json'); + v1Path = join(dir, 'subscriptions.v1.json'); }); afterEach(async () => { @@ -36,13 +40,14 @@ async function makeStore( const LEGACY_FEED = 'http://scripting.com/rss.xml'; const NEW_FEED = 'https://feed.example/rss'; +const SUBS_ONLY_FEED = 'https://subsonly.example/rss'; -async function readDisk(): Promise { - return JSON.parse(await readFile(filePath, 'utf8')); +async function readV2(): Promise { + return JSON.parse(await readFile(v2Path, 'utf8')); } -function fileExists(): Promise { - return readFile(filePath, 'utf8').then( +function v2Exists(): Promise { + return readFile(v2Path, 'utf8').then( () => true, () => false ); @@ -60,6 +65,19 @@ function coreResource(): Resource { }; } +function coreResourceWithFeed(): Resource { + return { + ...coreResource(), + feed: { + type: 'rss', + title: 'New', + description: 'D', + htmlUrl: 'http://x/', + language: 'en' + } + }; +} + function coreSubscription(): Subscription { return { url: 'http://sub.example/notify', @@ -74,6 +92,8 @@ function coreSubscription(): Subscription { }; } +// ---- the legacy (pre-v2) on-disk shape used only on the import path ---- + const LEGACY_FILE = { [LEGACY_FEED]: { resource: { @@ -105,266 +125,267 @@ const LEGACY_FILE = { } }; -async function writeLegacy(): Promise { - await writeFile(filePath, JSON.stringify(LEGACY_FILE, null, 2)); +async function writeLegacyAt(path: string): Promise { + await writeFile(path, JSON.stringify(LEGACY_FILE, null, 2)); } -describe('createFileStore', () => { - it('loads a legacy file and exposes the resource in core shape', async () => { - await writeLegacy(); - +describe('createFileStore — v2 persistence', () => { + it('writes the v2 envelope to subscriptions.v2.json', async () => { const store = await makeStore(); - expect(await store.getResource(LEGACY_FEED)).toEqual({ - url: LEGACY_FEED, - lastHash: '0dbe7cdebe7669b47423a4f53cc67f68', - lastSize: 84682, - ctChecks: 41426, - whenLastCheck: new Date('2026-06-10T12:55:28.000Z'), - ctUpdates: 35737, - whenLastUpdate: new Date('2026-06-10T12:45:25.000Z'), - feed: { - type: 'rss', - title: 'Scripting News', - description: 'Dave Winer, OG blogger.', - htmlUrl: 'http://scripting.com/', - language: 'en-us' + await store.putResource(NEW_FEED, coreResourceWithFeed()); + await store.putSubscriptions(NEW_FEED, [coreSubscription()]); + await store.flush(); + + expect(await readV2()).toEqual({ + version: 2, + feeds: { + [NEW_FEED]: { + resource: { + url: NEW_FEED, + lastHash: 'abc', + lastSize: 100, + ctChecks: 5, + whenLastCheck: '2026-01-02T03:04:05.000Z', + ctUpdates: 2, + whenLastUpdate: '2026-01-02T03:04:05.000Z', + feed: { + type: 'rss', + title: 'New', + description: 'D', + htmlUrl: 'http://x/', + language: 'en' + } + }, + subscriptions: [ + { + url: 'http://sub.example/notify', + protocol: 'http-post', + ctUpdates: 0, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: '2026-01-01T00:00:00.000Z', + whenLastUpdate: null, + whenLastError: null, + whenExpires: '2099-01-01T00:00:00.000Z' + } + ] + } } }); }); - it('maps legacy subscribers to core subscriptions', async () => { - await writeLegacy(); - + it('persists a subscriptions-only feed with a null resource', async () => { const store = await makeStore(); - expect(await store.getSubscriptions(LEGACY_FEED)).toEqual([ + await store.putSubscriptions(SUBS_ONLY_FEED, [coreSubscription()]); + await store.flush(); + + const onDisk = (await readV2()) as { + feeds: Record; + }; + expect(onDisk.feeds[SUBS_ONLY_FEED]?.resource).toBeNull(); + expect(await store.getResource(SUBS_ONLY_FEED)).toBeNull(); + }); + + it('round-trips the core model through put, close, and reload', async () => { + const a = await makeStore(); + await a.putResource(NEW_FEED, coreResourceWithFeed()); + await a.putSubscriptions(NEW_FEED, [coreSubscription()]); + await a.close(); + + const b = await makeStore(); + + // whenCreated is now persisted, so the subscription returns verbatim. + expect(await b.getResource(NEW_FEED)).toEqual(coreResourceWithFeed()); + expect(await b.getSubscriptions(NEW_FEED)).toEqual([coreSubscription()]); + expect(await b.list()).toEqual([ { - url: 'http://157.230.11.43:1414/feedping', - protocol: 'http-post', - ctUpdates: 89560, - ctErrors: 122, - ctConsecutiveErrors: 0, - whenCreated: new Date('2026-06-11T13:55:28+00:00'), - whenLastUpdate: new Date('2026-06-10T12:55:28.000Z'), - whenLastError: new Date('2024-12-12T23:37:21.000Z'), - whenExpires: new Date('2026-06-11T13:55:28+00:00') + feedUrl: NEW_FEED, + resource: coreResourceWithFeed(), + subscriptions: [coreSubscription()] } ]); }); - it('lists every tracked feed with its mapped resource and subscriptions', async () => { - await writeLegacy(); + it('loads a hand-written v2 file natively', async () => { + await writeFile( + v2Path, + JSON.stringify({ + version: 2, + feeds: { + [NEW_FEED]: { + resource: { + url: NEW_FEED, + lastHash: 'abc', + lastSize: 100, + ctChecks: 5, + whenLastCheck: '2026-01-02T03:04:05.000Z', + ctUpdates: 2, + whenLastUpdate: '2026-01-02T03:04:05.000Z', + feed: { type: 'rss', title: 'New' } + }, + subscriptions: [ + { + url: 'http://sub.example/notify', + protocol: 'http-post', + ctUpdates: 0, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: '2026-01-01T00:00:00.000Z', + whenLastUpdate: null, + whenLastError: null, + whenExpires: '2099-01-01T00:00:00.000Z' + } + ] + }, + [SUBS_ONLY_FEED]: { resource: null, subscriptions: [] } + } + }) + ); const store = await makeStore(); - expect(await store.list()).toEqual([ - { - feedUrl: LEGACY_FEED, - resource: await store.getResource(LEGACY_FEED), - subscriptions: await store.getSubscriptions(LEGACY_FEED) - } + expect(await store.getResource(NEW_FEED)).toEqual({ + ...coreResource(), + feed: { type: 'rss', title: 'New' } + }); + expect(await store.getSubscriptions(NEW_FEED)).toEqual([ + coreSubscription() ]); + expect(await store.getResource(SUBS_ONLY_FEED)).toBeNull(); }); it('removes a feed entirely', async () => { - await writeLegacy(); - const store = await makeStore(); - await store.remove(LEGACY_FEED); + await store.putResource(NEW_FEED, coreResource()); + await store.remove(NEW_FEED); - expect(await store.getResource(LEGACY_FEED)).toBeNull(); - expect(await store.getSubscriptions(LEGACY_FEED)).toEqual([]); + expect(await store.getResource(NEW_FEED)).toBeNull(); + expect(await store.getSubscriptions(NEW_FEED)).toEqual([]); expect(await store.list()).toEqual([]); }); - it('flushes putResource as a faithful flat resource shape', async () => { + it('starts empty when no file exists', async () => { const store = await makeStore(); + expect(await store.list()).toEqual([]); + }); - await store.putResource(NEW_FEED, { - url: NEW_FEED, - lastHash: 'abc', - lastSize: 100, - ctChecks: 5, - whenLastCheck: new Date('2026-01-02T03:04:05.000Z'), - ctUpdates: 2, - whenLastUpdate: new Date('2026-01-02T03:04:05.000Z'), - feed: { - type: 'rss', - title: 'New', - description: 'D', - htmlUrl: 'http://x/', - language: 'en' - } - }); - await store.flush(); + it('starts empty when the v2 file is corrupt and there is no legacy file', async () => { + await writeFile(v2Path, 'not json at all'); - expect(await readDisk()).toEqual({ - [NEW_FEED]: { - resource: { - lastSize: 100, - lastHash: 'abc', - ctChecks: 5, - whenLastCheck: '2026-01-02T03:04:05.000Z', - ctUpdates: 2, - whenLastUpdate: '2026-01-02T03:04:05.000Z', - feedType: 'rss', - feedTitle: 'New', - feedDescription: 'D', - feedHtmlUrl: 'http://x/', - feedLanguage: 'en' - }, - subscribers: [] - } - }); + const store = await makeStore(); + + expect(await store.list()).toEqual([]); }); +}); + +describe('createFileStore — legacy (v1) import', () => { + it('imports the legacy bare-name file into core shape when no v2 exists', async () => { + await writeLegacyAt(filePath); - it('flushes putSubscriptions as faithful subscriber records', async () => { const store = await makeStore(); - await store.putSubscriptions(NEW_FEED, [ + expect(await store.getResource(LEGACY_FEED)).toEqual({ + url: LEGACY_FEED, + lastHash: '0dbe7cdebe7669b47423a4f53cc67f68', + lastSize: 84682, + ctChecks: 41426, + whenLastCheck: new Date('2026-06-10T12:55:28.000Z'), + ctUpdates: 35737, + whenLastUpdate: new Date('2026-06-10T12:45:25.000Z'), + feed: { + type: 'rss', + title: 'Scripting News', + description: 'Dave Winer, OG blogger.', + htmlUrl: 'http://scripting.com/', + language: 'en-us' + } + }); + expect(await store.getSubscriptions(LEGACY_FEED)).toEqual([ { - url: 'http://sub.example/notify', + url: 'http://157.230.11.43:1414/feedping', protocol: 'http-post', - ctUpdates: 0, - ctErrors: 0, - ctConsecutiveErrors: 0, - whenCreated: new Date('2026-01-01T00:00:00.000Z'), - whenLastUpdate: null, - whenLastError: null, - whenExpires: new Date('2099-01-01T00:00:00.000Z') - }, - { - url: 'http://sub.example/rpc', - protocol: 'xml-rpc', - notifyProcedure: 'river.feedUpdated', - ctUpdates: 3, - ctErrors: 1, + ctUpdates: 89560, + ctErrors: 122, ctConsecutiveErrors: 0, - whenCreated: new Date('2026-01-01T00:00:00.000Z'), - whenLastUpdate: new Date('2026-02-01T00:00:00.000Z'), - whenLastError: new Date('2025-12-01T00:00:00.000Z'), - whenExpires: new Date('2099-01-01T00:00:00.000Z'), - details: { secret: 's3cr3t' } + whenCreated: new Date('2026-06-11T13:55:28+00:00'), + whenLastUpdate: new Date('2026-06-10T12:55:28.000Z'), + whenLastError: new Date('2024-12-12T23:37:21.000Z'), + whenExpires: new Date('2026-06-11T13:55:28+00:00') } ]); - await store.flush(); - - expect(await readDisk()).toEqual({ - [NEW_FEED]: { - resource: {}, - subscribers: [ - { - ctUpdates: 0, - whenLastUpdate: '1970-01-01T00:00:00.000Z', - ctErrors: 0, - ctConsecutiveErrors: 0, - whenLastError: '1970-01-01T00:00:00.000Z', - whenExpires: '2099-01-01T00:00:00.000Z', - url: 'http://sub.example/notify', - notifyProcedure: false, - protocol: 'http-post' - }, - { - ctUpdates: 3, - whenLastUpdate: '2026-02-01T00:00:00.000Z', - ctErrors: 1, - ctConsecutiveErrors: 0, - whenLastError: '2025-12-01T00:00:00.000Z', - whenExpires: '2099-01-01T00:00:00.000Z', - url: 'http://sub.example/rpc', - notifyProcedure: 'river.feedUpdated', - protocol: 'xml-rpc', - details: { secret: 's3cr3t' } - } - ] - } - }); - - // An entry created via subscriptions only has no real resource. - expect(await store.getResource(NEW_FEED)).toBeNull(); }); - it('round-trips subscriptions through put and get', async () => { - const store = await makeStore(); + it('imports the legacy .v1.json file when present', async () => { + await writeLegacyAt(v1Path); - await store.putSubscriptions(NEW_FEED, [coreSubscription()]); + const store = await makeStore(); - // whenCreated is not persisted; it is re-derived from whenExpires. - expect(await store.getSubscriptions(NEW_FEED)).toEqual([ - { - ...coreSubscription(), - whenCreated: new Date('2099-01-01T00:00:00.000Z') - } - ]); + expect((await store.list())[0]?.feedUrl).toBe(LEGACY_FEED); }); - it('coalesces a burst of puts into a single scheduled flush', async () => { - const store = await makeStore({ debounceMs: 1000 }); - - for (let i = 0; i < 5; i += 1) { - await store.putResource(`${NEW_FEED}/${i}`, coreResource()); - } - // Each put re-arms one timer rather than queuing five. - expect(vi.getTimerCount()).toBe(1); + it('migrates legacy data to v2 on first write, leaving the legacy file intact', async () => { + await writeLegacyAt(filePath); + const legacyBefore = await readFile(filePath, 'utf8'); - await vi.advanceTimersByTimeAsync(1000); + const store = await makeStore(); + await store.putResource(NEW_FEED, coreResource()); await store.flush(); - // The single flush captured every feed from the burst. - expect(Object.keys((await readDisk()) as object)).toHaveLength(5); + // The legacy file is untouched; the new v2 file holds both feeds. + expect(await readFile(filePath, 'utf8')).toBe(legacyBefore); + const onDisk = (await readV2()) as { feeds: Record }; + expect(Object.keys(onDisk.feeds).sort()).toEqual( + [LEGACY_FEED, NEW_FEED].sort() + ); }); - it('flushes by maxWaitMs even when churn keeps re-arming the debounce', async () => { - const store = await makeStore({ debounceMs: 1000, maxWaitMs: 3000 }); - - await store.putResource(NEW_FEED, coreResource()); + it('lets v2 win when both a v2 and a legacy file exist', async () => { + await writeLegacyAt(filePath); + await writeFile( + v2Path, + JSON.stringify({ + version: 2, + feeds: { + [NEW_FEED]: { resource: null, subscriptions: [] } + } + }) + ); - // Churn every 900ms so the 1000ms debounce never settles on its own. - for (let t = 900; t <= 2700; t += 900) { - await vi.advanceTimersByTimeAsync(900); - expect(await fileExists()).toBe(false); - await store.putResource(`${NEW_FEED}/${t}`, coreResource()); - } + const store = await makeStore(); - // At t=3000 the maxWait ceiling forces a flush a debounce-only - // scheduler would have pushed out to t=3700. - await vi.advanceTimersByTimeAsync(300); - await store.flush(); - expect(await fileExists()).toBe(true); + expect((await store.list()).map(e => e.feedUrl)).toEqual([NEW_FEED]); + expect(await store.getResource(LEGACY_FEED)).toBeNull(); }); - it('does not write until the debounce interval elapses', async () => { - const store = await makeStore({ debounceMs: 1000 }); - - await store.putResource(NEW_FEED, coreResource()); + it('calls onMigrate once after importing a legacy file', async () => { + await writeLegacyAt(filePath); + const onMigrate = vi.fn(); - await vi.advanceTimersByTimeAsync(999); - expect(await fileExists()).toBe(false); + await makeStore({ onMigrate }); - await vi.advanceTimersByTimeAsync(1); - // The debounce timer has fired; join its write to settle it. - await store.flush(); - expect(await fileExists()).toBe(true); - expect(Object.keys((await readDisk()) as object)).toEqual([NEW_FEED]); + expect(onMigrate).toHaveBeenCalledTimes(1); + expect(onMigrate).toHaveBeenCalledWith({ + from: filePath, + to: v2Path, + feedCount: 1 + }); }); - it('keeps resource and subscriptions together for one feed', async () => { - const store = await makeStore(); + it('does not call onMigrate when loading a v2 file', async () => { + await writeFile( + v2Path, + JSON.stringify({ version: 2, feeds: {} }) + ); + const onMigrate = vi.fn(); - await store.putResource(NEW_FEED, coreResource()); - await store.putSubscriptions(NEW_FEED, [coreSubscription()]); - await store.flush(); + await makeStore({ onMigrate }); - const onDisk = (await readDisk()) as Record< - string, - { resource: unknown; subscribers: unknown[] } - >; - expect(onDisk[NEW_FEED]?.resource).not.toEqual({}); - expect(onDisk[NEW_FEED]?.subscribers).toHaveLength(1); + expect(onMigrate).not.toHaveBeenCalled(); }); - it('reads sparse, hand-written entries with core defaults', async () => { + it('reads sparse, hand-written legacy entries with core defaults', async () => { const SPARSE_FEED = 'https://sparse.example/feed'; const NOSUB_FEED = 'https://nosub.example/feed'; await writeFile( @@ -421,14 +442,87 @@ describe('createFileStore', () => { expect(await store.getSubscriptions(NOSUB_FEED)).toEqual([]); const nosub = (await store.list()).find(e => e.feedUrl === NOSUB_FEED); expect(nosub?.subscriptions).toEqual([]); + expect(nosub?.resource).not.toBeNull(); }); - it('starts empty when the file is corrupt', async () => { - await writeFile(filePath, 'not json at all'); + it('treats a legacy entry with an empty resource as subscriptions-only', async () => { + await writeFile( + filePath, + JSON.stringify({ + [SUBS_ONLY_FEED]: { + resource: {}, + subscribers: [] + } + }) + ); const store = await makeStore(); - expect(await store.list()).toEqual([]); + expect(await store.getResource(SUBS_ONLY_FEED)).toBeNull(); + }); +}); + +describe('createFileStore — flush scheduling', () => { + it('round-trips subscriptions through put and get in-memory', async () => { + const store = await makeStore(); + + await store.putSubscriptions(NEW_FEED, [coreSubscription()]); + + expect(await store.getSubscriptions(NEW_FEED)).toEqual([ + coreSubscription() + ]); + }); + + it('coalesces a burst of puts into a single scheduled flush', async () => { + const store = await makeStore({ debounceMs: 1000 }); + + for (let i = 0; i < 5; i += 1) { + await store.putResource(`${NEW_FEED}/${i}`, coreResource()); + } + // Each put re-arms one timer rather than queuing five. + expect(vi.getTimerCount()).toBe(1); + + await vi.advanceTimersByTimeAsync(1000); + await store.flush(); + + // The single flush captured every feed from the burst. + const onDisk = (await readV2()) as { feeds: object }; + expect(Object.keys(onDisk.feeds)).toHaveLength(5); + }); + + it('flushes by maxWaitMs even when churn keeps re-arming the debounce', async () => { + const store = await makeStore({ debounceMs: 1000, maxWaitMs: 3000 }); + + await store.putResource(NEW_FEED, coreResource()); + + // Churn every 900ms so the 1000ms debounce never settles on its own. + for (let t = 900; t <= 2700; t += 900) { + await vi.advanceTimersByTimeAsync(900); + expect(await v2Exists()).toBe(false); + await store.putResource(`${NEW_FEED}/${t}`, coreResource()); + } + + // At t=3000 the maxWait ceiling forces a flush a debounce-only + // scheduler would have pushed out to t=3700. + await vi.advanceTimersByTimeAsync(300); + await store.flush(); + expect(await v2Exists()).toBe(true); + }); + + it('does not write until the debounce interval elapses', async () => { + const store = await makeStore({ debounceMs: 1000 }); + + await store.putResource(NEW_FEED, coreResource()); + + await vi.advanceTimersByTimeAsync(999); + expect(await v2Exists()).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + // The debounce timer has fired; join its write to settle it. + await store.flush(); + expect(await v2Exists()).toBe(true); + const onDisk = (await readV2()) as { feeds: object }; + expect(Object.keys(onDisk.feeds)).toEqual([NEW_FEED]); }); it('keeps data in memory when a write fails, without throwing', async () => { @@ -450,7 +544,7 @@ describe('createFileStore', () => { await store.flush(); - expect(await fileExists()).toBe(false); + expect(await v2Exists()).toBe(false); }); it('close performs a final flush and stops the timer', async () => { @@ -461,7 +555,7 @@ describe('createFileStore', () => { await store.close(); - expect(await fileExists()).toBe(true); + expect(await v2Exists()).toBe(true); expect(vi.getTimerCount()).toBe(0); }); }); diff --git a/packages/core/src/store/file-store.ts b/packages/core/src/store/file-store.ts index 50ae194..0863e98 100644 --- a/packages/core/src/store/file-store.ts +++ b/packages/core/src/store/file-store.ts @@ -5,15 +5,39 @@ import type { Protocol } from '../engine/protocol.js'; import type { Resource } from '../engine/resource.js'; import type { Subscription } from '../engine/subscription.js'; import type { FeedEntry, Store } from './store.js'; +import { + resourceFromJson, + resourceToJson, + subscriptionFromJson, + subscriptionToJson, + type JsonResource, + type JsonSubscription +} from './store-codec.js'; + +/** Reported once when a pre-v2 (legacy) file is imported, for host logging. */ +export interface MigrationInfo { + /** The legacy file the data was imported from. */ + from: string; + /** The v2 file all future writes target. */ + to: string; + /** Number of feeds imported. */ + feedCount: number; +} /** Options for {@link createFileStore}. */ export interface FileStoreOptions { - /** Path to the JSON file the store loads from and flushes to. */ + /** + * Path to the legacy/bare data file. The v2 file written and preferred on + * load is the sibling `.v2.json`; a `.v1.json` legacy file is also + * honoured on import. Defaults derive from this single path. + */ filePath: string; /** Quiet-gap delay before a coalesced flush. Defaults to 1000ms. */ debounceMs?: number; /** Hard ceiling between flushes under sustained churn. Defaults to 60000ms. */ maxWaitMs?: number; + /** Invoked once after a legacy file is imported (no-op if absent). */ + onMigrate?: (info: MigrationInfo) => void; } /** A file-backed {@link Store} with durable-flush controls. */ @@ -24,7 +48,30 @@ export interface FileStore extends Store { close(): Promise; } -/** One feed's on-disk record: flat resource fields plus its subscribers. */ +/** One feed in memory: the core model directly (no per-call mapping). */ +interface Entry { + resource: Resource | null; + subscriptions: Subscription[]; +} + +// ---- v2 on-disk envelope ---- + +interface V2Entry { + resource: JsonResource | null; + subscriptions: JsonSubscription[]; +} + +interface V2Doc { + version: 2; + feeds: Record; +} + +function isV2(doc: unknown): doc is V2Doc { + return (doc as { version?: unknown } | null | undefined)?.version === 2; +} + +// ---- legacy (pre-v2) on-disk shape + one-way importer ---- + interface DiskResource { lastSize?: number; lastHash?: string; @@ -60,42 +107,17 @@ interface DiskEntry { type DiskData = Record; -const EPOCH_ISO = new Date(0).toISOString(); - -/** Epoch (`new Date(0)`) marks "never happened" on disk. */ +/** Epoch (`new Date(0)`) marks "never happened" in the legacy file. */ function readWhen(value: string | undefined): Date { return new Date(value ?? 0); } -/** Epoch on disk maps to `null` ("never") in the core model. */ +/** Epoch in the legacy file maps to `null` ("never") in the core model. */ function readNullableWhen(value: string | undefined): Date | null { const date = new Date(value ?? 0); return date.getTime() === 0 ? null : date; } -function readSubscription(raw: DiskSubscriber): Subscription { - const whenExpires = readWhen(raw.whenExpires); - const subscription: Subscription = { - url: raw.url, - protocol: raw.protocol, - ctUpdates: raw.ctUpdates ?? 0, - ctErrors: raw.ctErrors ?? 0, - ctConsecutiveErrors: raw.ctConsecutiveErrors ?? 0, - // Legacy records carry no creation time; synthesize from expiry. - whenCreated: raw.whenCreated != null ? readWhen(raw.whenCreated) : whenExpires, - whenLastUpdate: readNullableWhen(raw.whenLastUpdate), - whenLastError: readNullableWhen(raw.whenLastError), - whenExpires - }; - if (typeof raw.notifyProcedure === 'string') { - subscription.notifyProcedure = raw.notifyProcedure; - } - if (raw.details !== undefined) { - subscription.details = raw.details; - } - return subscription; -} - function readFeed(raw: DiskResource): FeedMetadata | undefined { const feed: FeedMetadata = {}; if (raw.feedType != null) feed.type = raw.feedType; @@ -126,91 +148,164 @@ function readResource( return resource; } -function writeResource(resource: Resource): DiskResource { - const out: DiskResource = { - lastSize: resource.lastSize, - lastHash: resource.lastHash, - ctChecks: resource.ctChecks, - whenLastCheck: resource.whenLastCheck.toISOString(), - ctUpdates: resource.ctUpdates, - whenLastUpdate: resource.whenLastUpdate.toISOString() +function readSubscription(raw: DiskSubscriber): Subscription { + const whenExpires = readWhen(raw.whenExpires); + const subscription: Subscription = { + url: raw.url, + protocol: raw.protocol, + ctUpdates: raw.ctUpdates ?? 0, + ctErrors: raw.ctErrors ?? 0, + ctConsecutiveErrors: raw.ctConsecutiveErrors ?? 0, + // Legacy records carry no creation time; synthesize from expiry. + whenCreated: raw.whenCreated != null ? readWhen(raw.whenCreated) : whenExpires, + whenLastUpdate: readNullableWhen(raw.whenLastUpdate), + whenLastError: readNullableWhen(raw.whenLastError), + whenExpires }; - const feed = resource.feed; - if (feed !== undefined) { - if (feed.type != null) out.feedType = feed.type; - if (feed.title != null) out.feedTitle = feed.title; - if (feed.description != null) out.feedDescription = feed.description; - if (feed.htmlUrl != null) out.feedHtmlUrl = feed.htmlUrl; - if (feed.language != null) out.feedLanguage = feed.language; + if (typeof raw.notifyProcedure === 'string') { + subscription.notifyProcedure = raw.notifyProcedure; } - return out; + if (raw.details !== undefined) { + subscription.details = raw.details; + } + return subscription; } -/** `null` ("never") serializes back to the epoch string the legacy reader uses. */ -function writeWhen(value: Date | null): string { - return value === null ? EPOCH_ISO : value.toISOString(); +function importLegacy(data: DiskData): Map { + const feeds = new Map(); + for (const [feedUrl, entry] of Object.entries(data)) { + feeds.set(feedUrl, { + resource: readResource(feedUrl, entry.resource), + subscriptions: (entry.subscribers ?? []).map(readSubscription) + }); + } + return feeds; } -function writeSubscription(subscription: Subscription): DiskSubscriber { - const out: DiskSubscriber = { - ctUpdates: subscription.ctUpdates, - whenLastUpdate: writeWhen(subscription.whenLastUpdate), - ctErrors: subscription.ctErrors, - ctConsecutiveErrors: subscription.ctConsecutiveErrors, - whenLastError: writeWhen(subscription.whenLastError), - whenExpires: subscription.whenExpires.toISOString(), - url: subscription.url, - // REST subs carry no procedure; the legacy shape records that as `false`. - notifyProcedure: subscription.notifyProcedure ?? false, - protocol: subscription.protocol - }; - if (subscription.details !== undefined) { - out.details = subscription.details; +function loadV2(doc: V2Doc): Map { + const feeds = new Map(); + for (const [feedUrl, entry] of Object.entries(doc.feeds)) { + feeds.set(feedUrl, { + resource: + entry.resource === null + ? null + : resourceFromJson(entry.resource), + subscriptions: entry.subscriptions.map(subscriptionFromJson) + }); } - return out; + return feeds; } -async function loadDisk(filePath: string): Promise { +// ---- path derivation + raw reads ---- + +function derivePaths(filePath: string): { + v2Path: string; + v1Path: string; + legacyPath: string; +} { + const base = filePath.replace(/\.json$/, ''); + return { + v2Path: `${base}.v2.json`, + v1Path: `${base}.v1.json`, + legacyPath: filePath + }; +} + +async function readJson(path: string): Promise { try { - return JSON.parse(await readFile(filePath, 'utf8')) as DiskData; + return JSON.parse(await readFile(path, 'utf8')) as unknown; } catch { - return {}; + return undefined; + } +} + +interface LoadResult { + feeds: Map; + migratedFrom: string | null; +} + +/** + * Load with v2 precedence: the `.v2.json` file if present, else a converted + * legacy file (`.v1.json` then the bare name), else empty. The legacy file is + * read-only on this path — writes always target v2. + */ +async function load(paths: ReturnType): Promise { + const v2 = await readJson(paths.v2Path); + if (isV2(v2)) { + return { feeds: loadV2(v2), migratedFrom: null }; + } + + const fromV1 = await readJson(paths.v1Path); + if (fromV1 !== undefined) { + return { feeds: importLegacy(fromV1 as DiskData), migratedFrom: paths.v1Path }; } + + const fromLegacy = await readJson(paths.legacyPath); + if (fromLegacy !== undefined) { + return { + feeds: importLegacy(fromLegacy as DiskData), + migratedFrom: paths.legacyPath + }; + } + + return { feeds: new Map(), migratedFrom: null }; } /** - * A file-backed {@link Store}. Loads on init, maps the legacy on-disk shape - * (keyed by feed URL, flat feed fields, string dates) to and from core's model. + * A file-backed {@link Store}. Holds the core model in memory and persists it + * as the versioned v2 envelope; a pre-v2 file is imported (one-way) on first + * boot, then left untouched as a backup while writes move to `.v2.json`. */ export async function createFileStore( options: FileStoreOptions ): Promise { - const { filePath } = options; const debounceMs = options.debounceMs ?? 1000; const maxWaitMs = options.maxWaitMs ?? 60000; - const disk = await loadDisk(filePath); + const paths = derivePaths(options.filePath); + + const { feeds, migratedFrom } = await load(paths); + if (migratedFrom !== null) { + options.onMigrate?.({ + from: migratedFrom, + to: paths.v2Path, + feedCount: feeds.size + }); + } let dirty = false; let firstDirtyAt: number | null = null; let flushTimer: ReturnType | null = null; let inFlight: Promise | null = null; - function entryFor(feedUrl: string): DiskEntry { - const existing = disk[feedUrl]; + function entryFor(feedUrl: string): Entry { + const existing = feeds.get(feedUrl); if (existing !== undefined) return existing; - // Mirror the legacy shape: every entry has a resource and subscribers. - const created: DiskEntry = { resource: {}, subscribers: [] }; - disk[feedUrl] = created; + const created: Entry = { resource: null, subscriptions: [] }; + feeds.set(feedUrl, created); return created; } + function snapshot(): string { + const doc: V2Doc = { version: 2, feeds: {} }; + for (const [feedUrl, entry] of feeds) { + doc.feeds[feedUrl] = { + resource: + entry.resource === null + ? null + : resourceToJson(entry.resource), + subscriptions: entry.subscriptions.map(subscriptionToJson) + }; + } + return JSON.stringify(doc, null, 2); + } + async function writeToDisk(): Promise { - await mkdir(dirname(filePath), { recursive: true }); + await mkdir(dirname(paths.v2Path), { recursive: true }); // Snapshot synchronously so an in-flight write can't tear. - const snapshot = JSON.stringify(disk, null, 2); - const tmp = `${filePath}.tmp`; - await writeFile(tmp, snapshot); - await rename(tmp, filePath); + const data = snapshot(); + const tmp = `${paths.v2Path}.tmp`; + await writeFile(tmp, data); + await rename(tmp, paths.v2Path); } function clearFlushTimer(): void { @@ -262,40 +357,36 @@ export async function createFileStore( return { async getResource(feedUrl: string): Promise { - return readResource(feedUrl, disk[feedUrl]?.resource); + return feeds.get(feedUrl)?.resource ?? null; }, - async putResource( - feedUrl: string, - resource: Resource - ): Promise { - entryFor(feedUrl).resource = writeResource(resource); + async putResource(feedUrl: string, resource: Resource): Promise { + entryFor(feedUrl).resource = resource; markDirty(); }, async getSubscriptions(feedUrl: string): Promise { - const subscribers = disk[feedUrl]?.subscribers ?? []; - return subscribers.map(readSubscription); + return feeds.get(feedUrl)?.subscriptions ?? []; }, async putSubscriptions( feedUrl: string, subscriptions: Subscription[] ): Promise { - entryFor(feedUrl).subscribers = subscriptions.map(writeSubscription); + entryFor(feedUrl).subscriptions = subscriptions; markDirty(); }, async list(): Promise { - return Object.entries(disk).map(([feedUrl, entry]) => ({ + return Array.from(feeds, ([feedUrl, entry]) => ({ feedUrl, - resource: readResource(feedUrl, entry.resource), - subscriptions: (entry.subscribers ?? []).map(readSubscription) + resource: entry.resource, + subscriptions: entry.subscriptions })); }, async remove(feedUrl: string): Promise { - delete disk[feedUrl]; + feeds.delete(feedUrl); markDirty(); }, diff --git a/packages/core/src/store/store-codec.test.ts b/packages/core/src/store/store-codec.test.ts new file mode 100644 index 0000000..33dbbd0 --- /dev/null +++ b/packages/core/src/store/store-codec.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import { + resourceFromJson, + resourceToJson, + subscriptionFromJson, + subscriptionToJson +} from './store-codec.js'; +import type { Resource } from '../engine/resource.js'; +import type { Subscription } from '../engine/subscription.js'; + +describe('resource codec', () => { + it('serializes a resource with feed metadata to ISO dates and nested feed', () => { + const resource: Resource = { + url: 'https://feed.example/rss', + lastHash: 'abc', + lastSize: 100, + ctChecks: 5, + whenLastCheck: new Date('2026-01-02T03:04:05.000Z'), + ctUpdates: 2, + whenLastUpdate: new Date('2026-01-03T04:05:06.000Z'), + feed: { type: 'rss', title: 'New', language: 'en' } + }; + + expect(resourceToJson(resource)).toEqual({ + url: 'https://feed.example/rss', + lastHash: 'abc', + lastSize: 100, + ctChecks: 5, + whenLastCheck: '2026-01-02T03:04:05.000Z', + ctUpdates: 2, + whenLastUpdate: '2026-01-03T04:05:06.000Z', + feed: { type: 'rss', title: 'New', language: 'en' } + }); + }); + + it('omits feed when the resource has none', () => { + const resource: Resource = { + url: 'https://feed.example/rss', + lastHash: '', + lastSize: 0, + ctChecks: 0, + whenLastCheck: new Date(0), + ctUpdates: 0, + whenLastUpdate: new Date(0) + }; + + const json = resourceToJson(resource); + expect('feed' in json).toBe(false); + expect(resourceFromJson(json)).toEqual(resource); + }); + + it('round-trips a resource through to/from JSON', () => { + const resource: Resource = { + url: 'https://feed.example/rss', + lastHash: 'abc', + lastSize: 100, + ctChecks: 5, + whenLastCheck: new Date('2026-01-02T03:04:05.000Z'), + ctUpdates: 2, + whenLastUpdate: new Date('2026-01-03T04:05:06.000Z'), + feed: { type: 'atom', title: 'A' } + }; + + expect(resourceFromJson(resourceToJson(resource))).toEqual(resource); + }); +}); + +describe('subscription codec', () => { + it('serializes a full subscription, preserving null "never" dates', () => { + const subscription: Subscription = { + url: 'http://sub.example/rpc', + protocol: 'xml-rpc', + notifyProcedure: 'river.feedUpdated', + ctUpdates: 3, + ctErrors: 1, + ctConsecutiveErrors: 0, + whenCreated: new Date('2026-01-01T00:00:00.000Z'), + whenLastUpdate: new Date('2026-02-01T00:00:00.000Z'), + whenLastError: new Date('2025-12-01T00:00:00.000Z'), + whenExpires: new Date('2099-01-01T00:00:00.000Z'), + details: { secret: 's3cr3t' } + }; + + expect(subscriptionToJson(subscription)).toEqual({ + url: 'http://sub.example/rpc', + protocol: 'xml-rpc', + notifyProcedure: 'river.feedUpdated', + ctUpdates: 3, + ctErrors: 1, + ctConsecutiveErrors: 0, + whenCreated: '2026-01-01T00:00:00.000Z', + whenLastUpdate: '2026-02-01T00:00:00.000Z', + whenLastError: '2025-12-01T00:00:00.000Z', + whenExpires: '2099-01-01T00:00:00.000Z', + details: { secret: 's3cr3t' } + }); + }); + + it('keeps null for whenLastUpdate/whenLastError and omits optional fields', () => { + const subscription: Subscription = { + url: 'http://sub.example/notify', + protocol: 'http-post', + ctUpdates: 0, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: new Date('2026-01-01T00:00:00.000Z'), + whenLastUpdate: null, + whenLastError: null, + whenExpires: new Date('2099-01-01T00:00:00.000Z') + }; + + const json = subscriptionToJson(subscription); + expect(json.whenLastUpdate).toBeNull(); + expect(json.whenLastError).toBeNull(); + expect('notifyProcedure' in json).toBe(false); + expect('details' in json).toBe(false); + expect(subscriptionFromJson(json)).toEqual(subscription); + }); + + it('round-trips a full subscription through to/from JSON', () => { + const subscription: Subscription = { + url: 'http://sub.example/rpc', + protocol: 'xml-rpc', + notifyProcedure: 'river.feedUpdated', + ctUpdates: 3, + ctErrors: 1, + ctConsecutiveErrors: 0, + whenCreated: new Date('2026-01-01T00:00:00.000Z'), + whenLastUpdate: new Date('2026-02-01T00:00:00.000Z'), + whenLastError: new Date('2025-12-01T00:00:00.000Z'), + whenExpires: new Date('2099-01-01T00:00:00.000Z'), + details: { secret: 's3cr3t' } + }; + + expect(subscriptionFromJson(subscriptionToJson(subscription))).toEqual( + subscription + ); + }); +}); diff --git a/packages/core/src/store/store-codec.ts b/packages/core/src/store/store-codec.ts new file mode 100644 index 0000000..4e327f5 --- /dev/null +++ b/packages/core/src/store/store-codec.ts @@ -0,0 +1,119 @@ +import type { FeedMetadata } from '../feed/feed.js'; +import type { Protocol } from '../engine/protocol.js'; +import type { Resource } from '../engine/resource.js'; +import type { Subscription } from '../engine/subscription.js'; + +/** + * JSON-safe projections of the core domain model: the v2 on-disk / wire shape. + * Identical to the domain types except `Date`s become ISO-8601 strings; `null` + * still marks "never", feed metadata stays nested, and optional fields are + * omitted rather than emitted as `false`/epoch. These are the single source of + * truth for (de)serializing the model — both the file store and the server's + * raw-data/test endpoints map through them. + */ +export interface JsonResource { + url: string; + lastHash: string; + lastSize: number; + ctChecks: number; + whenLastCheck: string; + ctUpdates: number; + whenLastUpdate: string; + feed?: FeedMetadata; +} + +export interface JsonSubscription { + url: string; + protocol: Protocol; + notifyProcedure?: string; + ctUpdates: number; + ctErrors: number; + ctConsecutiveErrors: number; + whenCreated: string; + whenLastUpdate: string | null; + whenLastError: string | null; + whenExpires: string; + details?: Record; +} + +export function resourceToJson(resource: Resource): JsonResource { + const json: JsonResource = { + url: resource.url, + lastHash: resource.lastHash, + lastSize: resource.lastSize, + ctChecks: resource.ctChecks, + whenLastCheck: resource.whenLastCheck.toISOString(), + ctUpdates: resource.ctUpdates, + whenLastUpdate: resource.whenLastUpdate.toISOString() + }; + if (resource.feed !== undefined) { + json.feed = { ...resource.feed }; + } + return json; +} + +export function resourceFromJson(json: JsonResource): Resource { + const resource: Resource = { + url: json.url, + lastHash: json.lastHash, + lastSize: json.lastSize, + ctChecks: json.ctChecks, + whenLastCheck: new Date(json.whenLastCheck), + ctUpdates: json.ctUpdates, + whenLastUpdate: new Date(json.whenLastUpdate) + }; + if (json.feed !== undefined) { + resource.feed = { ...json.feed }; + } + return resource; +} + +export function subscriptionToJson(subscription: Subscription): JsonSubscription { + const json: JsonSubscription = { + url: subscription.url, + protocol: subscription.protocol, + ctUpdates: subscription.ctUpdates, + ctErrors: subscription.ctErrors, + ctConsecutiveErrors: subscription.ctConsecutiveErrors, + whenCreated: subscription.whenCreated.toISOString(), + whenLastUpdate: + subscription.whenLastUpdate === null + ? null + : subscription.whenLastUpdate.toISOString(), + whenLastError: + subscription.whenLastError === null + ? null + : subscription.whenLastError.toISOString(), + whenExpires: subscription.whenExpires.toISOString() + }; + if (subscription.notifyProcedure !== undefined) { + json.notifyProcedure = subscription.notifyProcedure; + } + if (subscription.details !== undefined) { + json.details = subscription.details; + } + return json; +} + +export function subscriptionFromJson(json: JsonSubscription): Subscription { + const subscription: Subscription = { + url: json.url, + protocol: json.protocol, + ctUpdates: json.ctUpdates, + ctErrors: json.ctErrors, + ctConsecutiveErrors: json.ctConsecutiveErrors, + whenCreated: new Date(json.whenCreated), + whenLastUpdate: + json.whenLastUpdate === null ? null : new Date(json.whenLastUpdate), + whenLastError: + json.whenLastError === null ? null : new Date(json.whenLastError), + whenExpires: new Date(json.whenExpires) + }; + if (json.notifyProcedure !== undefined) { + subscription.notifyProcedure = json.notifyProcedure; + } + if (json.details !== undefined) { + subscription.details = json.details; + } + return subscription; +} From c5baeffa7ec84f337a3f899c0b38e6e8a3d68e81 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Fri, 12 Jun 2026 15:22:24 -0500 Subject: [PATCH 52/90] refactor(server): expose the core model over /test/* and /subscriptions.json Delete legacy-store-shape.js (and its test): the raw-data view and the test harness now speak the core domain model's JSON shape via @rsscloud/core's codec (resource/subscription to/fromJson), the last consumers of the legacy wire mapping. - /subscriptions.json now returns the v2 document { version: 2, feeds: {...} } instead of the legacy keyed dump. - /test/getResource|getSubscriptions|getData return core-model JSON (nested feed, ISO dates, null for "never", no _id); /test/setResource stays lenient for the harness, /test/setSubscriptions takes the subscriptions array (was pleaseNotify). New services/feeds-json.js projects store entries onto the shared v2 feeds map. - core.js wires the file store's onMigrate hook to log a one-line legacy->v2 migration notice. The e2e helpers + assertions move onto the same core-model shapes (store-api.js, init-subscription.js, remove-expired-subscriptions.js). Full e2e suite green (134 passing); apps/server unit tests green (17). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/e2e/test/helpers/init-subscription.js | 28 ++-- apps/e2e/test/remove-expired-subscriptions.js | 16 +- apps/e2e/test/store-api.js | 18 +-- apps/server/controllers/index.js | 5 +- apps/server/controllers/test.js | 53 ++++--- apps/server/core.js | 8 +- apps/server/services/feeds-json.js | 17 +++ apps/server/services/legacy-store-shape.js | 137 ------------------ .../services/legacy-store-shape.test.js | 117 --------------- 9 files changed, 94 insertions(+), 305 deletions(-) create mode 100644 apps/server/services/feeds-json.js delete mode 100644 apps/server/services/legacy-store-shape.js delete mode 100644 apps/server/services/legacy-store-shape.test.js diff --git a/apps/e2e/test/helpers/init-subscription.js b/apps/e2e/test/helpers/init-subscription.js index 3676025..fd85de6 100644 --- a/apps/e2e/test/helpers/init-subscription.js +++ b/apps/e2e/test/helpers/init-subscription.js @@ -3,6 +3,9 @@ const getDayjs = require('./dayjs-wrapper'); const ctSecsResourceExpire = parseInt(process.env.CT_SECS_RESOURCE_EXPIRE, 10) || 90000; +// Build a core-model subscription (the JsonSubscription wire shape) onto the +// `subscriptions` array: `null` marks "never", whenCreated is recorded, and a +// REST subscription carries no notifyProcedure (string-only in the core model). async function initSubscription( subscriptions, notifyProcedure, @@ -10,33 +13,32 @@ async function initSubscription( protocol ) { const dayjs = await getDayjs(); + const now = dayjs().utc(); const defaultSubscription = { + url: apiurl, + protocol, ctUpdates: 0, - whenLastUpdate: new Date(dayjs.utc('0', 'x').format()), ctErrors: 0, ctConsecutiveErrors: 0, - whenLastError: new Date(dayjs.utc('0', 'x').format()), + whenCreated: new Date(now.format()), + whenLastUpdate: null, + whenLastError: null, whenExpires: new Date( - dayjs() - .utc() - .add(ctSecsResourceExpire, 'seconds') - .format() + now.add(ctSecsResourceExpire, 'seconds').format() ), - url: apiurl, - notifyProcedure, - protocol + ...(typeof notifyProcedure === 'string' ? { notifyProcedure } : {}) }, - index = subscriptions.pleaseNotify.findIndex(subscription => { + index = subscriptions.findIndex(subscription => { return subscription.url === apiurl; }); if (-1 === index) { - subscriptions.pleaseNotify.push(defaultSubscription); + subscriptions.push(defaultSubscription); } else { - subscriptions.pleaseNotify[index] = Object.assign( + subscriptions[index] = Object.assign( {}, defaultSubscription, - subscriptions.pleaseNotify[index] + subscriptions[index] ); } diff --git a/apps/e2e/test/remove-expired-subscriptions.js b/apps/e2e/test/remove-expired-subscriptions.js index fe34d24..6937384 100644 --- a/apps/e2e/test/remove-expired-subscriptions.js +++ b/apps/e2e/test/remove-expired-subscriptions.js @@ -162,10 +162,10 @@ describe('RemoveExpiredSubscriptions', function() { const resDoc = await storeApi.findResource(resourceUrl); expect(resDoc).to.not.be.null; - // JSON store entry should still exist with empty subscribers + // JSON store entry should still exist with empty subscriptions const storeData = await storeApi.getData(); expect(storeData).to.have.property(resourceUrl); - expect(storeData[resourceUrl].subscribers).to.deep.equal([]); + expect(storeData[resourceUrl].subscriptions).to.deep.equal([]); }); it('should remove empty-subscribers entry when whenLastUpdate is beyond retention window', async function() { @@ -233,19 +233,19 @@ describe('RemoveExpiredSubscriptions', function() { await storeApi.removeExpired(); - // Subscription document should still exist with empty pleaseNotify + // Subscription document should still exist with empty subscriptions const subDoc = await storeApi.findSubscription(resourceUrl); expect(subDoc).to.not.be.null; - expect(subDoc.pleaseNotify).to.deep.equal([]); + expect(subDoc).to.deep.equal([]); // Resource document should still exist const resDoc = await storeApi.findResource(resourceUrl); expect(resDoc).to.not.be.null; - // JSON store entry should still exist with empty subscribers + // JSON store entry should still exist with empty subscriptions const storeData = await storeApi.getData(); expect(storeData).to.have.property(resourceUrl); - expect(storeData[resourceUrl].subscribers).to.deep.equal([]); + expect(storeData[resourceUrl].subscriptions).to.deep.equal([]); }); it('should not remove resource when valid subscriptions remain', async function() { @@ -290,8 +290,8 @@ describe('RemoveExpiredSubscriptions', function() { // Subscription document should still exist with valid subscription const subDoc = await storeApi.findSubscription(resourceUrl); expect(subDoc).to.not.be.null; - expect(subDoc.pleaseNotify).to.have.lengthOf(1); - expect(subDoc.pleaseNotify[0].url).to.equal(apiurl2); + expect(subDoc).to.have.lengthOf(1); + expect(subDoc[0].url).to.equal(apiurl2); // Resource document should still exist const resDoc = await storeApi.findResource(resourceUrl); diff --git a/apps/e2e/test/store-api.js b/apps/e2e/test/store-api.js index 97d19b4..3686dc7 100644 --- a/apps/e2e/test/store-api.js +++ b/apps/e2e/test/store-api.js @@ -22,10 +22,10 @@ async function fetchSubscriptions(resourceUrl) { return subscriptions; } -async function setSubscriptions(resourceUrl, pleaseNotify) { +async function setSubscriptions(resourceUrl, subscriptions) { await postJson('/test/setSubscriptions', { feedUrl: resourceUrl, - pleaseNotify + subscriptions }); } @@ -63,28 +63,28 @@ module.exports = { apiurl, protocol ); - await setSubscriptions(resourceUrl, subscriptions.pleaseNotify); + await setSubscriptions(resourceUrl, subscriptions); - const index = subscriptions.pleaseNotify.findIndex(subscription => { + const index = subscriptions.findIndex(subscription => { return subscription.url === apiurl; }); if (-1 !== index) { - return subscriptions.pleaseNotify[index]; + return subscriptions[index]; } throw Error(`Cannot find ${apiurl} subscription`); }, updateSubscription: async function(resourceUrl, subscription) { const subscriptions = await fetchSubscriptions(resourceUrl), - index = subscriptions.pleaseNotify.findIndex(match => { + index = subscriptions.findIndex(match => { return subscription.url === match.url; }); if (-1 !== index) { - subscriptions.pleaseNotify[index] = subscription; - await setSubscriptions(resourceUrl, subscriptions.pleaseNotify); - return subscriptions.pleaseNotify[index]; + subscriptions[index] = subscription; + await setSubscriptions(resourceUrl, subscriptions); + return subscriptions[index]; } throw Error(`Cannot find ${subscription.url} subscription`); diff --git a/apps/server/controllers/index.js b/apps/server/controllers/index.js index f06e830..8a79fff 100644 --- a/apps/server/controllers/index.js +++ b/apps/server/controllers/index.js @@ -3,7 +3,7 @@ const express = require('express'), md = require('markdown-it')(), { createFeedsOpml } = require('../services/feeds-opml'), { createStats } = require('../services/stats'), - { toLegacyData } = require('../services/legacy-store-shape'), + { toFeedsJson } = require('../services/feeds-json'), { ping, pleaseNotify, rpc2 } = require('@rsscloud/express'), { core, store } = require('../core'), { generateOpml } = createFeedsOpml({ core }), @@ -49,8 +49,9 @@ router.get('/stats.json', (req, res) => { router.get('/subscriptions.json', async(req, res, next) => { try { + const feeds = toFeedsJson(await store.list()); res.set('Content-Type', 'application/json'); - res.send(JSON.stringify(toLegacyData(await store.list()), null, 2)); + res.send(JSON.stringify({ version: 2, feeds }, null, 2)); } catch (err) { next(err); } diff --git a/apps/server/controllers/test.js b/apps/server/controllers/test.js index 53ad391..607c473 100644 --- a/apps/server/controllers/test.js +++ b/apps/server/controllers/test.js @@ -1,12 +1,12 @@ const express = require('express'), { core, store } = require('../core'), { - toCoreResource, - toLegacyResource, - toCoreSubscription, - toLegacySubscription, - toLegacyData - } = require('../services/legacy-store-shape'), + resourceToJson, + resourceFromJson, + subscriptionToJson, + subscriptionFromJson + } = require('@rsscloud/core'), + { toFeedsJson } = require('../services/feeds-json'), createRemoveExpiredSubscriptions = require('../services/remove-expired-subscriptions'), router = new express.Router(); @@ -18,6 +18,24 @@ console.warn( router.use(express.json()); +const EPOCH_ISO = new Date(0).toISOString(); + +// The /test/* API speaks the core model's JSON shape (JsonResource / +// JsonSubscription). setResource stays lenient — the harness sends partial +// fixtures — filling core defaults and the feed URL before deserializing. +function resourceFromInput(feedUrl, raw) { + return resourceFromJson({ + url: feedUrl, + lastHash: raw.lastHash ?? '', + lastSize: raw.lastSize ?? 0, + ctChecks: raw.ctChecks ?? 0, + whenLastCheck: raw.whenLastCheck ?? EPOCH_ISO, + ctUpdates: raw.ctUpdates ?? 0, + whenLastUpdate: raw.whenLastUpdate ?? EPOCH_ISO, + ...(raw.feed !== undefined ? { feed: raw.feed } : {}) + }); +} + router.post('/clear', async(req, res) => { try { for (const { feedUrl } of await store.list()) { @@ -32,7 +50,7 @@ router.post('/clear', async(req, res) => { router.post('/setResource', async(req, res) => { try { const { feedUrl, resource } = req.body; - await store.putResource(feedUrl, toCoreResource(feedUrl, resource)); + await store.putResource(feedUrl, resourceFromInput(feedUrl, resource)); res.json({ success: true }); } catch (error) { res.status(500).json({ success: false, error: error.message }); @@ -46,10 +64,7 @@ router.post('/getResource', async(req, res) => { res.json({ success: true, found: resource !== null, - resource: - resource !== null - ? { _id: feedUrl, ...toLegacyResource(resource) } - : null + resource: resource !== null ? resourceToJson(resource) : null }); } catch (error) { res.status(500).json({ success: false, error: error.message }); @@ -58,8 +73,11 @@ router.post('/getResource', async(req, res) => { router.post('/setSubscriptions', async(req, res) => { try { - const { feedUrl, pleaseNotify } = req.body; - await store.putSubscriptions(feedUrl, pleaseNotify.map(toCoreSubscription)); + const { feedUrl, subscriptions } = req.body; + await store.putSubscriptions( + feedUrl, + subscriptions.map(subscriptionFromJson) + ); res.json({ success: true }); } catch (error) { res.status(500).json({ success: false, error: error.message }); @@ -70,13 +88,12 @@ router.post('/getSubscriptions', async(req, res) => { try { const { feedUrl } = req.body; const entry = (await store.list()).find(e => e.feedUrl === feedUrl); - const pleaseNotify = entry - ? entry.subscriptions.map(toLegacySubscription) - : []; res.json({ success: true, found: entry !== undefined, - subscriptions: { _id: feedUrl, pleaseNotify } + subscriptions: entry + ? entry.subscriptions.map(subscriptionToJson) + : [] }); } catch (error) { res.status(500).json({ success: false, error: error.message }); @@ -85,7 +102,7 @@ router.post('/getSubscriptions', async(req, res) => { router.post('/getData', async(req, res) => { try { - res.json({ success: true, data: toLegacyData(await store.list()) }); + res.json({ success: true, data: toFeedsJson(await store.list()) }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } diff --git a/apps/server/core.js b/apps/server/core.js index 89e76d8..3215d0b 100644 --- a/apps/server/core.js +++ b/apps/server/core.js @@ -32,7 +32,13 @@ const plugins = [ // `core.store` is the ready store the read-side controllers use; `core.close()` // flushes + closes it for the graceful-shutdown hooks in app.js. const core = createRssCloudCore({ - store: createFileStore({ filePath: config.dataFilePath }), + store: createFileStore({ + filePath: config.dataFilePath, + onMigrate: ({ from, to, feedCount }) => + console.log( + `[file-store] migrated ${feedCount} feed(s) from legacy file ${from}; writes now target ${to}` + ) + }), plugins, config: coreConfig }); diff --git a/apps/server/services/feeds-json.js b/apps/server/services/feeds-json.js new file mode 100644 index 0000000..acce10c --- /dev/null +++ b/apps/server/services/feeds-json.js @@ -0,0 +1,17 @@ +const { resourceToJson, subscriptionToJson } = require('@rsscloud/core'); + +// Project the core store's entries onto the v2 `feeds` map — the JSON shape the +// `/subscriptions.json` raw-data view and the `/test/*` harness both expose. +// This is the core-model successor to the retired legacy-store-shape dump. +function toFeedsJson(entries) { + const feeds = {}; + for (const { feedUrl, resource, subscriptions } of entries) { + feeds[feedUrl] = { + resource: resource === null ? null : resourceToJson(resource), + subscriptions: subscriptions.map(subscriptionToJson) + }; + } + return feeds; +} + +module.exports = { toFeedsJson }; diff --git a/apps/server/services/legacy-store-shape.js b/apps/server/services/legacy-store-shape.js deleted file mode 100644 index 29d6005..0000000 --- a/apps/server/services/legacy-store-shape.js +++ /dev/null @@ -1,137 +0,0 @@ -// The legacy on-disk / json-store wire shape (keyed by feed URL, flat feed -// fields, string dates, `_id`, `pleaseNotify`) mapped to and from core's model. -// -// core's `Store` interface speaks the core model (Date objects, nested `feed`, -// `subscriptions`). Two server seams still speak the legacy shape and need this -// translation: the `/test/*` harness the e2e suite drives, and the -// `/subscriptions.json` raw-data view. core-store-adapter.js consumes the same -// mappers so there is a single source of truth; this mirrors -// packages/core/src/store/file-store.ts (core's own copy at the disk boundary). - -function toCoreFeed(raw) { - const feed = {}; - if (raw.feedType != null) feed.type = raw.feedType; - if (raw.feedTitle != null) feed.title = raw.feedTitle; - if (raw.feedDescription != null) feed.description = raw.feedDescription; - if (raw.feedHtmlUrl != null) feed.htmlUrl = raw.feedHtmlUrl; - if (raw.feedLanguage != null) feed.language = raw.feedLanguage; - return Object.keys(feed).length > 0 ? feed : undefined; -} - -function toCoreResource(feedUrl, raw) { - // A missing entry or a resource with no real fields (json-store returns - // `{ _id }` for an empty `{}` resource) is "no resource". - if (raw == null) return null; - if (Object.keys(raw).filter(key => key !== '_id').length === 0) return null; - const resource = { - url: feedUrl, - lastHash: raw.lastHash ?? '', - lastSize: raw.lastSize ?? 0, - ctChecks: raw.ctChecks ?? 0, - whenLastCheck: new Date(raw.whenLastCheck ?? 0), - ctUpdates: raw.ctUpdates ?? 0, - whenLastUpdate: new Date(raw.whenLastUpdate ?? 0) - }; - const feed = toCoreFeed(raw); - if (feed !== undefined) resource.feed = feed; - return resource; -} - -function toLegacyResource(resource) { - const out = { - lastSize: resource.lastSize, - lastHash: resource.lastHash, - ctChecks: resource.ctChecks, - whenLastCheck: resource.whenLastCheck.toISOString(), - ctUpdates: resource.ctUpdates, - whenLastUpdate: resource.whenLastUpdate.toISOString() - }; - const feed = resource.feed; - if (feed !== undefined) { - if (feed.type != null) out.feedType = feed.type; - if (feed.title != null) out.feedTitle = feed.title; - if (feed.description != null) out.feedDescription = feed.description; - if (feed.htmlUrl != null) out.feedHtmlUrl = feed.htmlUrl; - if (feed.language != null) out.feedLanguage = feed.language; - } - return out; -} - -const EPOCH_ISO = new Date(0).toISOString(); - -// Epoch ("never happened" on disk) maps to `null` in the core model. -function toNullableDate(value) { - const date = new Date(value ?? 0); - return date.getTime() === 0 ? null : date; -} - -// `null` ("never") serializes back to the epoch string the legacy reader uses. -function fromNullableDate(value) { - return value === null ? EPOCH_ISO : value.toISOString(); -} - -function toCoreSubscription(raw) { - const whenExpires = new Date(raw.whenExpires ?? 0); - const subscription = { - url: raw.url, - protocol: raw.protocol, - ctUpdates: raw.ctUpdates ?? 0, - ctErrors: raw.ctErrors ?? 0, - ctConsecutiveErrors: raw.ctConsecutiveErrors ?? 0, - // Legacy records carry no creation time; synthesize from expiry. - whenCreated: - raw.whenCreated != null ? new Date(raw.whenCreated) : whenExpires, - whenLastUpdate: toNullableDate(raw.whenLastUpdate), - whenLastError: toNullableDate(raw.whenLastError), - whenExpires - }; - if (typeof raw.notifyProcedure === 'string') { - subscription.notifyProcedure = raw.notifyProcedure; - } - if (raw.details !== undefined) { - subscription.details = raw.details; - } - return subscription; -} - -function toLegacySubscription(subscription) { - const out = { - ctUpdates: subscription.ctUpdates, - whenLastUpdate: fromNullableDate(subscription.whenLastUpdate), - ctErrors: subscription.ctErrors, - ctConsecutiveErrors: subscription.ctConsecutiveErrors, - whenLastError: fromNullableDate(subscription.whenLastError), - whenExpires: subscription.whenExpires.toISOString(), - url: subscription.url, - // REST subs carry no procedure; the legacy shape records that as `false`. - notifyProcedure: subscription.notifyProcedure ?? false, - protocol: subscription.protocol - }; - if (subscription.details !== undefined) { - out.details = subscription.details; - } - return out; -} - -// Rebuild the legacy nested dump (`{ feedUrl: { resource, subscribers } }`) the -// json-store exposed via getData(), from core's FeedEntry[] (store.list()). A -// subscriptions-only entry (core resource `null`) maps back to an empty `{}` -// resource, matching the legacy shape. -function toLegacyData(entries) { - const data = {}; - for (const entry of entries) { - data[entry.feedUrl] = { - resource: entry.resource ? toLegacyResource(entry.resource) : {}, - subscribers: entry.subscriptions.map(toLegacySubscription) - }; - } - return data; -} - -module.exports = { - toCoreResource, - toLegacyResource, - toCoreSubscription, - toLegacySubscription, - toLegacyData -}; diff --git a/apps/server/services/legacy-store-shape.test.js b/apps/server/services/legacy-store-shape.test.js deleted file mode 100644 index cc1022e..0000000 --- a/apps/server/services/legacy-store-shape.test.js +++ /dev/null @@ -1,117 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); - -const { - toCoreResource, - toLegacyResource, - toCoreSubscription, - toLegacySubscription, - toLegacyData -} = require('./legacy-store-shape'); - -const EPOCH_ISO = new Date(0).toISOString(); - -test('toCoreResource treats a missing or empty resource as no resource', () => { - assert.equal(toCoreResource('https://a/feed', null), null); - assert.equal(toCoreResource('https://a/feed', undefined), null); - assert.equal(toCoreResource('https://a/feed', {}), null); - // json-store hands back `{ _id }` for an empty resource — still "none". - assert.equal(toCoreResource('https://a/feed', { _id: 'https://a/feed' }), null); -}); - -test('a populated resource round-trips legacy -> core -> legacy', () => { - const legacy = { - lastSize: 100, - lastHash: 'abc', - ctChecks: 3, - whenLastCheck: '2026-06-01T00:00:00.000Z', - ctUpdates: 2, - whenLastUpdate: '2026-06-02T00:00:00.000Z', - feedTitle: 'Alpha', - feedType: 'rss' - }; - - const core = toCoreResource('https://a/feed', legacy); - assert.equal(core.url, 'https://a/feed'); - assert.ok(core.whenLastCheck instanceof Date); - assert.deepEqual(core.feed, { title: 'Alpha', type: 'rss' }); - - assert.deepEqual(toLegacyResource(core), legacy); -}); - -test('a subscription round-trips legacy -> core -> legacy', () => { - const legacy = { - ctUpdates: 0, - whenLastUpdate: EPOCH_ISO, - ctErrors: 0, - ctConsecutiveErrors: 2, - whenLastError: EPOCH_ISO, - whenExpires: '2026-07-01T00:00:00.000Z', - url: 'http://sub.example.com/notify', - notifyProcedure: false, - protocol: 'http-post' - }; - - const core = toCoreSubscription(legacy); - // Epoch ("never") becomes null in the core model. - assert.equal(core.whenLastUpdate, null); - assert.equal(core.whenLastError, null); - assert.equal(core.ctConsecutiveErrors, 2); - assert.ok(core.whenExpires instanceof Date); - - assert.deepEqual(toLegacySubscription(core), legacy); -}); - -test('toLegacySubscription defaults a missing notifyProcedure to false', () => { - const core = toCoreSubscription({ - url: 'http://sub/notify', - protocol: 'http-post', - whenExpires: '2026-07-01T00:00:00.000Z' - }); - assert.equal(core.notifyProcedure, undefined); - assert.equal(toLegacySubscription(core).notifyProcedure, false); -}); - -test('toLegacyData rebuilds the nested dump, mapping a null resource to {}', () => { - const entries = [ - { - feedUrl: 'https://a/feed', - resource: toCoreResource('https://a/feed', { - lastSize: 1, - lastHash: 'h', - ctChecks: 1, - whenLastCheck: '2026-06-01T00:00:00.000Z', - ctUpdates: 1, - whenLastUpdate: '2026-06-01T00:00:00.000Z' - }), - subscriptions: [ - toCoreSubscription({ - url: 'http://sub/notify', - protocol: 'http-post', - whenExpires: '2026-07-01T00:00:00.000Z' - }) - ] - }, - { - feedUrl: 'https://b/feed', - resource: null, - subscriptions: [] - } - ]; - - const data = toLegacyData(entries); - - assert.deepEqual(Object.keys(data), ['https://a/feed', 'https://b/feed']); - assert.equal(data['https://a/feed'].resource.lastHash, 'h'); - assert.equal(data['https://a/feed'].subscribers.length, 1); - assert.equal( - data['https://a/feed'].subscribers[0].url, - 'http://sub/notify' - ); - // Subscriptions-only feed: empty resource object, empty subscribers. - assert.deepEqual(data['https://b/feed'], { resource: {}, subscribers: [] }); -}); - -test('toLegacyData returns an empty object for no entries', () => { - assert.deepEqual(toLegacyData([]), {}); -}); From 58d7350e2caaa734026dfbcacc170f2c5d541ddf Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Fri, 12 Jun 2026 15:23:34 -0500 Subject: [PATCH 53/90] docs: mark the on-disk v2 format unification done Remove the completed "Unify the on-disk format with the domain model" follow-up and note in the intro that the v2 format migration (disk == domain model, legacy-store-shape.js deleted) is done. Update the WebSub section: new Subscription fields now persist directly on the v2 format, no prerequisite. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 48 ++++++++---------------------------------------- 1 file changed, 8 insertions(+), 40 deletions(-) diff --git a/TODO.md b/TODO.md index d95827c..186de5d 100644 --- a/TODO.md +++ b/TODO.md @@ -1,43 +1,11 @@ # TODO — rsscloud-server: open work -Outstanding + future work only. The `apps/server` → `@rsscloud/core` migration is -done; its history lives in git (`refactor(server):` commits), not here. Per -CLAUDE.md: build with the `tdd` skill (red-green vertical slices); Conventional -Commits enforced. Architecture decisions are recorded in `docs/adr/`; domain -vocabulary in `CONTEXT.md`. - -## Small follow-ups (optional, none blocking) - -- [ ] **Unify the on-disk format with the domain model** (versioned-file migration). - Two layers do the same legacy↔core translation today — - `packages/core/.../file-store.ts` (disk↔core) and - `apps/server/services/legacy-store-shape.js` (legacy-wire↔core, for `/test/*` + - `/subscriptions.json`). Persisting core's model instead **deletes - `legacy-store-shape.js`**, moves `/subscriptions.json` + the e2e `/test/*` - helpers onto the core model, and drops the per-read mapping in `list()` / - `getResource()` (in-memory becomes the core model directly). `file-store.ts` is - left doing only date (de)serialization + a one-way legacy importer. - - The three service unit tests (`stats` / `feeds-opml` / `remove-expired`) already - build injected in-memory cores and seed the core model directly, so - `controllers/index.js` (`/subscriptions.json`) and `controllers/test.js` - (`/test/*`) are now the only remaining `legacy-store-shape.js` consumers. - - *Migration flow (self-completing, no manual step):* - - Load precedence: `subscriptions.v2.json` → `subscriptions.v1.json` / - `subscriptions.json` (legacy, **converted** on load) → empty. - - All writes go to `subscriptions.v2.json`; the legacy file is never rewritten - (left as a "new format exists" signal + pre-migration backup). Future boots - read v2 directly. - - The converter already exists — `file-store.ts`'s current `readResource` / - `readSubscription` (legacy→core) become the v1 import path; only the *writer* - flips to v2. Keep the v1 importer until a later major drops it. - - Config: derive paths from `DATA_FILE_PATH` (write `…/subscriptions.v2.json`, - fall back to `.v1.json` / the bare name). Log once on migration. - - *Caveat:* forward-only — once v2 runs, the legacy file goes stale, so rolling - back to old code loses post-migration writes. If both exist, v2 wins (document - it). +Outstanding + future work only. The `apps/server` → `@rsscloud/core` migration and +the on-disk **v2 format unification** (disk == domain model; `legacy-store-shape.js` +deleted; one-way legacy importer in `file-store.ts`) are both done — their history +lives in git (`feat(core):` / `refactor(server):` commits), not here. Per CLAUDE.md: +build with the `tdd` skill (red-green vertical slices); Conventional Commits enforced. +Architecture decisions are recorded in `docs/adr/`; domain vocabulary in `CONTEXT.md`. ## WebSub hub support (bigger — spans core + express) @@ -69,8 +37,8 @@ through it rather than re-deriving callback/scheme/`diffDomain` logic. *Open questions:* sync vs async intent verification (spec prefers async `202`); which HMAC algos to require; content source on publish (fetch vs publisher-pushed). The new -subscription fields ride better on the domain-model disk format — do the unify -follow-up first. +subscription fields now persist directly — the domain-model v2 disk format is in place, +so new `Subscription` fields ride along with no extra mapping. *First slice:* core `subscribe` happy path (parse, verify intent, persist) + the express `websub` factory + an e2e callback handshake. Defer content distribution, From fbbec726cb8da9a6db2cfe20f4e55a42948001b8 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Fri, 12 Jun 2026 15:59:37 -0500 Subject: [PATCH 54/90] docs: drop stale PLAN/json-store references in service comments The migration-era PLAN doc was never committed and json-store is retired. Point the comments at the present design instead: core-event-bridge renders core events as-is "by design"; remove-expired's effects land in "the shared store the /test/getData view reads" (was "json-store"), and the sweep "differs by design" (was "see PLAN slice E"). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/services/core-event-bridge.js | 2 +- apps/server/services/remove-expired-subscriptions.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/services/core-event-bridge.js b/apps/server/services/core-event-bridge.js index 182fd46..38190c0 100644 --- a/apps/server/services/core-event-bridge.js +++ b/apps/server/services/core-event-bridge.js @@ -1,5 +1,5 @@ // Bridges core's observability events onto the /wsLog websocket so /viewLog keeps -// working once endpoints run through @rsscloud/core. Per PLAN #4 we render core's +// working once endpoints run through @rsscloud/core. By design we render core's // events as-is: no enrichment, request headers dropped, and per-event timing taken // from core's `durationMs` where it carries one (only `ping` does today). diff --git a/apps/server/services/remove-expired-subscriptions.js b/apps/server/services/remove-expired-subscriptions.js index 260a1cf..7b4909c 100644 --- a/apps/server/services/remove-expired-subscriptions.js +++ b/apps/server/services/remove-expired-subscriptions.js @@ -2,9 +2,9 @@ // logic lives in @rsscloud/core; this is a thin adapter over core.removeExpired() // that the server schedules (app.js) and the /test/* API drives. Callers own // their own error handling, and core reads/writes the shared store, so the -// effects land in the same json-store the legacy /test/getData reads. +// effects land in the same store the /test/getData view reads. // -// Differs from the retired hand-rolled sweep by design (see PLAN slice E): +// Differs from the retired hand-rolled sweep by design: // - returns core's MaintenanceResult (feedsProcessed/feedsDeleted) instead of // the legacy documentsProcessed/documentsDeleted/urlsFixed shape; // - drops the IPv4-mapped-IPv6 callback rewrite (new subs are normalized at From 865f48a12e5f482a6934451950468340a7fb7e57 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Fri, 12 Jun 2026 18:37:43 -0500 Subject: [PATCH 55/90] docs: track architecture cleanup (deepening opportunities) in TODO Record five deepening items from the 2026-06-12 architecture review, ordered by payoff, above the WebSub section: seal the core.store port, inject core at the HTTP edge, extract the maintenance jobs, dedupe fetchWithTimeout, and fix the feedsChangedLast7Days label drift. The client.js wire-builder item cross-references the existing client package work rather than duplicating it. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/TODO.md b/TODO.md index 186de5d..241d387 100644 --- a/TODO.md +++ b/TODO.md @@ -7,6 +7,72 @@ lives in git (`feat(core):` / `refactor(server):` commits), not here. Per CLAUDE build with the `tdd` skill (red-green vertical slices); Conventional Commits enforced. Architecture decisions are recorded in `docs/adr/`; domain vocabulary in `CONTEXT.md`. +## Architecture cleanup (deepening opportunities) + +From an architecture review (2026-06-12). Ordered by payoff. Vocabulary: a +**shallow** module's interface is nearly as complex as its implementation; a +**deep** one hides a lot of behaviour behind a small interface; a **seam** is a +place behaviour can be swapped without editing in place; **leakage** is one +module's internals crossing a seam into another. File/line refs will drift — +trust the names over the numbers. + +### 1. Seal the `core.store` port (the keystone) + +`Store` is injected *into* the engine, then re-exposed *out* of it +(`core/engine/core.ts` `readonly store`, re-exported by `apps/server/core.js`). +That leak lets the read side reach past the engine to touch state directly: +`controllers/index.js` (`/subscriptions.json`), `services/feeds-json.js`, +`services/feeds-opml.js`, the whole `/test/*` API in `controllers/test.js`, and +even core's own tests (`core.store.list()`). + +*Fix:* give `RssCloudCore` a narrow read seam — `listFeeds()` snapshot plus a +`seedResource()` for the test API — and drop `readonly store` from the +interface. Concentrates all state access in one module. Unblocks #2 (the +injectable in-memory core). + +### 2. Open a test seam at the HTTP edge + +Controllers `require('../core')` at module load, so importing any one boots a +real `FileStore` — no controller has a test. Four (`home`, `ping-form`, +`please-notify-form`, `docs`) are near-identical `res.render` shells, and the +`/LICENSE.md` route re-inlines what `docs.js` already does. + +*Fix:* a `createControllers({ core })` factory mirroring the testable services +(`feeds-opml`, `stats`, etc.), plus a table-driven mount for the render-only +routes. Two adapters justify the seam: prod core and an in-memory core in tests. + +### 3. Lift the maintenance jobs out of `create-core.ts` + +`removeExpired` and `generateStats` (~130 lines inside the 585-line factory) are +read-only jobs needing only `store` + a clock, but are exercisable only by +building a full core with fetch + plugin mocks they never use. + +*Fix:* extract as functions over `(store, config, now)`; core delegates. Narrows +the test surface; shrinks the factory. (Coverage stays 100% per CLAUDE.md.) + +### 4. One `fetchWithTimeout`, not three copies + +The abort-controller + `clearTimeout` pattern is written verbatim in +`engine/create-core.ts`, `protocols/rest-plugin.ts`, and +`protocols/xml-rpc-plugin.ts`; only the timeout source differs. + +*Fix:* a shared `fetchWithTimeout(doFetch, ms, url, init)` core util. A bug in +the abort dance then has one place to live, and one place to test. + +### 5. `feedsChangedLast7Days` label can silently lie + +The window is a config value upstream (`feedsChangedWindowDays`) but a baked-in +literal `7` downstream: the wire field name in `services/stats.js` +(`toLegacyStats`) and the wording in `views/stats.handlebars`. Change the config +and the label keeps claiming "7 days". + +*Fix:* carry the window count through the projection (`feedsChangedLastWindow` + +`windowDays`) and let the template interpolate it. + +> The review's sixth item — extracting the hand-rolled wire builders out of +> `apps/server/client.js` — is already the "Client app + `@rsscloud/client` +> package" work below. Not duplicated here. + ## WebSub hub support (bigger — spans core + express) Make the server act as a [WebSub](https://www.w3.org/TR/websub/) **hub** (the W3C From 6f14cfc477c5534928177b0b81af6c4ec3ecbcfd Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Fri, 12 Jun 2026 20:01:36 -0500 Subject: [PATCH 56/90] refactor(core): seal the store port behind a narrow listFeeds/seed seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The injected Store was re-exposed as `readonly store` on RssCloudCore, letting the read side and the /test/* API reach past the engine to touch persistence directly. Replace it with an intentional seam and drop the leak: - listFeeds(): read-only feed snapshot — serves /subscriptions.json, /feeds.opml, and the test API's reads (getResource/getSubscriptions/ getData all derive from it). - seedResource / seedSubscriptions / clearFeeds: the test API's write/reset seam. - Remove `readonly store` from the interface and the returned object; the Store facade stays private to create-core.ts. Server rewiring: controllers/index.js, controllers/test.js (drops its store import entirely), services/feeds-opml.js, and the feeds-opml / stats / remove-expired-subscriptions tests now use the seam. Core 100% coverage retained; server node tests + Docker e2e (134) green. All state access is now concentrated in core, unblocking the injectable in-memory core. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 26 +++---- apps/server/controllers/index.js | 4 +- apps/server/controllers/test.js | 24 +++--- apps/server/core.js | 8 +- apps/server/services/feeds-opml.js | 6 +- apps/server/services/feeds-opml.test.js | 10 +-- .../remove-expired-subscriptions.test.js | 24 +++--- apps/server/services/stats.test.js | 12 +-- packages/core/src/engine/core.ts | 28 ++++++- packages/core/src/engine/create-core.test.ts | 78 +++++++++++++++---- packages/core/src/engine/create-core.ts | 31 +++++++- 11 files changed, 171 insertions(+), 80 deletions(-) diff --git a/TODO.md b/TODO.md index 241d387..59d4d99 100644 --- a/TODO.md +++ b/TODO.md @@ -16,21 +16,13 @@ place behaviour can be swapped without editing in place; **leakage** is one module's internals crossing a seam into another. File/line refs will drift — trust the names over the numbers. -### 1. Seal the `core.store` port (the keystone) +> The keystone — sealing the `core.store` port — is **done**: `RssCloudCore` +> now exposes a narrow `listFeeds()` read seam plus `seedResource()` / +> `seedSubscriptions()` / `clearFeeds()` for the test API, and `readonly store` +> is gone from the interface. All state access is concentrated in core. History +> in git (`refactor(core):` / `refactor(server):`). This unblocks #1 below. -`Store` is injected *into* the engine, then re-exposed *out* of it -(`core/engine/core.ts` `readonly store`, re-exported by `apps/server/core.js`). -That leak lets the read side reach past the engine to touch state directly: -`controllers/index.js` (`/subscriptions.json`), `services/feeds-json.js`, -`services/feeds-opml.js`, the whole `/test/*` API in `controllers/test.js`, and -even core's own tests (`core.store.list()`). - -*Fix:* give `RssCloudCore` a narrow read seam — `listFeeds()` snapshot plus a -`seedResource()` for the test API — and drop `readonly store` from the -interface. Concentrates all state access in one module. Unblocks #2 (the -injectable in-memory core). - -### 2. Open a test seam at the HTTP edge +### 1. Open a test seam at the HTTP edge Controllers `require('../core')` at module load, so importing any one boots a real `FileStore` — no controller has a test. Four (`home`, `ping-form`, @@ -41,7 +33,7 @@ real `FileStore` — no controller has a test. Four (`home`, `ping-form`, (`feeds-opml`, `stats`, etc.), plus a table-driven mount for the render-only routes. Two adapters justify the seam: prod core and an in-memory core in tests. -### 3. Lift the maintenance jobs out of `create-core.ts` +### 2. Lift the maintenance jobs out of `create-core.ts` `removeExpired` and `generateStats` (~130 lines inside the 585-line factory) are read-only jobs needing only `store` + a clock, but are exercisable only by @@ -50,7 +42,7 @@ building a full core with fetch + plugin mocks they never use. *Fix:* extract as functions over `(store, config, now)`; core delegates. Narrows the test surface; shrinks the factory. (Coverage stays 100% per CLAUDE.md.) -### 4. One `fetchWithTimeout`, not three copies +### 3. One `fetchWithTimeout`, not three copies The abort-controller + `clearTimeout` pattern is written verbatim in `engine/create-core.ts`, `protocols/rest-plugin.ts`, and @@ -59,7 +51,7 @@ The abort-controller + `clearTimeout` pattern is written verbatim in *Fix:* a shared `fetchWithTimeout(doFetch, ms, url, init)` core util. A bug in the abort dance then has one place to live, and one place to test. -### 5. `feedsChangedLast7Days` label can silently lie +### 4. `feedsChangedLast7Days` label can silently lie The window is a config value upstream (`feedsChangedWindowDays`) but a baked-in literal `7` downstream: the wire field name in `services/stats.js` diff --git a/apps/server/controllers/index.js b/apps/server/controllers/index.js index 8a79fff..988b24b 100644 --- a/apps/server/controllers/index.js +++ b/apps/server/controllers/index.js @@ -5,7 +5,7 @@ const express = require('express'), { createStats } = require('../services/stats'), { toFeedsJson } = require('../services/feeds-json'), { ping, pleaseNotify, rpc2 } = require('@rsscloud/express'), - { core, store } = require('../core'), + { core } = require('../core'), { generateOpml } = createFeedsOpml({ core }), { getStats } = createStats({ core }), router = new express.Router(); @@ -49,7 +49,7 @@ router.get('/stats.json', (req, res) => { router.get('/subscriptions.json', async(req, res, next) => { try { - const feeds = toFeedsJson(await store.list()); + const feeds = toFeedsJson(await core.listFeeds()); res.set('Content-Type', 'application/json'); res.send(JSON.stringify({ version: 2, feeds }, null, 2)); } catch (err) { diff --git a/apps/server/controllers/test.js b/apps/server/controllers/test.js index 607c473..127f5be 100644 --- a/apps/server/controllers/test.js +++ b/apps/server/controllers/test.js @@ -1,5 +1,5 @@ const express = require('express'), - { core, store } = require('../core'), + { core } = require('../core'), { resourceToJson, resourceFromJson, @@ -20,6 +20,13 @@ router.use(express.json()); const EPOCH_ISO = new Date(0).toISOString(); +// The /test/* API drives core's narrow seed/snapshot seam — it never reaches +// into the store. Reads find the feed in the listFeeds() snapshot; writes go +// through seedResource/seedSubscriptions/clearFeeds. +async function findEntry(feedUrl) { + return (await core.listFeeds()).find(entry => entry.feedUrl === feedUrl); +} + // The /test/* API speaks the core model's JSON shape (JsonResource / // JsonSubscription). setResource stays lenient — the harness sends partial // fixtures — filling core defaults and the feed URL before deserializing. @@ -38,9 +45,7 @@ function resourceFromInput(feedUrl, raw) { router.post('/clear', async(req, res) => { try { - for (const { feedUrl } of await store.list()) { - await store.remove(feedUrl); - } + await core.clearFeeds(); res.json({ success: true }); } catch (error) { res.status(500).json({ success: false, error: error.message }); @@ -50,7 +55,7 @@ router.post('/clear', async(req, res) => { router.post('/setResource', async(req, res) => { try { const { feedUrl, resource } = req.body; - await store.putResource(feedUrl, resourceFromInput(feedUrl, resource)); + await core.seedResource(feedUrl, resourceFromInput(feedUrl, resource)); res.json({ success: true }); } catch (error) { res.status(500).json({ success: false, error: error.message }); @@ -60,7 +65,8 @@ router.post('/setResource', async(req, res) => { router.post('/getResource', async(req, res) => { try { const { feedUrl } = req.body; - const resource = await store.getResource(feedUrl); + const entry = await findEntry(feedUrl); + const resource = entry?.resource ?? null; res.json({ success: true, found: resource !== null, @@ -74,7 +80,7 @@ router.post('/getResource', async(req, res) => { router.post('/setSubscriptions', async(req, res) => { try { const { feedUrl, subscriptions } = req.body; - await store.putSubscriptions( + await core.seedSubscriptions( feedUrl, subscriptions.map(subscriptionFromJson) ); @@ -87,7 +93,7 @@ router.post('/setSubscriptions', async(req, res) => { router.post('/getSubscriptions', async(req, res) => { try { const { feedUrl } = req.body; - const entry = (await store.list()).find(e => e.feedUrl === feedUrl); + const entry = await findEntry(feedUrl); res.json({ success: true, found: entry !== undefined, @@ -102,7 +108,7 @@ router.post('/getSubscriptions', async(req, res) => { router.post('/getData', async(req, res) => { try { - res.json({ success: true, data: toFeedsJson(await store.list()) }); + res.json({ success: true, data: toFeedsJson(await core.listFeeds()) }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } diff --git a/apps/server/core.js b/apps/server/core.js index 3215d0b..f6c4afb 100644 --- a/apps/server/core.js +++ b/apps/server/core.js @@ -28,9 +28,9 @@ const plugins = [ // createFileStore is async, but core.js is required synchronously — the // @rsscloud/express middleware factories need a concrete `core` at mount time. // core takes the store promise, resolves it once, and defers every operation -// until the load completes, so the host gets a concrete `core` immediately. -// `core.store` is the ready store the read-side controllers use; `core.close()` -// flushes + closes it for the graceful-shutdown hooks in app.js. +// until the load completes, so the host gets a concrete `core` immediately. The +// store stays private to core; read-side controllers use `core.listFeeds()` and +// `core.close()` flushes + closes it for the graceful-shutdown hooks in app.js. const core = createRssCloudCore({ store: createFileStore({ filePath: config.dataFilePath, @@ -43,4 +43,4 @@ const core = createRssCloudCore({ config: coreConfig }); -module.exports = { core, events: core.events, store: core.store }; +module.exports = { core, events: core.events }; diff --git a/apps/server/services/feeds-opml.js b/apps/server/services/feeds-opml.js index b9dc9ee..7f2d199 100644 --- a/apps/server/services/feeds-opml.js +++ b/apps/server/services/feeds-opml.js @@ -5,14 +5,14 @@ const getDayjs = require('./dayjs-wrapper'); // Builds the `/feeds.opml` document: every tracked feed as an , // sorted case-insensitively by display text. The controller owns the HTTP // response (Content-Type + error forwarding); this returns the XML string. -// Reads the injected core's store, whose `resource.feed` metadata is null/absent -// for a feed that has never been pinged (so text falls back to the feed URL). +// Reads the injected core's feed snapshot, whose `resource.feed` metadata is +// null/absent for a feed never pinged (so text falls back to the feed URL). function createFeedsOpml({ core }) { async function generateOpml() { const dayjs = await getDayjs(); const nowIso = dayjs().utc().format(); - const entries = await core.store.list(); + const entries = await core.listFeeds(); const outlines = []; for (const { feedUrl, resource } of entries) { diff --git a/apps/server/services/feeds-opml.test.js b/apps/server/services/feeds-opml.test.js index e0e7269..229a2b3 100644 --- a/apps/server/services/feeds-opml.test.js +++ b/apps/server/services/feeds-opml.test.js @@ -54,7 +54,7 @@ async function parseOpml(xml) { test('generateOpml renders a feed with full metadata as an outline', async() => { const { core, generateOpml } = setup(); - await core.store.putResource('https://a.example.com/feed.xml', makeResource('https://a.example.com/feed.xml', { + await core.seedResource('https://a.example.com/feed.xml', makeResource('https://a.example.com/feed.xml', { type: 'atom', title: 'Alpha', description: 'The Alpha feed', @@ -89,9 +89,9 @@ test('generateOpml sorts case-insensitively and falls back to the feed URL', asy const { core, generateOpml } = setup(); // Untitled feed: text falls back to the URL, type defaults to rss, and no // title/description/htmlUrl/language attributes are emitted. - await core.store.putResource('https://apple.example.com/feed.xml', makeResource('https://apple.example.com/feed.xml')); - await core.store.putResource('https://b.example.com/feed.xml', makeResource('https://b.example.com/feed.xml', { title: 'banana' })); - await core.store.putResource('https://z.example.com/feed.xml', makeResource('https://z.example.com/feed.xml', { title: 'Cherry' })); + await core.seedResource('https://apple.example.com/feed.xml', makeResource('https://apple.example.com/feed.xml')); + await core.seedResource('https://b.example.com/feed.xml', makeResource('https://b.example.com/feed.xml', { title: 'banana' })); + await core.seedResource('https://z.example.com/feed.xml', makeResource('https://z.example.com/feed.xml', { title: 'Cherry' })); const result = await parseOpml(await generateOpml()); const outlines = result.opml.body[0].outline; @@ -109,7 +109,7 @@ test('generateOpml sorts case-insensitively and falls back to the feed URL', asy test('generateOpml lists a subscribed feed that was never pinged', async() => { const { core, generateOpml } = setup(); - await core.store.putSubscriptions('https://new.example.com/feed.xml', [makeSubscription()]); + await core.seedSubscriptions('https://new.example.com/feed.xml', [makeSubscription()]); const result = await parseOpml(await generateOpml()); const outlines = result.opml.body[0].outline; diff --git a/apps/server/services/remove-expired-subscriptions.test.js b/apps/server/services/remove-expired-subscriptions.test.js index fdb2593..b8524d0 100644 --- a/apps/server/services/remove-expired-subscriptions.test.js +++ b/apps/server/services/remove-expired-subscriptions.test.js @@ -55,13 +55,13 @@ function makeSubscription(overrides = {}) { } async function entryFor(core, feedUrl) { - return (await core.store.list()).find(e => e.feedUrl === feedUrl); + return (await core.listFeeds()).find(e => e.feedUrl === feedUrl); } test('removes an expired subscription and prunes the now-empty feed', async() => { const { core, removeExpiredSubscriptions } = setup(); const feed = 'https://a.example.com/feed.xml'; - await core.store.putSubscriptions(feed, [makeSubscription({ whenExpires: expired() })]); + await core.seedSubscriptions(feed, [makeSubscription({ whenExpires: expired() })]); const result = await removeExpiredSubscriptions(); @@ -72,8 +72,8 @@ test('removes an expired subscription and prunes the now-empty feed', async() => test('clears an expired subscription but retains a recently-updated feed', async() => { const { core, removeExpiredSubscriptions } = setup(); const feed = 'https://b.example.com/feed.xml'; - await core.store.putResource(feed, makeResource(feed, { whenLastUpdate: withinWindow() })); - await core.store.putSubscriptions(feed, [makeSubscription({ whenExpires: expired() })]); + await core.seedResource(feed, makeResource(feed, { whenLastUpdate: withinWindow() })); + await core.seedSubscriptions(feed, [makeSubscription({ whenExpires: expired() })]); const result = await removeExpiredSubscriptions(); @@ -86,8 +86,8 @@ test('clears an expired subscription but retains a recently-updated feed', async test('removes a feed whose resource is older than the retention window', async() => { const { core, removeExpiredSubscriptions } = setup(); const feed = 'https://c.example.com/feed.xml'; - await core.store.putResource(feed, makeResource(feed, { whenLastUpdate: beyondWindow() })); - await core.store.putSubscriptions(feed, [makeSubscription({ whenExpires: expired() })]); + await core.seedResource(feed, makeResource(feed, { whenLastUpdate: beyondWindow() })); + await core.seedSubscriptions(feed, [makeSubscription({ whenExpires: expired() })]); await removeExpiredSubscriptions(); @@ -97,8 +97,8 @@ test('removes a feed whose resource is older than the retention window', async() test('leaves active subscriptions untouched', async() => { const { core, removeExpiredSubscriptions } = setup(); const feed = 'https://d.example.com/feed.xml'; - await core.store.putResource(feed, makeResource(feed, { whenLastUpdate: withinWindow() })); - await core.store.putSubscriptions(feed, [makeSubscription({ whenExpires: active() })]); + await core.seedResource(feed, makeResource(feed, { whenLastUpdate: withinWindow() })); + await core.seedSubscriptions(feed, [makeSubscription({ whenExpires: active() })]); const result = await removeExpiredSubscriptions(); @@ -111,7 +111,7 @@ test('leaves active subscriptions untouched', async() => { test('removes an orphaned resource with no subscriptions', async() => { const { core, removeExpiredSubscriptions } = setup(); const feed = 'https://e.example.com/feed.xml'; - await core.store.putResource(feed, makeResource(feed, { whenLastUpdate: beyondWindow() })); + await core.seedResource(feed, makeResource(feed, { whenLastUpdate: beyondWindow() })); await removeExpiredSubscriptions(); @@ -121,7 +121,7 @@ test('removes an orphaned resource with no subscriptions', async() => { test('returns the core MaintenanceResult shape', async() => { const { core, removeExpiredSubscriptions } = setup(); const feed = 'https://f.example.com/feed.xml'; - await core.store.putSubscriptions(feed, [makeSubscription({ whenExpires: expired() })]); + await core.seedSubscriptions(feed, [makeSubscription({ whenExpires: expired() })]); const result = await removeExpiredSubscriptions(); @@ -136,8 +136,8 @@ test('returns the core MaintenanceResult shape', async() => { test('removes a subscription that has reached the consecutive-error limit', async() => { const { core, removeExpiredSubscriptions } = setup(); const feed = 'https://g.example.com/feed.xml'; - await core.store.putResource(feed, makeResource(feed, { whenLastUpdate: withinWindow() })); - await core.store.putSubscriptions(feed, [ + await core.seedResource(feed, makeResource(feed, { whenLastUpdate: withinWindow() })); + await core.seedSubscriptions(feed, [ makeSubscription({ whenExpires: active(), ctConsecutiveErrors: coreConfig.maxConsecutiveErrors diff --git a/apps/server/services/stats.test.js b/apps/server/services/stats.test.js index a22ae4c..ea32f00 100644 --- a/apps/server/services/stats.test.js +++ b/apps/server/services/stats.test.js @@ -96,21 +96,21 @@ test('generateStats aggregates active subscriptions into the legacy shape', asyn const future = new Date(Date.now() + DAY_MS); const past = new Date(Date.now() - DAY_MS); - await core.store.putResource('https://a.example.com/feed.xml', makeResource('https://a.example.com/feed.xml', { + await core.seedResource('https://a.example.com/feed.xml', makeResource('https://a.example.com/feed.xml', { title: 'Alpha', whenLastUpdate: recent })); - await core.store.putSubscriptions('https://a.example.com/feed.xml', [ + await core.seedSubscriptions('https://a.example.com/feed.xml', [ makeSubscription({ url: 'http://sub1.example.com/notify', whenExpires: future }), makeSubscription({ url: 'http://sub2.example.com/notify', whenExpires: future }), makeSubscription({ url: 'http://gone.example.com/notify', whenExpires: past }) ]); - await core.store.putResource('https://b.example.com/feed.xml', makeResource('https://b.example.com/feed.xml', { + await core.seedResource('https://b.example.com/feed.xml', makeResource('https://b.example.com/feed.xml', { title: 'Bravo', whenLastUpdate: recent })); - await core.store.putSubscriptions('https://b.example.com/feed.xml', [ + await core.seedSubscriptions('https://b.example.com/feed.xml', [ makeSubscription({ url: 'http://sub1.example.com/notify', whenExpires: future }) ]); @@ -148,11 +148,11 @@ test('generateStats omits feeds whose subscriptions have all expired', async() = const { core, generateStats } = setup(); const past = new Date(Date.now() - DAY_MS); - await core.store.putResource('https://stale.example.com/feed.xml', makeResource('https://stale.example.com/feed.xml', { + await core.seedResource('https://stale.example.com/feed.xml', makeResource('https://stale.example.com/feed.xml', { title: 'Stale', whenLastUpdate: new Date(Date.now() - DAY_MS) })); - await core.store.putSubscriptions('https://stale.example.com/feed.xml', [ + await core.seedSubscriptions('https://stale.example.com/feed.xml', [ makeSubscription({ url: 'http://gone.example.com/notify', whenExpires: past }) ]); diff --git a/packages/core/src/engine/core.ts b/packages/core/src/engine/core.ts index fe46816..d89a149 100644 --- a/packages/core/src/engine/core.ts +++ b/packages/core/src/engine/core.ts @@ -11,7 +11,9 @@ import type { EventBus } from '../events.js'; import type { FeedParser } from '../feed/feed.js'; import type { ProtocolPlugin } from './plugin.js'; import type { MaintenanceResult, Stats } from './stats.js'; -import type { Store } from '../store/store.js'; +import type { Resource } from './resource.js'; +import type { Subscription } from './subscription.js'; +import type { FeedEntry, Store } from '../store/store.js'; /** * Everything core needs, assembled by the host's composition root. The shared @@ -60,10 +62,28 @@ export interface RssCloudCore { readonly events: EventBus; /** - * The persistence store, ready to use. When constructed from a - * `Promise`, this facade defers each call until the load resolves. + * Read-only snapshot of every tracked feed — the host's seam for the + * raw-data views (`/subscriptions.json`, OPML export) without reaching into + * the store. Concentrates all state access in core; the {@link Store} stays + * private to the engine. */ - readonly store: Store; + listFeeds(): Promise; + + /** + * Seed a feed's resource state directly. The narrow write seam the host's + * test API drives to stage fixtures; production paths reach state only + * through {@link subscribe}/{@link ping}. + */ + seedResource(feedUrl: string, resource: Resource): Promise; + + /** Seed a feed's subscriber list directly (see {@link seedResource}). */ + seedSubscriptions( + feedUrl: string, + subscriptions: Subscription[] + ): Promise; + + /** Drop every tracked feed — the test API's reset between fixtures. */ + clearFeeds(): Promise; /** * Await async store construction and tear the store down (flush + close). diff --git a/packages/core/src/engine/create-core.test.ts b/packages/core/src/engine/create-core.test.ts index 7a93771..8e56610 100644 --- a/packages/core/src/engine/create-core.test.ts +++ b/packages/core/src/engine/create-core.test.ts @@ -1052,22 +1052,6 @@ describe('createRssCloudCore async store construction', () => { expect((await store.getResource(FEED))?.ctChecks).toBe(1); }); - it('exposes core.store as a Store backed by the resolved store', async () => { - const inner = createInMemoryStore(); - - const core = createRssCloudCore({ - store: Promise.resolve(inner), - plugins: [], - config: resolveConfig(), - fetch: fetchReturning(RSS) - }); - - await core.ping({ resourceUrl: FEED }); - - const entries = await core.store.list(); - expect(entries.map(e => e.feedUrl)).toContain(FEED); - }); - it('close() awaits construction and closes a store that can close', async () => { const close = vi.fn(async () => undefined); const inner = { ...createInMemoryStore(), close }; @@ -1093,3 +1077,65 @@ describe('createRssCloudCore async store construction', () => { await expect(core.close()).resolves.toBeUndefined(); }); }); + +describe('createRssCloudCore listFeeds', () => { + it('returns a snapshot of every tracked feed', async () => { + const inner = createInMemoryStore(); + await inner.putResource(FEED, resource()); + await inner.putSubscriptions(FEED, [subscription()]); + + const core = createRssCloudCore({ + store: inner, + plugins: [], + config: resolveConfig() + }); + + const feeds = await core.listFeeds(); + + expect(feeds).toEqual([ + { feedUrl: FEED, resource: resource(), subscriptions: [subscription()] } + ]); + }); +}); + +describe('createRssCloudCore feed seeding', () => { + function freshCore() { + return createRssCloudCore({ + store: createInMemoryStore(), + plugins: [], + config: resolveConfig() + }); + } + + it('seedResource persists a resource that listFeeds reflects', async () => { + const core = freshCore(); + + await core.seedResource(FEED, resource()); + + expect(await core.listFeeds()).toEqual([ + { feedUrl: FEED, resource: resource(), subscriptions: [] } + ]); + }); + + it('seedSubscriptions persists subscriptions that listFeeds reflects', async () => { + const core = freshCore(); + + await core.seedSubscriptions(FEED, [subscription()]); + + expect(await core.listFeeds()).toEqual([ + { feedUrl: FEED, resource: null, subscriptions: [subscription()] } + ]); + }); + + it('clearFeeds removes every tracked feed', async () => { + const core = freshCore(); + await core.seedResource(FEED, resource()); + await core.seedSubscriptions('https://other.example/rss', [ + subscription() + ]); + + await core.clearFeeds(); + + expect(await core.listFeeds()).toEqual([]); + }); +}); diff --git a/packages/core/src/engine/create-core.ts b/packages/core/src/engine/create-core.ts index 01c1d2c..090f704 100644 --- a/packages/core/src/engine/create-core.ts +++ b/packages/core/src/engine/create-core.ts @@ -16,7 +16,7 @@ import type { Protocol } from './protocol.js'; import type { Resource } from './resource.js'; import type { FeedStat, MaintenanceResult, Stats } from './stats.js'; import type { Subscription } from './subscription.js'; -import type { Store } from '../store/store.js'; +import type { FeedEntry, Store } from '../store/store.js'; import type { RssCloudCore, RssCloudCoreOptions @@ -572,12 +572,39 @@ export function createRssCloudCore( } } + function listFeeds(): Promise { + return store.list(); + } + + function seedResource( + feedUrl: string, + resource: Resource + ): Promise { + return store.putResource(feedUrl, resource); + } + + function seedSubscriptions( + feedUrl: string, + subscriptions: Subscription[] + ): Promise { + return store.putSubscriptions(feedUrl, subscriptions); + } + + async function clearFeeds(): Promise { + for (const { feedUrl } of await store.list()) { + await store.remove(feedUrl); + } + } + return { subscribe, unsubscribe, ping, events, - store, + listFeeds, + seedResource, + seedSubscriptions, + clearFeeds, close, removeExpired, generateStats From 1a7912fa887517f89ce86c37530d787ad10ad048 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Fri, 12 Jun 2026 20:13:23 -0500 Subject: [PATCH 57/90] refactor(server): build controllers via a createControllers({ core }) factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Importing `controllers/index.js` ran `require('../core')` at module load, constructing a real FileStore (disk I/O) just to load the module — no controller could be exercised without booting persistence. - `createControllers({ core })` factory returns the router with core injected, mirroring the service factories. `app.js` passes the prod core; a test could pass an in-memory core. Importing the module no longer touches the store. - `controllers/test.js` becomes `createTestController({ core })` — also no module-load core import. - Collapse the three identical Accept→render/406 shells (home, ping-form, please-notify-form) into a table-driven mount; delete those files. - Extract `services/markdown-doc.js` (renderMarkdownDoc) and use it for both /docs and /LICENSE.md, dropping the inlined LICENSE duplication. Unit-tested directly (H1-strip + missing-file), no HTTP, no mocks. - Fold the /stats render + /stats.json routes together; delete stats.js. Behaviour-preserving: HTTP coverage stays with the e2e suite (134 green); server unit tests 21 green; lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 35 ++-- apps/server/app.js | 3 +- apps/server/controllers/docs.js | 33 --- apps/server/controllers/home.js | 15 -- apps/server/controllers/index.js | 147 ++++++++----- apps/server/controllers/ping-form.js | 15 -- apps/server/controllers/please-notify-form.js | 15 -- apps/server/controllers/stats.js | 12 -- apps/server/controllers/test.js | 194 ++++++++++-------- apps/server/services/markdown-doc.js | 15 ++ apps/server/services/markdown-doc.test.js | 36 ++++ 11 files changed, 267 insertions(+), 253 deletions(-) delete mode 100644 apps/server/controllers/docs.js delete mode 100644 apps/server/controllers/home.js delete mode 100644 apps/server/controllers/ping-form.js delete mode 100644 apps/server/controllers/please-notify-form.js delete mode 100644 apps/server/controllers/stats.js create mode 100644 apps/server/services/markdown-doc.js create mode 100644 apps/server/services/markdown-doc.test.js diff --git a/TODO.md b/TODO.md index 59d4d99..78f0ec0 100644 --- a/TODO.md +++ b/TODO.md @@ -16,24 +16,19 @@ place behaviour can be swapped without editing in place; **leakage** is one module's internals crossing a seam into another. File/line refs will drift — trust the names over the numbers. -> The keystone — sealing the `core.store` port — is **done**: `RssCloudCore` -> now exposes a narrow `listFeeds()` read seam plus `seedResource()` / -> `seedSubscriptions()` / `clearFeeds()` for the test API, and `readonly store` -> is gone from the interface. All state access is concentrated in core. History -> in git (`refactor(core):` / `refactor(server):`). This unblocks #1 below. - -### 1. Open a test seam at the HTTP edge - -Controllers `require('../core')` at module load, so importing any one boots a -real `FileStore` — no controller has a test. Four (`home`, `ping-form`, -`please-notify-form`, `docs`) are near-identical `res.render` shells, and the -`/LICENSE.md` route re-inlines what `docs.js` already does. - -*Fix:* a `createControllers({ core })` factory mirroring the testable services -(`feeds-opml`, `stats`, etc.), plus a table-driven mount for the render-only -routes. Two adapters justify the seam: prod core and an in-memory core in tests. - -### 2. Lift the maintenance jobs out of `create-core.ts` +> **Done (history in git):** +> - **Sealed the `core.store` port** — `RssCloudCore` exposes a narrow +> `listFeeds()` read seam plus `seedResource()` / `seedSubscriptions()` / +> `clearFeeds()` for the test API; `readonly store` is gone from the +> interface. All state access is concentrated in core. +> - **Opened the HTTP-edge seam** — controllers are a +> `createControllers({ core })` factory, so importing them no longer boots a +> `FileStore`. The near-identical `res.render` shells (`home`, `ping-form`, +> `please-notify-form`) collapsed into a table-driven mount, and `/docs` + +> `/LICENSE.md` share one `renderMarkdownDoc` service. HTTP behaviour stays +> covered by the e2e suite (no new HTTP-level unit tests, by decision). + +### 1. Lift the maintenance jobs out of `create-core.ts` `removeExpired` and `generateStats` (~130 lines inside the 585-line factory) are read-only jobs needing only `store` + a clock, but are exercisable only by @@ -42,7 +37,7 @@ building a full core with fetch + plugin mocks they never use. *Fix:* extract as functions over `(store, config, now)`; core delegates. Narrows the test surface; shrinks the factory. (Coverage stays 100% per CLAUDE.md.) -### 3. One `fetchWithTimeout`, not three copies +### 2. One `fetchWithTimeout`, not three copies The abort-controller + `clearTimeout` pattern is written verbatim in `engine/create-core.ts`, `protocols/rest-plugin.ts`, and @@ -51,7 +46,7 @@ The abort-controller + `clearTimeout` pattern is written verbatim in *Fix:* a shared `fetchWithTimeout(doFetch, ms, url, init)` core util. A bug in the abort dance then has one place to live, and one place to test. -### 4. `feedsChangedLast7Days` label can silently lie +### 3. `feedsChangedLast7Days` label can silently lie The window is a config value upstream (`feedsChangedWindowDays`) but a baked-in literal `7` downstream: the wire field name in `services/stats.js` diff --git a/apps/server/app.js b/apps/server/app.js index efe592a..eb62530 100644 --- a/apps/server/app.js +++ b/apps/server/app.js @@ -10,6 +10,7 @@ const config = require('./config'), createRemoveExpiredSubscriptions = require('./services/remove-expired-subscriptions'), websocket = require('./services/websocket'), { core, events: coreEvents } = require('./core'), + { createControllers } = require('./controllers'), bridgeCoreEvents = require('./services/core-event-bridge'); const stats = createStats({ core }); @@ -93,7 +94,7 @@ app.use( ); // Load controllers (includes the core-backed /ping + /pleaseNotify front doors) -app.use(require('./controllers')); +app.use(createControllers({ core })); async function gracefulShutdown() { await core.close(); diff --git a/apps/server/controllers/docs.js b/apps/server/controllers/docs.js deleted file mode 100644 index 346d62a..0000000 --- a/apps/server/controllers/docs.js +++ /dev/null @@ -1,33 +0,0 @@ -const express = require('express'), - router = new express.Router(), - md = require('markdown-it')(), - fs = require('fs'); - -router.get('/', (req, res) => { - switch (req.accepts('html')) { - case 'html': { - try { - // README keeps its own "# rssCloud Server" heading for GitHub, - // but the docs page uses a consistent "Documentation" header, - // so drop the leading H1 from the rendered output. - const htmltext = md - .render(fs.readFileSync('README.md', { encoding: 'utf8' })) - .replace(/]*>[\s\S]*?<\/h1>\s*/i, ''); - res.render('docs', { - title: 'rssCloud Server: Documentation', - heading: 'rssCloud Server: Documentation', - htmltext - }); - } catch (err) { - console.error('Error reading README.md:', err.message); - res.status(500).send('Internal Server Error'); - } - break; - } - default: - res.status(406).send('Not Acceptable'); - break; - } -}); - -module.exports = router; diff --git a/apps/server/controllers/home.js b/apps/server/controllers/home.js deleted file mode 100644 index eba138c..0000000 --- a/apps/server/controllers/home.js +++ /dev/null @@ -1,15 +0,0 @@ -const express = require('express'), - router = new express.Router(); - -router.get('/', function(req, res) { - switch (req.accepts('html')) { - case 'html': - res.render('home'); - break; - default: - res.status(406).send('Not Acceptable'); - break; - } -}); - -module.exports = router; diff --git a/apps/server/controllers/index.js b/apps/server/controllers/index.js index 988b24b..d42b04b 100644 --- a/apps/server/controllers/index.js +++ b/apps/server/controllers/index.js @@ -1,73 +1,116 @@ const express = require('express'), - fs = require('fs'), - md = require('markdown-it')(), { createFeedsOpml } = require('../services/feeds-opml'), { createStats } = require('../services/stats'), { toFeedsJson } = require('../services/feeds-json'), + { renderMarkdownDoc } = require('../services/markdown-doc'), { ping, pleaseNotify, rpc2 } = require('@rsscloud/express'), - { core } = require('../core'), - { generateOpml } = createFeedsOpml({ core }), - { getStats } = createStats({ core }), - router = new express.Router(); + { createTestController } = require('./test'); -// Core-backed protocol front doors (@rsscloud/express driving @rsscloud/core). -// POST-bound (the package delegates method-binding to the consumer) so a GET to -// any of these paths still falls through to a 404, matching the legacy routers. -// /RPC2 handles rssCloud.hello/pleaseNotify/ping; the dispatcher never throws, -// faulting in-response on malformed or unknown calls. -router.post('/ping', ping({ core })); -router.post('/pleaseNotify', pleaseNotify({ core })); -router.post('/RPC2', rpc2({ core })); +// Render-only pages — identical Accept→render/406 shells, mounted from a table +// instead of one near-duplicate router file each. +const NEGOTIATED_VIEWS = [ + { path: '/', view: 'home' }, + { path: '/pingForm', view: 'ping-form' }, + { path: '/pleaseNotifyForm', view: 'please-notify-form' } +]; -router.use('/', require('./home')); -router.use('/docs', require('./docs')); - -router.get('/LICENSE.md', (req, res) => { +// Render a Markdown file into the shared `docs` view, mapping a read failure to +// a 500. The README/LICENSE routes differ only in source file, heading, and +// whether the redundant leading H1 is dropped. +function sendMarkdownDoc(res, { file, label, stripH1 }) { try { - const htmltext = md.render( - fs.readFileSync('LICENSE.md', { encoding: 'utf8' }) - ); res.render('docs', { - title: 'rssCloud Server: License', - heading: 'rssCloud Server: License', - htmltext + title: `rssCloud Server: ${label}`, + heading: `rssCloud Server: ${label}`, + htmltext: renderMarkdownDoc(file, { stripH1 }) }); } catch (err) { - console.error('Error reading LICENSE.md:', err.message); + console.error(`Error reading ${file}:`, err.message); res.status(500).send('Internal Server Error'); } -}); -router.use('/pleaseNotifyForm', require('./please-notify-form')); -router.use('/pingForm', require('./ping-form')); -router.use('/viewLog', require('./view-log')); -router.use('/stats', require('./stats')); +} -router.get('/stats.json', (req, res) => { - res.set('Content-Type', 'application/json'); - res.send(JSON.stringify(getStats(), null, 2)); -}); +// Build the server's router over an injected core (prod file-backed core, or an +// in-memory core in tests) — importing this module no longer boots a store. +function createControllers({ core }) { + const router = new express.Router(), + { generateOpml } = createFeedsOpml({ core }), + { getStats } = createStats({ core }); -router.get('/subscriptions.json', async(req, res, next) => { - try { - const feeds = toFeedsJson(await core.listFeeds()); - res.set('Content-Type', 'application/json'); - res.send(JSON.stringify({ version: 2, feeds }, null, 2)); - } catch (err) { - next(err); + // Core-backed protocol front doors (@rsscloud/express driving @rsscloud/core). + // POST-bound (the package delegates method-binding to the consumer) so a GET + // to any of these paths still falls through to a 404, matching the legacy + // routers. /RPC2 handles rssCloud.hello/pleaseNotify/ping; the dispatcher + // never throws, faulting in-response on malformed or unknown calls. + router.post('/ping', ping({ core })); + router.post('/pleaseNotify', pleaseNotify({ core })); + router.post('/RPC2', rpc2({ core })); + + for (const { path, view } of NEGOTIATED_VIEWS) { + router.get(path, (req, res) => { + if (req.accepts('html') === 'html') { + res.render(view); + } else { + res.status(406).send('Not Acceptable'); + } + }); } -}); -router.get('/feeds.opml', async(req, res, next) => { - try { - res.set('Content-Type', 'text/x-opml; charset=utf-8'); - res.send(await generateOpml()); - } catch (err) { - next(err); + router.get('/docs', (req, res) => { + if (req.accepts('html') !== 'html') { + res.status(406).send('Not Acceptable'); + return; + } + sendMarkdownDoc(res, { + file: 'README.md', + label: 'Documentation', + stripH1: true + }); + }); + + router.get('/LICENSE.md', (req, res) => { + sendMarkdownDoc(res, { + file: 'LICENSE.md', + label: 'License', + stripH1: false + }); + }); + + router.use('/viewLog', require('./view-log')); + + router.get('/stats', (req, res) => { + res.render('stats', getStats()); + }); + + router.get('/stats.json', (req, res) => { + res.set('Content-Type', 'application/json'); + res.send(JSON.stringify(getStats(), null, 2)); + }); + + router.get('/subscriptions.json', async(req, res, next) => { + try { + const feeds = toFeedsJson(await core.listFeeds()); + res.set('Content-Type', 'application/json'); + res.send(JSON.stringify({ version: 2, feeds }, null, 2)); + } catch (err) { + next(err); + } + }); + + router.get('/feeds.opml', async(req, res, next) => { + try { + res.set('Content-Type', 'text/x-opml; charset=utf-8'); + res.send(await generateOpml()); + } catch (err) { + next(err); + } + }); + + if (process.env.ENABLE_TEST_API === 'true') { + router.use('/test', createTestController({ core })); } -}); -if (process.env.ENABLE_TEST_API === 'true') { - router.use('/test', require('./test')); + return router; } -module.exports = router; +module.exports = { createControllers }; diff --git a/apps/server/controllers/ping-form.js b/apps/server/controllers/ping-form.js deleted file mode 100644 index 5e5397c..0000000 --- a/apps/server/controllers/ping-form.js +++ /dev/null @@ -1,15 +0,0 @@ -const express = require('express'), - router = new express.Router(); - -router.get('/', function(req, res) { - switch (req.accepts('html')) { - case 'html': - res.render('ping-form'); - break; - default: - res.status(406).send('Not Acceptable'); - break; - } -}); - -module.exports = router; diff --git a/apps/server/controllers/please-notify-form.js b/apps/server/controllers/please-notify-form.js deleted file mode 100644 index 657ceb3..0000000 --- a/apps/server/controllers/please-notify-form.js +++ /dev/null @@ -1,15 +0,0 @@ -const express = require('express'), - router = new express.Router(); - -router.get('/', function(req, res) { - switch (req.accepts('html')) { - case 'html': - res.render('please-notify-form'); - break; - default: - res.status(406).send('Not Acceptable'); - break; - } -}); - -module.exports = router; diff --git a/apps/server/controllers/stats.js b/apps/server/controllers/stats.js deleted file mode 100644 index 412b61d..0000000 --- a/apps/server/controllers/stats.js +++ /dev/null @@ -1,12 +0,0 @@ -const express = require('express'), - { createStats } = require('../services/stats'), - { core } = require('../core'), - { getStats } = createStats({ core }), - router = new express.Router(); - -router.get('/', function(req, res) { - const stats = getStats(); - res.render('stats', stats); -}); - -module.exports = router; diff --git a/apps/server/controllers/test.js b/apps/server/controllers/test.js index 127f5be..5084f44 100644 --- a/apps/server/controllers/test.js +++ b/apps/server/controllers/test.js @@ -1,5 +1,4 @@ const express = require('express'), - { core } = require('../core'), { resourceToJson, resourceFromJson, @@ -7,26 +6,10 @@ const express = require('express'), subscriptionFromJson } = require('@rsscloud/core'), { toFeedsJson } = require('../services/feeds-json'), - createRemoveExpiredSubscriptions = require('../services/remove-expired-subscriptions'), - router = new express.Router(); - -const removeExpiredSubscriptions = createRemoveExpiredSubscriptions({ core }); - -console.warn( - '[test-api] ENABLE_TEST_API=true — /test/* endpoints are mounted. Never enable in production.' -); - -router.use(express.json()); + createRemoveExpiredSubscriptions = require('../services/remove-expired-subscriptions'); const EPOCH_ISO = new Date(0).toISOString(); -// The /test/* API drives core's narrow seed/snapshot seam — it never reaches -// into the store. Reads find the feed in the listFeeds() snapshot; writes go -// through seedResource/seedSubscriptions/clearFeeds. -async function findEntry(feedUrl) { - return (await core.listFeeds()).find(entry => entry.feedUrl === feedUrl); -} - // The /test/* API speaks the core model's JSON shape (JsonResource / // JsonSubscription). setResource stays lenient — the harness sends partial // fixtures — filling core defaults and the feed URL before deserializing. @@ -43,84 +26,115 @@ function resourceFromInput(feedUrl, raw) { }); } -router.post('/clear', async(req, res) => { - try { - await core.clearFeeds(); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } -}); +// Built with an injected core (prod file-backed or in-memory in tests). The +// /test/* API drives core's narrow seed/snapshot seam — it never reaches into +// the store. Reads find the feed in the listFeeds() snapshot; writes go through +// seedResource/seedSubscriptions/clearFeeds. +function createTestController({ core }) { + const router = new express.Router(); + const removeExpiredSubscriptions = createRemoveExpiredSubscriptions({ + core + }); -router.post('/setResource', async(req, res) => { - try { - const { feedUrl, resource } = req.body; - await core.seedResource(feedUrl, resourceFromInput(feedUrl, resource)); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } -}); + console.warn( + '[test-api] ENABLE_TEST_API=true — /test/* endpoints are mounted. Never enable in production.' + ); -router.post('/getResource', async(req, res) => { - try { - const { feedUrl } = req.body; - const entry = await findEntry(feedUrl); - const resource = entry?.resource ?? null; - res.json({ - success: true, - found: resource !== null, - resource: resource !== null ? resourceToJson(resource) : null - }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } -}); + router.use(express.json()); -router.post('/setSubscriptions', async(req, res) => { - try { - const { feedUrl, subscriptions } = req.body; - await core.seedSubscriptions( - feedUrl, - subscriptions.map(subscriptionFromJson) + async function findEntry(feedUrl) { + return (await core.listFeeds()).find( + entry => entry.feedUrl === feedUrl ); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); } -}); -router.post('/getSubscriptions', async(req, res) => { - try { - const { feedUrl } = req.body; - const entry = await findEntry(feedUrl); - res.json({ - success: true, - found: entry !== undefined, - subscriptions: entry - ? entry.subscriptions.map(subscriptionToJson) - : [] - }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } -}); + router.post('/clear', async(req, res) => { + try { + await core.clearFeeds(); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); -router.post('/getData', async(req, res) => { - try { - res.json({ success: true, data: toFeedsJson(await core.listFeeds()) }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } -}); + router.post('/setResource', async(req, res) => { + try { + const { feedUrl, resource } = req.body; + await core.seedResource( + feedUrl, + resourceFromInput(feedUrl, resource) + ); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); -router.post('/removeExpired', async(req, res) => { - try { - const result = await removeExpiredSubscriptions(); - res.json({ success: true, result }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } -}); + router.post('/getResource', async(req, res) => { + try { + const { feedUrl } = req.body; + const entry = await findEntry(feedUrl); + const resource = entry?.resource ?? null; + res.json({ + success: true, + found: resource !== null, + resource: resource !== null ? resourceToJson(resource) : null + }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + router.post('/setSubscriptions', async(req, res) => { + try { + const { feedUrl, subscriptions } = req.body; + await core.seedSubscriptions( + feedUrl, + subscriptions.map(subscriptionFromJson) + ); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + router.post('/getSubscriptions', async(req, res) => { + try { + const { feedUrl } = req.body; + const entry = await findEntry(feedUrl); + res.json({ + success: true, + found: entry !== undefined, + subscriptions: entry + ? entry.subscriptions.map(subscriptionToJson) + : [] + }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + router.post('/getData', async(req, res) => { + try { + res.json({ + success: true, + data: toFeedsJson(await core.listFeeds()) + }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + router.post('/removeExpired', async(req, res) => { + try { + const result = await removeExpiredSubscriptions(); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + return router; +} -module.exports = router; +module.exports = { createTestController }; diff --git a/apps/server/services/markdown-doc.js b/apps/server/services/markdown-doc.js new file mode 100644 index 0000000..ba799c2 --- /dev/null +++ b/apps/server/services/markdown-doc.js @@ -0,0 +1,15 @@ +const fs = require('fs'); +const md = require('markdown-it')(); + +// Render a Markdown file to HTML for the shared `docs` view. `stripH1` drops a +// leading

— the README keeps its own "# rssCloud Server" title for GitHub, +// but the docs page supplies its own heading, so the rendered H1 is redundant. +// Throws if the file can't be read; the caller maps that to a 500. +function renderMarkdownDoc(filePath, { stripH1 = false } = {}) { + const html = md.render(fs.readFileSync(filePath, { encoding: 'utf8' })); + return stripH1 + ? html.replace(/]*>[\s\S]*?<\/h1>\s*/i, '') + : html; +} + +module.exports = { renderMarkdownDoc }; diff --git a/apps/server/services/markdown-doc.test.js b/apps/server/services/markdown-doc.test.js new file mode 100644 index 0000000..265d20c --- /dev/null +++ b/apps/server/services/markdown-doc.test.js @@ -0,0 +1,36 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { renderMarkdownDoc } = require('./markdown-doc'); + +function writeTemp(contents) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mddoc-')); + const file = path.join(dir, 'doc.md'); + fs.writeFileSync(file, contents); + return file; +} + +test('renders Markdown to HTML', () => { + const file = writeTemp('# Title\n\nHello **world**'); + const html = renderMarkdownDoc(file); + assert.match(html, /world<\/strong>/); +}); + +test('strips the leading H1 when stripH1 is set', () => { + const file = writeTemp('# Documentation\n\nBody text'); + const html = renderMarkdownDoc(file, { stripH1: true }); + assert.doesNotMatch(html, /

Body text<\/p>/); +}); + +test('retains the H1 by default', () => { + const file = writeTemp('# Documentation\n\nBody text'); + const html = renderMarkdownDoc(file); + assert.match(html, /]*>Documentation<\/h1>/); +}); + +test('throws when the file cannot be read', () => { + assert.throws(() => renderMarkdownDoc('/no/such/file.md')); +}); From bd2d194dceb371ab199baad43d41057f1800b5f8 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 09:43:32 -0500 Subject: [PATCH 58/90] refactor(core): extract maintenance jobs from the core factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removeExpired and generateStats are housekeeping jobs needing only a store, config, and a clock — but they lived inside createRssCloudCore, so exercising them meant building a full core with fetch + plugin mocks they never use. Lift both into a standalone engine/maintenance module as functions over (store, config, now); the factory now delegates. Shrinks create-core.ts by ~143 lines and narrows the test surface: the maintenance suite calls the functions directly against an in-memory store, with a single core-level smoke test guarding each delegation. Coverage stays at 100%. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/engine/create-core.test.ts | 267 +---------------- packages/core/src/engine/create-core.ts | 149 +--------- packages/core/src/engine/maintenance.test.ts | 296 +++++++++++++++++++ packages/core/src/engine/maintenance.ts | 157 ++++++++++ 4 files changed, 468 insertions(+), 401 deletions(-) create mode 100644 packages/core/src/engine/maintenance.test.ts create mode 100644 packages/core/src/engine/maintenance.ts diff --git a/packages/core/src/engine/create-core.test.ts b/packages/core/src/engine/create-core.test.ts index 8e56610..25a60c8 100644 --- a/packages/core/src/engine/create-core.test.ts +++ b/packages/core/src/engine/create-core.test.ts @@ -766,271 +766,22 @@ describe('createRssCloudCore initialization', () => { const stats = await core.generateStats(); expect(stats.feedsWithSubscribers).toBe(0); }); -}); - -const NOW = new Date('2026-06-01T00:00:00Z'); -const RECENT = new Date('2026-05-30T00:00:00Z'); -const OLD = new Date('2026-01-01T00:00:00Z'); -const FUTURE = new Date('2099-01-01T00:00:00Z'); -const PAST = new Date('2000-01-01T00:00:00Z'); - -function maintenanceCore(store: Store, at: Date = NOW) { - return createRssCloudCore({ - store, - plugins: [], - config: resolveConfig(), - fetch: fetchReturning(''), - now: () => at - }); -} - -describe('createRssCloudCore generateStats', () => { - it('returns an empty snapshot for an empty store', async () => { - const stats = await maintenanceCore(createInMemoryStore()).generateStats(); - - expect(stats).toMatchObject({ - feedsChangedLastWindow: 0, - feedsWithSubscribers: 0, - uniqueAggregators: 0, - totalActiveSubscriptions: 0, - topFeeds: [], - moreFeeds: [], - protocolBreakdown: {} - }); - expect(stats.generatedAt).toBe('2026-06-01T00:00:00.000Z'); - }); - - it('computes an activity snapshot across feed states', async () => { - const store = createInMemoryStore(); - - await store.putResource( - 'https://a.example/rss', - resource({ - whenLastUpdate: RECENT, - feed: { type: 'rss', title: 'Feed A' } - }) - ); - await store.putSubscriptions('https://a.example/rss', [ - subscription({ - url: 'https://host1.example/notify', - whenExpires: FUTURE - }), - subscription({ - url: 'https://host2.example/notify', - whenExpires: FUTURE - }) - ]); - - await store.putResource( - 'https://b.example/rss', - resource({ whenLastUpdate: new Date(0) }) - ); - await store.putSubscriptions('https://b.example/rss', [ - subscription({ - url: 'https://host3.example/notify', - protocol: 'https-post', - whenExpires: FUTURE - }) - ]); - - await store.putResource( - 'https://c.example/rss', - resource({ whenLastUpdate: OLD }) - ); - await store.putSubscriptions('https://c.example/rss', [ - subscription({ url: 'not a url', whenExpires: FUTURE }) - ]); - - await store.putSubscriptions('https://d.example/rss', [ - subscription({ - url: 'https://host4.example/notify', - whenExpires: FUTURE - }) - ]); - - await store.putResource( - 'https://e.example/rss', - resource({ whenLastUpdate: RECENT }) - ); - await store.putSubscriptions('https://e.example/rss', [ - subscription({ - url: 'https://host5.example/notify', - whenExpires: PAST - }) - ]); - - const stats = await maintenanceCore(store).generateStats(); - - expect(stats.feedsChangedLastWindow).toBe(2); - expect(stats.feedsWithSubscribers).toBe(4); - expect(stats.totalActiveSubscriptions).toBe(5); - expect(stats.uniqueAggregators).toBe(4); - expect(stats.protocolBreakdown).toEqual({ - 'http-post': 4, - 'https-post': 1 - }); - - const byUrl = Object.fromEntries( - [...stats.topFeeds, ...stats.moreFeeds].map(feed => [feed.url, feed]) - ); - expect(byUrl['https://a.example/rss']).toMatchObject({ - feedTitle: 'Feed A', - whenLastUpdate: '2026-05-30T00:00:00.000Z' - }); - expect(byUrl['https://b.example/rss']).toMatchObject({ - feedTitle: null, - whenLastUpdate: null - }); - expect(byUrl['https://c.example/rss']).toMatchObject({ - whenLastUpdate: '2026-01-01T00:00:00.000Z' - }); - expect(byUrl['https://d.example/rss']).toMatchObject({ - feedTitle: null, - whenLastUpdate: null - }); - }); - - it('caps the top feeds at a ten-deep cut, keeping boundary ties', async () => { - const store = createInMemoryStore(); - const manySubs = (prefix: string, count: number): Subscription[] => - Array.from({ length: count }, (_unused, i) => - subscription({ - url: `https://${prefix}-h${i}.example/notify`, - whenExpires: FUTURE - }) - ); - - for (let i = 0; i < 11; i++) { - await store.putSubscriptions( - `https://big${i}.example/rss`, - manySubs(`big${i}`, 5) - ); - } - for (let i = 0; i < 2; i++) { - await store.putSubscriptions( - `https://small${i}.example/rss`, - manySubs(`small${i}`, 1) - ); - } - - const stats = await maintenanceCore(store).generateStats(); - - expect(stats.feedsWithSubscribers).toBe(13); - expect(stats.topFeeds).toHaveLength(11); - expect(stats.moreFeeds).toHaveLength(2); - expect(stats.topFeeds.every(f => f.subscriberCount === 5)).toBe(true); - }); -}); - -describe('createRssCloudCore removeExpired', () => { - it('drops expired and error-exhausted subscriptions', async () => { - const store = createInMemoryStore(); - await store.putResource(FEED, resource({ whenLastUpdate: RECENT })); - await store.putSubscriptions(FEED, [ - subscription({ - url: 'https://valid.example/notify', - whenExpires: FUTURE - }), - subscription({ - url: 'https://expired.example/notify', - whenExpires: PAST - }), - subscription({ - url: 'https://exhausted.example/notify', - whenExpires: FUTURE, - ctConsecutiveErrors: 3 - }) - ]); - const result = await maintenanceCore(store).removeExpired(); - - expect(result.subscriptionsRemoved).toBe(2); - expect(result.feedsProcessed).toBe(1); - expect(result.feedsDeleted).toBe(0); - expect(result.orphanedResourcesRemoved).toBe(0); - const subs = await store.getSubscriptions(FEED); - expect(subs.map(s => s.url)).toEqual(['https://valid.example/notify']); - }); - - it('empties but retains a recently updated feed when all subs expire', async () => { + it('delegates removeExpired to the maintenance job', async () => { const store = createInMemoryStore(); - await store.putResource(FEED, resource({ whenLastUpdate: RECENT })); await store.putSubscriptions(FEED, [ - subscription({ whenExpires: PAST }) + subscription({ whenExpires: new Date('2000-01-01T00:00:00Z') }) ]); + const core = createRssCloudCore({ + store, + plugins: [], + config: resolveConfig(), + now: () => new Date('2026-06-01T00:00:00Z') + }); - const result = await maintenanceCore(store).removeExpired(); + const result = await core.removeExpired(); expect(result.subscriptionsRemoved).toBe(1); - expect(result.feedsDeleted).toBe(0); - expect(await store.list()).toHaveLength(1); - expect(await store.getSubscriptions(FEED)).toEqual([]); - }); - - it('deletes a stale feed when all subs expire', async () => { - const store = createInMemoryStore(); - await store.putResource(FEED, resource({ whenLastUpdate: OLD })); - await store.putSubscriptions(FEED, [ - subscription({ whenExpires: PAST }) - ]); - - const result = await maintenanceCore(store).removeExpired(); - - expect(result.feedsDeleted).toBe(1); - expect(await store.list()).toHaveLength(0); - }); - - it('leaves a healthy feed untouched', async () => { - const store = createInMemoryStore(); - await store.putResource(FEED, resource({ whenLastUpdate: RECENT })); - await store.putSubscriptions(FEED, [ - subscription({ whenExpires: FUTURE }) - ]); - - const result = await maintenanceCore(store).removeExpired(); - - expect(result.subscriptionsRemoved).toBe(0); - expect(result.feedsProcessed).toBe(1); - expect(await store.getSubscriptions(FEED)).toHaveLength(1); - }); - - it('removes an orphaned resource outside the retain window', async () => { - const store = createInMemoryStore(); - await store.putResource(FEED, resource({ whenLastUpdate: OLD })); - - const result = await maintenanceCore(store).removeExpired(); - - expect(result.orphanedResourcesRemoved).toBe(1); - expect(await store.list()).toHaveLength(0); - }); - - it('keeps an orphaned resource that was recently updated', async () => { - const store = createInMemoryStore(); - await store.putResource(FEED, resource({ whenLastUpdate: RECENT })); - - const result = await maintenanceCore(store).removeExpired(); - - expect(result.orphanedResourcesRemoved).toBe(0); - expect(await store.list()).toHaveLength(1); - }); - - it('removes an empty entry that has no resource', async () => { - const store = createInMemoryStore(); - await store.putSubscriptions(FEED, []); - - const result = await maintenanceCore(store).removeExpired(); - - expect(result.orphanedResourcesRemoved).toBe(1); - expect(await store.list()).toHaveLength(0); - }); - - it('removes an orphaned resource that was never updated', async () => { - const store = createInMemoryStore(); - await store.putResource(FEED, resource({ whenLastUpdate: new Date(0) })); - - const result = await maintenanceCore(store).removeExpired(); - - expect(result.orphanedResourcesRemoved).toBe(1); expect(await store.list()).toHaveLength(0); }); }); diff --git a/packages/core/src/engine/create-core.ts b/packages/core/src/engine/create-core.ts index 090f704..8bcf041 100644 --- a/packages/core/src/engine/create-core.ts +++ b/packages/core/src/engine/create-core.ts @@ -11,10 +11,13 @@ import type { import { RssCloudError } from '../errors.js'; import { createEventBus } from '../events.js'; import { createDefaultFeedParser } from '../feed/feed-parser.js'; +import { + generateStats as runGenerateStats, + removeExpired as runRemoveExpired +} from './maintenance.js'; import type { ResourcePayload, ProtocolPlugin } from './plugin.js'; import type { Protocol } from './protocol.js'; import type { Resource } from './resource.js'; -import type { FeedStat, MaintenanceResult, Stats } from './stats.js'; import type { Subscription } from './subscription.js'; import type { FeedEntry, Store } from '../store/store.js'; import type { @@ -425,146 +428,6 @@ export function createRssCloudCore( return { success: true, message: 'Unsubscribed.' }; } - function windowCutoff(from: Date): Date { - return new Date( - from.getTime() - config.feedsChangedWindowDays * 86400 * 1000 - ); - } - - async function removeExpired(): Promise { - const current = now(); - const cutoff = windowCutoff(current); - const entries = await store.list(); - - let subscriptionsRemoved = 0; - let feedsDeleted = 0; - let orphanedResourcesRemoved = 0; - - const recentlyUpdated = (resource: Resource | null): boolean => - resource !== null && - resource.whenLastUpdate.getTime() > 0 && - resource.whenLastUpdate >= cutoff; - - for (const entry of entries) { - if (entry.subscriptions.length === 0) { - if (recentlyUpdated(entry.resource)) { - continue; - } - await store.remove(entry.feedUrl); - orphanedResourcesRemoved += 1; - continue; - } - - const valid = entry.subscriptions.filter(subscription => { - const expired = - subscription.whenExpires.getTime() <= current.getTime(); - const exhausted = - subscription.ctConsecutiveErrors >= - config.maxConsecutiveErrors; - if (expired || exhausted) { - subscriptionsRemoved += 1; - return false; - } - return true; - }); - - if (valid.length === entry.subscriptions.length) { - continue; - } - - if (valid.length === 0) { - if (recentlyUpdated(entry.resource)) { - await store.putSubscriptions(entry.feedUrl, []); - } else { - await store.remove(entry.feedUrl); - feedsDeleted += 1; - } - } else { - await store.putSubscriptions(entry.feedUrl, valid); - } - } - - return { - subscriptionsRemoved, - feedsProcessed: entries.length, - feedsDeleted, - orphanedResourcesRemoved - }; - } - - async function generateStats(): Promise { - const current = now(); - const cutoff = windowCutoff(current); - const entries = await store.list(); - - let feedsChangedLastWindow = 0; - let totalActiveSubscriptions = 0; - const hostnames = new Set(); - const protocolBreakdown: Record = {}; - const feedStats: FeedStat[] = []; - - for (const entry of entries) { - const lastUpdate = entry.resource?.whenLastUpdate ?? null; - const hasRealUpdate = - lastUpdate !== null && lastUpdate.getTime() > 0; - if (hasRealUpdate && lastUpdate >= cutoff) { - feedsChangedLastWindow += 1; - } - - let activeCount = 0; - for (const subscription of entry.subscriptions) { - if (subscription.whenExpires.getTime() <= current.getTime()) { - continue; - } - activeCount += 1; - totalActiveSubscriptions += 1; - try { - hostnames.add(new URL(subscription.url).hostname); - } catch { - // ignore unparseable callback URLs - } - protocolBreakdown[subscription.protocol] = - (protocolBreakdown[subscription.protocol] ?? 0) + 1; - } - - if (activeCount > 0) { - feedStats.push({ - url: entry.feedUrl, - subscriberCount: activeCount, - whenLastUpdate: hasRealUpdate - ? lastUpdate.toISOString() - : null, - feedTitle: entry.resource?.feed?.title ?? null - }); - } - } - - const sorted = [...feedStats].sort( - (a, b) => b.subscriberCount - a.subscriberCount - ); - const cut = sorted.slice(0, 10); - const last = cut[cut.length - 1]; - let topFeeds = cut; - let moreFeeds: FeedStat[] = []; - if (last !== undefined && sorted.length > 10) { - topFeeds = sorted.filter( - feed => feed.subscriberCount >= last.subscriberCount - ); - moreFeeds = sorted.slice(topFeeds.length); - } - - return { - generatedAt: current.toISOString(), - feedsChangedLastWindow, - feedsWithSubscribers: feedStats.length, - uniqueAggregators: hostnames.size, - totalActiveSubscriptions, - topFeeds, - moreFeeds, - protocolBreakdown - }; - } - async function close(): Promise { const resolved = await storeReady; if (isClosable(resolved)) { @@ -606,7 +469,7 @@ export function createRssCloudCore( seedSubscriptions, clearFeeds, close, - removeExpired, - generateStats + removeExpired: () => runRemoveExpired(store, config, now), + generateStats: () => runGenerateStats(store, config, now) }; } diff --git a/packages/core/src/engine/maintenance.test.ts b/packages/core/src/engine/maintenance.test.ts new file mode 100644 index 0000000..3008021 --- /dev/null +++ b/packages/core/src/engine/maintenance.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest'; +import { generateStats, removeExpired } from './maintenance.js'; +import { resolveConfig } from '../config.js'; +import { createInMemoryStore } from '../store/memory-store.js'; +import type { Resource } from './resource.js'; +import type { Subscription } from './subscription.js'; + +const FEED = 'https://feed.example/rss'; + +const NOW = new Date('2026-06-01T00:00:00Z'); +const RECENT = new Date('2026-05-30T00:00:00Z'); +const OLD = new Date('2026-01-01T00:00:00Z'); +const FUTURE = new Date('2099-01-01T00:00:00Z'); +const PAST = new Date('2000-01-01T00:00:00Z'); + +const config = resolveConfig(); +const clock = (): Date => NOW; + +function subscription(overrides: Partial = {}): Subscription { + return { + url: 'https://sub.example/notify', + protocol: 'http-post', + ctUpdates: 0, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: new Date(0), + whenLastUpdate: null, + whenLastError: null, + whenExpires: new Date('2099-01-01T00:00:00Z'), + ...overrides + }; +} + +function resource(overrides: Partial = {}): Resource { + return { + url: FEED, + lastHash: 'hash', + lastSize: 1, + ctChecks: 1, + whenLastCheck: new Date(0), + ctUpdates: 0, + whenLastUpdate: new Date(0), + ...overrides + }; +} + +describe('generateStats', () => { + it('returns an empty snapshot for an empty store', async () => { + const stats = await generateStats(createInMemoryStore(), config, clock); + + expect(stats).toMatchObject({ + feedsChangedLastWindow: 0, + feedsWithSubscribers: 0, + uniqueAggregators: 0, + totalActiveSubscriptions: 0, + topFeeds: [], + moreFeeds: [], + protocolBreakdown: {} + }); + expect(stats.generatedAt).toBe('2026-06-01T00:00:00.000Z'); + }); + + it('computes an activity snapshot across feed states', async () => { + const store = createInMemoryStore(); + + await store.putResource( + 'https://a.example/rss', + resource({ + whenLastUpdate: RECENT, + feed: { type: 'rss', title: 'Feed A' } + }) + ); + await store.putSubscriptions('https://a.example/rss', [ + subscription({ + url: 'https://host1.example/notify', + whenExpires: FUTURE + }), + subscription({ + url: 'https://host2.example/notify', + whenExpires: FUTURE + }) + ]); + + await store.putResource( + 'https://b.example/rss', + resource({ whenLastUpdate: new Date(0) }) + ); + await store.putSubscriptions('https://b.example/rss', [ + subscription({ + url: 'https://host3.example/notify', + protocol: 'https-post', + whenExpires: FUTURE + }) + ]); + + await store.putResource( + 'https://c.example/rss', + resource({ whenLastUpdate: OLD }) + ); + await store.putSubscriptions('https://c.example/rss', [ + subscription({ url: 'not a url', whenExpires: FUTURE }) + ]); + + await store.putSubscriptions('https://d.example/rss', [ + subscription({ + url: 'https://host4.example/notify', + whenExpires: FUTURE + }) + ]); + + await store.putResource( + 'https://e.example/rss', + resource({ whenLastUpdate: RECENT }) + ); + await store.putSubscriptions('https://e.example/rss', [ + subscription({ + url: 'https://host5.example/notify', + whenExpires: PAST + }) + ]); + + const stats = await generateStats(store, config, clock); + + expect(stats.feedsChangedLastWindow).toBe(2); + expect(stats.feedsWithSubscribers).toBe(4); + expect(stats.totalActiveSubscriptions).toBe(5); + expect(stats.uniqueAggregators).toBe(4); + expect(stats.protocolBreakdown).toEqual({ + 'http-post': 4, + 'https-post': 1 + }); + + const byUrl = Object.fromEntries( + [...stats.topFeeds, ...stats.moreFeeds].map(feed => [feed.url, feed]) + ); + expect(byUrl['https://a.example/rss']).toMatchObject({ + feedTitle: 'Feed A', + whenLastUpdate: '2026-05-30T00:00:00.000Z' + }); + expect(byUrl['https://b.example/rss']).toMatchObject({ + feedTitle: null, + whenLastUpdate: null + }); + expect(byUrl['https://c.example/rss']).toMatchObject({ + whenLastUpdate: '2026-01-01T00:00:00.000Z' + }); + expect(byUrl['https://d.example/rss']).toMatchObject({ + feedTitle: null, + whenLastUpdate: null + }); + }); + + it('caps the top feeds at a ten-deep cut, keeping boundary ties', async () => { + const store = createInMemoryStore(); + const manySubs = (prefix: string, count: number): Subscription[] => + Array.from({ length: count }, (_unused, i) => + subscription({ + url: `https://${prefix}-h${i}.example/notify`, + whenExpires: FUTURE + }) + ); + + for (let i = 0; i < 11; i++) { + await store.putSubscriptions( + `https://big${i}.example/rss`, + manySubs(`big${i}`, 5) + ); + } + for (let i = 0; i < 2; i++) { + await store.putSubscriptions( + `https://small${i}.example/rss`, + manySubs(`small${i}`, 1) + ); + } + + const stats = await generateStats(store, config, clock); + + expect(stats.feedsWithSubscribers).toBe(13); + expect(stats.topFeeds).toHaveLength(11); + expect(stats.moreFeeds).toHaveLength(2); + expect(stats.topFeeds.every(f => f.subscriberCount === 5)).toBe(true); + }); +}); + +describe('removeExpired', () => { + it('drops expired and error-exhausted subscriptions', async () => { + const store = createInMemoryStore(); + await store.putResource(FEED, resource({ whenLastUpdate: RECENT })); + await store.putSubscriptions(FEED, [ + subscription({ + url: 'https://valid.example/notify', + whenExpires: FUTURE + }), + subscription({ + url: 'https://expired.example/notify', + whenExpires: PAST + }), + subscription({ + url: 'https://exhausted.example/notify', + whenExpires: FUTURE, + ctConsecutiveErrors: 3 + }) + ]); + + const result = await removeExpired(store, config, clock); + + expect(result.subscriptionsRemoved).toBe(2); + expect(result.feedsProcessed).toBe(1); + expect(result.feedsDeleted).toBe(0); + expect(result.orphanedResourcesRemoved).toBe(0); + const subs = await store.getSubscriptions(FEED); + expect(subs.map(s => s.url)).toEqual(['https://valid.example/notify']); + }); + + it('empties but retains a recently updated feed when all subs expire', async () => { + const store = createInMemoryStore(); + await store.putResource(FEED, resource({ whenLastUpdate: RECENT })); + await store.putSubscriptions(FEED, [ + subscription({ whenExpires: PAST }) + ]); + + const result = await removeExpired(store, config, clock); + + expect(result.subscriptionsRemoved).toBe(1); + expect(result.feedsDeleted).toBe(0); + expect(await store.list()).toHaveLength(1); + expect(await store.getSubscriptions(FEED)).toEqual([]); + }); + + it('deletes a stale feed when all subs expire', async () => { + const store = createInMemoryStore(); + await store.putResource(FEED, resource({ whenLastUpdate: OLD })); + await store.putSubscriptions(FEED, [ + subscription({ whenExpires: PAST }) + ]); + + const result = await removeExpired(store, config, clock); + + expect(result.feedsDeleted).toBe(1); + expect(await store.list()).toHaveLength(0); + }); + + it('leaves a healthy feed untouched', async () => { + const store = createInMemoryStore(); + await store.putResource(FEED, resource({ whenLastUpdate: RECENT })); + await store.putSubscriptions(FEED, [ + subscription({ whenExpires: FUTURE }) + ]); + + const result = await removeExpired(store, config, clock); + + expect(result.subscriptionsRemoved).toBe(0); + expect(result.feedsProcessed).toBe(1); + expect(await store.getSubscriptions(FEED)).toHaveLength(1); + }); + + it('removes an orphaned resource outside the retain window', async () => { + const store = createInMemoryStore(); + await store.putResource(FEED, resource({ whenLastUpdate: OLD })); + + const result = await removeExpired(store, config, clock); + + expect(result.orphanedResourcesRemoved).toBe(1); + expect(await store.list()).toHaveLength(0); + }); + + it('keeps an orphaned resource that was recently updated', async () => { + const store = createInMemoryStore(); + await store.putResource(FEED, resource({ whenLastUpdate: RECENT })); + + const result = await removeExpired(store, config, clock); + + expect(result.orphanedResourcesRemoved).toBe(0); + expect(await store.list()).toHaveLength(1); + }); + + it('removes an empty entry that has no resource', async () => { + const store = createInMemoryStore(); + await store.putSubscriptions(FEED, []); + + const result = await removeExpired(store, config, clock); + + expect(result.orphanedResourcesRemoved).toBe(1); + expect(await store.list()).toHaveLength(0); + }); + + it('removes an orphaned resource that was never updated', async () => { + const store = createInMemoryStore(); + await store.putResource(FEED, resource({ whenLastUpdate: new Date(0) })); + + const result = await removeExpired(store, config, clock); + + expect(result.orphanedResourcesRemoved).toBe(1); + expect(await store.list()).toHaveLength(0); + }); +}); diff --git a/packages/core/src/engine/maintenance.ts b/packages/core/src/engine/maintenance.ts new file mode 100644 index 0000000..9580502 --- /dev/null +++ b/packages/core/src/engine/maintenance.ts @@ -0,0 +1,157 @@ +import type { RssCloudConfig } from '../config.js'; +import type { Resource } from './resource.js'; +import type { FeedStat, MaintenanceResult, Stats } from './stats.js'; +import type { Store } from '../store/store.js'; + +/** + * Housekeeping jobs the host schedules on an interval. They need only a + * {@link Store}, the protocol {@link RssCloudConfig}, and a clock — no + * transports or feed parsing — so they live apart from the core factory and + * are exercised directly rather than through a fully-wired core. + */ + +function windowCutoff(from: Date, windowDays: number): Date { + return new Date(from.getTime() - windowDays * 86400 * 1000); +} + +/** Drop expired/exhausted subscriptions and prune orphaned resources. */ +export async function removeExpired( + store: Store, + config: RssCloudConfig, + now: () => Date +): Promise { + const current = now(); + const cutoff = windowCutoff(current, config.feedsChangedWindowDays); + const entries = await store.list(); + + let subscriptionsRemoved = 0; + let feedsDeleted = 0; + let orphanedResourcesRemoved = 0; + + const recentlyUpdated = (resource: Resource | null): boolean => + resource !== null && + resource.whenLastUpdate.getTime() > 0 && + resource.whenLastUpdate >= cutoff; + + for (const entry of entries) { + if (entry.subscriptions.length === 0) { + if (recentlyUpdated(entry.resource)) { + continue; + } + await store.remove(entry.feedUrl); + orphanedResourcesRemoved += 1; + continue; + } + + const valid = entry.subscriptions.filter(subscription => { + const expired = + subscription.whenExpires.getTime() <= current.getTime(); + const exhausted = + subscription.ctConsecutiveErrors >= config.maxConsecutiveErrors; + if (expired || exhausted) { + subscriptionsRemoved += 1; + return false; + } + return true; + }); + + if (valid.length === entry.subscriptions.length) { + continue; + } + + if (valid.length === 0) { + if (recentlyUpdated(entry.resource)) { + await store.putSubscriptions(entry.feedUrl, []); + } else { + await store.remove(entry.feedUrl); + feedsDeleted += 1; + } + } else { + await store.putSubscriptions(entry.feedUrl, valid); + } + } + + return { + subscriptionsRemoved, + feedsProcessed: entries.length, + feedsDeleted, + orphanedResourcesRemoved + }; +} + +/** Aggregate a snapshot of server activity from the current store contents. */ +export async function generateStats( + store: Store, + config: RssCloudConfig, + now: () => Date +): Promise { + const current = now(); + const cutoff = windowCutoff(current, config.feedsChangedWindowDays); + const entries = await store.list(); + + let feedsChangedLastWindow = 0; + let totalActiveSubscriptions = 0; + const hostnames = new Set(); + const protocolBreakdown: Record = {}; + const feedStats: FeedStat[] = []; + + for (const entry of entries) { + const lastUpdate = entry.resource?.whenLastUpdate ?? null; + const hasRealUpdate = lastUpdate !== null && lastUpdate.getTime() > 0; + if (hasRealUpdate && lastUpdate >= cutoff) { + feedsChangedLastWindow += 1; + } + + let activeCount = 0; + for (const subscription of entry.subscriptions) { + if (subscription.whenExpires.getTime() <= current.getTime()) { + continue; + } + activeCount += 1; + totalActiveSubscriptions += 1; + try { + hostnames.add(new URL(subscription.url).hostname); + } catch { + // ignore unparseable callback URLs + } + protocolBreakdown[subscription.protocol] = + (protocolBreakdown[subscription.protocol] ?? 0) + 1; + } + + if (activeCount > 0) { + feedStats.push({ + url: entry.feedUrl, + subscriberCount: activeCount, + whenLastUpdate: hasRealUpdate + ? lastUpdate.toISOString() + : null, + feedTitle: entry.resource?.feed?.title ?? null + }); + } + } + + const sorted = [...feedStats].sort( + (a, b) => b.subscriberCount - a.subscriberCount + ); + const cut = sorted.slice(0, 10); + const last = cut[cut.length - 1]; + let topFeeds = cut; + let moreFeeds: FeedStat[] = []; + if (last !== undefined && sorted.length > 10) { + topFeeds = sorted.filter( + feed => feed.subscriberCount >= last.subscriberCount + ); + moreFeeds = sorted.slice(topFeeds.length); + } + + return { + generatedAt: current.toISOString(), + feedsChangedLastWindow, + feedsWithSubscribers: feedStats.length, + uniqueAggregators: hostnames.size, + totalActiveSubscriptions, + topFeeds, + moreFeeds, + protocolBreakdown + }; +} From 6138b16ec2d91591809890020f43b616d914ff1f Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 09:44:00 -0500 Subject: [PATCH 59/90] docs: mark the maintenance-job extraction done in TODO Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/TODO.md b/TODO.md index 78f0ec0..f67b1e4 100644 --- a/TODO.md +++ b/TODO.md @@ -27,17 +27,13 @@ trust the names over the numbers. > `please-notify-form`) collapsed into a table-driven mount, and `/docs` + > `/LICENSE.md` share one `renderMarkdownDoc` service. HTTP behaviour stays > covered by the e2e suite (no new HTTP-level unit tests, by decision). +> - **Lifted the maintenance jobs out of `create-core.ts`** — `removeExpired` +> and `generateStats` now live in `engine/maintenance.ts` as functions over +> `(store, config, now)`; the factory delegates. Shrank the factory ~143 +> lines and the maintenance suite exercises them directly against an +> in-memory store (one core-level smoke test per delegation). Coverage 100%. -### 1. Lift the maintenance jobs out of `create-core.ts` - -`removeExpired` and `generateStats` (~130 lines inside the 585-line factory) are -read-only jobs needing only `store` + a clock, but are exercisable only by -building a full core with fetch + plugin mocks they never use. - -*Fix:* extract as functions over `(store, config, now)`; core delegates. Narrows -the test surface; shrinks the factory. (Coverage stays 100% per CLAUDE.md.) - -### 2. One `fetchWithTimeout`, not three copies +### 1. One `fetchWithTimeout`, not three copies The abort-controller + `clearTimeout` pattern is written verbatim in `engine/create-core.ts`, `protocols/rest-plugin.ts`, and @@ -46,7 +42,7 @@ The abort-controller + `clearTimeout` pattern is written verbatim in *Fix:* a shared `fetchWithTimeout(doFetch, ms, url, init)` core util. A bug in the abort dance then has one place to live, and one place to test. -### 3. `feedsChangedLast7Days` label can silently lie +### 2. `feedsChangedLast7Days` label can silently lie The window is a config value upstream (`feedsChangedWindowDays`) but a baked-in literal `7` downstream: the wire field name in `services/stats.js` From afc84f3a5bfccf6f2b9efdc2bcc016eb25ede939 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 09:53:03 -0500 Subject: [PATCH 60/90] refactor(core): collapse three fetchWithTimeout copies into one util The abort-controller + clearTimeout dance was written verbatim in the engine and both protocol plugins, differing only in which doFetch and timeout they passed. Extract a single fetchWithTimeout(doFetch, ms, url, init) at the package root; the three callers now delegate. A bug in the abort/teardown logic now has one home and one test suite (fake-timer coverage of the abort-on-timeout and clear-on-settle paths). Coverage stays at 100%. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/engine/create-core.ts | 24 ++---- packages/core/src/fetch-with-timeout.test.ts | 75 +++++++++++++++++++ packages/core/src/fetch-with-timeout.ts | 20 +++++ packages/core/src/protocols/rest-plugin.ts | 25 ++----- packages/core/src/protocols/xml-rpc-plugin.ts | 18 +---- 5 files changed, 109 insertions(+), 53 deletions(-) create mode 100644 packages/core/src/fetch-with-timeout.test.ts create mode 100644 packages/core/src/fetch-with-timeout.ts diff --git a/packages/core/src/engine/create-core.ts b/packages/core/src/engine/create-core.ts index 8bcf041..1cf995e 100644 --- a/packages/core/src/engine/create-core.ts +++ b/packages/core/src/engine/create-core.ts @@ -10,6 +10,7 @@ import type { } from './dto.js'; import { RssCloudError } from '../errors.js'; import { createEventBus } from '../events.js'; +import { fetchWithTimeout } from '../fetch-with-timeout.js'; import { createDefaultFeedParser } from '../feed/feed-parser.js'; import { generateStats as runGenerateStats, @@ -84,22 +85,6 @@ export function createRssCloudCore( return new Date(base.getTime() + config.ctSecsResourceExpire * 1000); } - async function fetchWithTimeout( - url: string, - init: RequestInit - ): Promise { - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - config.requestTimeoutMs - ); - try { - return await doFetch(url, { ...init, signal: controller.signal }); - } finally { - clearTimeout(timeout); - } - } - function newResource(url: string): Resource { return { url, @@ -141,7 +126,12 @@ export function createRssCloudCore( let ok = false; try { - const res = await fetchWithTimeout(resourceUrl, { method: 'GET' }); + const res = await fetchWithTimeout( + doFetch, + config.requestTimeoutMs, + resourceUrl, + { method: 'GET' } + ); ok = res.ok; if (ok) { body = await res.text(); diff --git a/packages/core/src/fetch-with-timeout.test.ts b/packages/core/src/fetch-with-timeout.test.ts new file mode 100644 index 0000000..a07ea52 --- /dev/null +++ b/packages/core/src/fetch-with-timeout.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fetchWithTimeout } from './fetch-with-timeout.js'; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('fetchWithTimeout', () => { + it('forwards the request to doFetch with an abort signal and returns its response', async () => { + const response = new Response('ok'); + let signal: AbortSignal | undefined; + const doFetch = vi.fn(async (_url: string, init: RequestInit) => { + signal = init.signal as AbortSignal; + return response; + }); + + const res = await fetchWithTimeout( + doFetch as unknown as typeof fetch, + 1000, + 'https://target.example/notify', + { method: 'POST' } + ); + + expect(res).toBe(response); + expect(doFetch).toHaveBeenCalledWith( + 'https://target.example/notify', + expect.objectContaining({ + method: 'POST', + signal: expect.any(AbortSignal) + }) + ); + expect(signal?.aborted).toBe(false); + }); + + it('aborts the request once the timeout elapses', async () => { + vi.useFakeTimers(); + let signal: AbortSignal | undefined; + const doFetch = vi.fn( + (_url: string, init: RequestInit) => { + signal = init.signal as AbortSignal; + return new Promise(() => {}); + } + ); + + void fetchWithTimeout( + doFetch as unknown as typeof fetch, + 1000, + 'https://target.example/notify', + {} + ); + + expect(signal?.aborted).toBe(false); + vi.advanceTimersByTime(1000); + expect(signal?.aborted).toBe(true); + }); + + it('clears the timer once settled, so a completed request is never aborted', async () => { + vi.useFakeTimers(); + let signal: AbortSignal | undefined; + const doFetch = vi.fn(async (_url: string, init: RequestInit) => { + signal = init.signal as AbortSignal; + return new Response('ok'); + }); + + await fetchWithTimeout( + doFetch as unknown as typeof fetch, + 1000, + 'https://target.example/notify', + {} + ); + + vi.advanceTimersByTime(5000); + expect(signal?.aborted).toBe(false); + }); +}); diff --git a/packages/core/src/fetch-with-timeout.ts b/packages/core/src/fetch-with-timeout.ts new file mode 100644 index 0000000..7778908 --- /dev/null +++ b/packages/core/src/fetch-with-timeout.ts @@ -0,0 +1,20 @@ +/** + * Run a fetch under a hard timeout: start an {@link AbortController}, abort it + * after `ms`, and always clear the timer once the request settles. The single + * home for the abort/clearTimeout dance every outbound caller (engine + each + * protocol plugin) needs; they differ only in which `doFetch` and `ms` they pass. + */ +export async function fetchWithTimeout( + doFetch: typeof fetch, + ms: number, + url: string, + init: RequestInit +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), ms); + try { + return await doFetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} diff --git a/packages/core/src/protocols/rest-plugin.ts b/packages/core/src/protocols/rest-plugin.ts index ea20666..d825709 100644 --- a/packages/core/src/protocols/rest-plugin.ts +++ b/packages/core/src/protocols/rest-plugin.ts @@ -5,6 +5,7 @@ import type { VerifyContext } from '../engine/plugin.js'; import type { Protocol } from '../engine/protocol.js'; +import { fetchWithTimeout } from '../fetch-with-timeout.js'; /** Construction-time dependencies for the rssCloud REST protocol plugin. */ export interface RestProtocolPluginOptions { @@ -49,30 +50,12 @@ export function createRestProtocolPlugin( return body; } - /** Fetch with the configured timeout enforced via an abort signal. */ - async function fetchWithTimeout( - url: string, - init: RequestInit - ): Promise { - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - requestTimeoutMs - ); - - try { - return await doFetch(url, { ...init, signal: controller.signal }); - } finally { - clearTimeout(timeout); - } - } - /** POST the notification, following redirects; throws on timeout or non-2xx. */ async function sendNotify( targetUrl: string, body: URLSearchParams ): Promise { - const res = await fetchWithTimeout(targetUrl, { + const res = await fetchWithTimeout(doFetch, requestTimeoutMs, targetUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, @@ -112,7 +95,9 @@ export function createRestProtocolPlugin( const query = new URLSearchParams({ url: resourceUrl, challenge }); const testUrl = apiurl + '?' + query.toString(); - const res = await fetchWithTimeout(testUrl, { method: 'GET' }); + const res = await fetchWithTimeout(doFetch, requestTimeoutMs, testUrl, { + method: 'GET' + }); const body = await res.text(); if (!res.ok || body !== challenge) { diff --git a/packages/core/src/protocols/xml-rpc-plugin.ts b/packages/core/src/protocols/xml-rpc-plugin.ts index d18891c..6bcdb43 100644 --- a/packages/core/src/protocols/xml-rpc-plugin.ts +++ b/packages/core/src/protocols/xml-rpc-plugin.ts @@ -5,6 +5,7 @@ import type { VerifyContext } from '../engine/plugin.js'; import type { Protocol } from '../engine/protocol.js'; +import { fetchWithTimeout } from '../fetch-with-timeout.js'; import { buildNotifyCall } from './xml-rpc-codec.js'; /** Construction-time dependencies for the rssCloud XML-RPC protocol plugin. */ @@ -33,28 +34,13 @@ export function createXmlRpcProtocolPlugin( const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; - /** Fetch with the configured timeout enforced via an abort signal. */ - async function fetchWithTimeout( - url: string, - init: RequestInit - ): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), requestTimeoutMs); - - try { - return await doFetch(url, { ...init, signal: controller.signal }); - } finally { - clearTimeout(timeout); - } - } - /** POST the notify methodCall; throws on timeout or non-2xx. */ async function sendNotify( targetUrl: string, procedure: string, resourceUrl: string ): Promise { - const res = await fetchWithTimeout(targetUrl, { + const res = await fetchWithTimeout(doFetch, requestTimeoutMs, targetUrl, { method: 'POST', headers: { 'Content-Type': 'text/xml' }, body: buildNotifyCall(procedure, resourceUrl) From 0a067db757b4eb3b3b163e1b2c40bf367385941a Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 09:53:21 -0500 Subject: [PATCH 61/90] docs: mark the fetchWithTimeout consolidation done in TODO Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index f67b1e4..b03a4ce 100644 --- a/TODO.md +++ b/TODO.md @@ -32,17 +32,12 @@ trust the names over the numbers. > `(store, config, now)`; the factory delegates. Shrank the factory ~143 > lines and the maintenance suite exercises them directly against an > in-memory store (one core-level smoke test per delegation). Coverage 100%. +> - **Collapsed the three `fetchWithTimeout` copies** — the abort/`clearTimeout` +> dance now lives in one `fetchWithTimeout(doFetch, ms, url, init)` at the +> package root; the engine and both protocol plugins delegate. One home, one +> fake-timer test suite (abort-on-timeout + clear-on-settle). Coverage 100%. -### 1. One `fetchWithTimeout`, not three copies - -The abort-controller + `clearTimeout` pattern is written verbatim in -`engine/create-core.ts`, `protocols/rest-plugin.ts`, and -`protocols/xml-rpc-plugin.ts`; only the timeout source differs. - -*Fix:* a shared `fetchWithTimeout(doFetch, ms, url, init)` core util. A bug in -the abort dance then has one place to live, and one place to test. - -### 2. `feedsChangedLast7Days` label can silently lie +### 1. `feedsChangedLast7Days` label can silently lie The window is a config value upstream (`feedsChangedWindowDays`) but a baked-in literal `7` downstream: the wire field name in `services/stats.js` From a0a8456e3b2d558bf7fe385df40ad2e0576a0020 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 10:02:49 -0500 Subject: [PATCH 62/90] feat(core): expose the change-window size on Stats generateStats already counted feeds changed within feedsChangedWindowDays but only emitted the count, leaving downstream consumers to guess (and hardcode) the window. Add windowDays to the Stats projection so the window is self-describing and a label can state it honestly. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/engine/maintenance.test.ts | 10 ++++++++++ packages/core/src/engine/maintenance.ts | 1 + packages/core/src/engine/stats.ts | 2 ++ 3 files changed, 13 insertions(+) diff --git a/packages/core/src/engine/maintenance.test.ts b/packages/core/src/engine/maintenance.test.ts index 3008021..86b4c6d 100644 --- a/packages/core/src/engine/maintenance.test.ts +++ b/packages/core/src/engine/maintenance.test.ts @@ -60,6 +60,16 @@ describe('generateStats', () => { expect(stats.generatedAt).toBe('2026-06-01T00:00:00.000Z'); }); + it('reports the configured change window, not a baked-in literal', async () => { + const stats = await generateStats( + createInMemoryStore(), + resolveConfig({ feedsChangedWindowDays: 14 }), + clock + ); + + expect(stats.windowDays).toBe(14); + }); + it('computes an activity snapshot across feed states', async () => { const store = createInMemoryStore(); diff --git a/packages/core/src/engine/maintenance.ts b/packages/core/src/engine/maintenance.ts index 9580502..fb8715d 100644 --- a/packages/core/src/engine/maintenance.ts +++ b/packages/core/src/engine/maintenance.ts @@ -147,6 +147,7 @@ export async function generateStats( return { generatedAt: current.toISOString(), feedsChangedLastWindow, + windowDays: config.feedsChangedWindowDays, feedsWithSubscribers: feedStats.length, uniqueAggregators: hostnames.size, totalActiveSubscriptions, diff --git a/packages/core/src/engine/stats.ts b/packages/core/src/engine/stats.ts index 7c5ef74..4903095 100644 --- a/packages/core/src/engine/stats.ts +++ b/packages/core/src/engine/stats.ts @@ -12,6 +12,8 @@ export interface Stats { /** ISO 8601 generation time, or `null` before the first run. */ generatedAt: string | null; feedsChangedLastWindow: number; + /** The size (days) of the change window {@link feedsChangedLastWindow} counts. */ + windowDays: number; feedsWithSubscribers: number; /** Distinct subscriber hostnames. */ uniqueAggregators: number; From 7c855c1967aad052f66468903b5979337a84bc62 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 10:02:58 -0500 Subject: [PATCH 63/90] fix(server): stop the stats label hardcoding "7 days" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Feeds changed in last 7 days" label and the feedsChangedLast7Days wire field were baked-in literals while the window itself is the configurable feedsChangedWindowDays — change the config and the label kept claiming 7. Carry core's feedsChangedLastWindow + windowDays through toLegacyStats (and the no-file fallback) and let the template interpolate the count, so the stats page reflects the actual window. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/services/stats.js | 13 ++++++++----- apps/server/services/stats.test.js | 18 ++++++++++++++++-- apps/server/views/stats.handlebars | 4 ++-- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/apps/server/services/stats.js b/apps/server/services/stats.js index 9aa3e37..9133bbd 100644 --- a/apps/server/services/stats.js +++ b/apps/server/services/stats.js @@ -17,7 +17,8 @@ function getStats() { } catch { return { generatedAt: null, - feedsChangedLast7Days: 0, + feedsChangedLastWindow: 0, + windowDays: config.feedsChangedWindowDays, feedsWithSubscribers: 0, uniqueAggregators: 0, totalActiveSubscriptions: 0, @@ -28,9 +29,10 @@ function getStats() { } } -// Map core's Stats onto the legacy wire shape the view + /stats.json expose: -// rename feedsChangedLastWindow, and report exactly the three known protocols -// (seeded at 0, dropping any core might include outside that set). +// Map core's Stats onto the wire shape the view + /stats.json expose: carry the +// change window (count + its size in days, so the label can't lie) and report +// exactly the three known protocols (seeded at 0, dropping any core might +// include outside that set). function toLegacyStats(coreStats) { const protocolBreakdown = {}; for (const protocol of KNOWN_PROTOCOLS) { @@ -39,7 +41,8 @@ function toLegacyStats(coreStats) { } return { generatedAt: coreStats.generatedAt, - feedsChangedLast7Days: coreStats.feedsChangedLastWindow, + feedsChangedLastWindow: coreStats.feedsChangedLastWindow, + windowDays: coreStats.windowDays, feedsWithSubscribers: coreStats.feedsWithSubscribers, uniqueAggregators: coreStats.uniqueAggregators, totalActiveSubscriptions: coreStats.totalActiveSubscriptions, diff --git a/apps/server/services/stats.test.js b/apps/server/services/stats.test.js index ea32f00..5811763 100644 --- a/apps/server/services/stats.test.js +++ b/apps/server/services/stats.test.js @@ -62,7 +62,8 @@ function makeSubscription(overrides = {}) { const EMPTY_STATS = { generatedAt: null, - feedsChangedLast7Days: 0, + feedsChangedLastWindow: 0, + windowDays: 7, feedsWithSubscribers: 0, uniqueAggregators: 0, totalActiveSubscriptions: 0, @@ -90,6 +91,19 @@ test('generateStats persists an empty snapshot getStats reads back', async() => assert.deepEqual(getStats(), generated); }); +test('generateStats carries the configured change window through', async() => { + const core = createRssCloudCore({ + store: createInMemoryStore(), + plugins: [], + config: resolveConfig({ feedsChangedWindowDays: 30 }) + }); + const { generateStats } = createStats({ core }); + + const generated = await generateStats(); + + assert.equal(generated.windowDays, 30); +}); + test('generateStats aggregates active subscriptions into the legacy shape', async() => { const { core, generateStats } = setup(); const recent = new Date(Date.now() - DAY_MS); @@ -116,7 +130,7 @@ test('generateStats aggregates active subscriptions into the legacy shape', asyn const generated = await generateStats(); - assert.equal(generated.feedsChangedLast7Days, 2); + assert.equal(generated.feedsChangedLastWindow, 2); assert.equal(generated.feedsWithSubscribers, 2); assert.equal(generated.totalActiveSubscriptions, 3); // sub1.example.com is shared across both feeds — counted once. diff --git a/apps/server/views/stats.handlebars b/apps/server/views/stats.handlebars index 3bbc0e4..a1ee628 100644 --- a/apps/server/views/stats.handlebars +++ b/apps/server/views/stats.handlebars @@ -12,8 +12,8 @@

Overview

- - + + From 730e2fda6646be61563e592511271b2544c6cbde Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 10:03:27 -0500 Subject: [PATCH 64/90] docs: mark the stats-label fix done; close out architecture cleanup Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/TODO.md b/TODO.md index b03a4ce..4e45447 100644 --- a/TODO.md +++ b/TODO.md @@ -36,20 +36,14 @@ trust the names over the numbers. > dance now lives in one `fetchWithTimeout(doFetch, ms, url, init)` at the > package root; the engine and both protocol plugins delegate. One home, one > fake-timer test suite (abort-on-timeout + clear-on-settle). Coverage 100%. - -### 1. `feedsChangedLast7Days` label can silently lie - -The window is a config value upstream (`feedsChangedWindowDays`) but a baked-in -literal `7` downstream: the wire field name in `services/stats.js` -(`toLegacyStats`) and the wording in `views/stats.handlebars`. Change the config -and the label keeps claiming "7 days". - -*Fix:* carry the window count through the projection (`feedsChangedLastWindow` + -`windowDays`) and let the template interpolate it. - -> The review's sixth item — extracting the hand-rolled wire builders out of -> `apps/server/client.js` — is already the "Client app + `@rsscloud/client` -> package" work below. Not duplicated here. +> - **Stopped the stats label lying** — core's `Stats` now carries `windowDays` +> alongside `feedsChangedLastWindow`; `toLegacyStats` passes both through and +> `views/stats.handlebars` interpolates the count ("changed in last +> {{windowDays}} days"). Change `feedsChangedWindowDays` and the label follows. + +All three reviewed cleanup items are done. (The review's sixth item — extracting +the hand-rolled wire builders out of `apps/server/client.js` — is the "Client app ++ `@rsscloud/client` package" work below, not a separate task.) ## WebSub hub support (bigger — spans core + express) From 4d93648276d43461677efd6d37e8c3440bbee977 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 10:17:27 -0500 Subject: [PATCH 65/90] refactor(server): drop the remove-expired-subscriptions pass-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service was () => core.removeExpired() with no transform or error handling, and its 152-line test re-verified core's maintenance behaviour (expiry, retention window, orphan pruning, the error limit, the MaintenanceResult shape) — all already covered by core's engine/maintenance.test.ts. A shallow module whose deletion test passes cleanly: the one-liner inlines at its three call sites (the startup + scheduled cleanup in app.js, the /test/removeExpired endpoint) and the behaviour stays owned and tested once, in core. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/app.js | 6 +- apps/server/controllers/test.js | 8 +- .../services/remove-expired-subscriptions.js | 23 --- .../remove-expired-subscriptions.test.js | 152 ------------------ 4 files changed, 4 insertions(+), 185 deletions(-) delete mode 100644 apps/server/services/remove-expired-subscriptions.js delete mode 100644 apps/server/services/remove-expired-subscriptions.test.js diff --git a/apps/server/app.js b/apps/server/app.js index eb62530..1a6c109 100644 --- a/apps/server/app.js +++ b/apps/server/app.js @@ -7,14 +7,12 @@ const config = require('./config'), getDayjs = require('./services/dayjs-wrapper'), { createStats } = require('./services/stats'), morgan = require('morgan'), - createRemoveExpiredSubscriptions = require('./services/remove-expired-subscriptions'), websocket = require('./services/websocket'), { core, events: coreEvents } = require('./core'), { createControllers } = require('./controllers'), bridgeCoreEvents = require('./services/core-event-bridge'); const stats = createStats({ core }); -const removeExpiredSubscriptions = createRemoveExpiredSubscriptions({ core }); let app, hbs, server, dayjs; @@ -23,7 +21,7 @@ console.log(`${config.appName} ${config.appVersion}`); // Schedule cleanup tasks function scheduleCleanupTasks() { // Run cleanup immediately on startup - removeExpiredSubscriptions() + core.removeExpired() .then(() => console.log('Startup subscription cleanup completed')) .catch(err => console.error('Error in startup subscription cleanup:', err) @@ -34,7 +32,7 @@ function scheduleCleanupTasks() { async() => { try { console.log('Running scheduled subscription cleanup...'); - await removeExpiredSubscriptions(); + await core.removeExpired(); } catch (error) { console.error( 'Error in scheduled subscription cleanup:', diff --git a/apps/server/controllers/test.js b/apps/server/controllers/test.js index 5084f44..e61c2f0 100644 --- a/apps/server/controllers/test.js +++ b/apps/server/controllers/test.js @@ -5,8 +5,7 @@ const express = require('express'), subscriptionToJson, subscriptionFromJson } = require('@rsscloud/core'), - { toFeedsJson } = require('../services/feeds-json'), - createRemoveExpiredSubscriptions = require('../services/remove-expired-subscriptions'); + { toFeedsJson } = require('../services/feeds-json'); const EPOCH_ISO = new Date(0).toISOString(); @@ -32,9 +31,6 @@ function resourceFromInput(feedUrl, raw) { // seedResource/seedSubscriptions/clearFeeds. function createTestController({ core }) { const router = new express.Router(); - const removeExpiredSubscriptions = createRemoveExpiredSubscriptions({ - core - }); console.warn( '[test-api] ENABLE_TEST_API=true — /test/* endpoints are mounted. Never enable in production.' @@ -127,7 +123,7 @@ function createTestController({ core }) { router.post('/removeExpired', async(req, res) => { try { - const result = await removeExpiredSubscriptions(); + const result = await core.removeExpired(); res.json({ success: true, result }); } catch (error) { res.status(500).json({ success: false, error: error.message }); diff --git a/apps/server/services/remove-expired-subscriptions.js b/apps/server/services/remove-expired-subscriptions.js deleted file mode 100644 index 7b4909c..0000000 --- a/apps/server/services/remove-expired-subscriptions.js +++ /dev/null @@ -1,23 +0,0 @@ -// Drops expired/errored subscriptions and prunes empty feeds. The protocol -// logic lives in @rsscloud/core; this is a thin adapter over core.removeExpired() -// that the server schedules (app.js) and the /test/* API drives. Callers own -// their own error handling, and core reads/writes the shared store, so the -// effects land in the same store the /test/getData view reads. -// -// Differs from the retired hand-rolled sweep by design: -// - returns core's MaintenanceResult (feedsProcessed/feedsDeleted) instead of -// the legacy documentsProcessed/documentsDeleted/urlsFixed shape; -// - drops the IPv4-mapped-IPv6 callback rewrite (new subs are normalized at -// subscribe time, so only stale persisted URLs went uncleaned); -// - treats ctConsecutiveErrors >= maxConsecutiveErrors as exhausted (was a -// strict >), matching core's delivery filter. - -// Built with an injected core so callers (production wiring, the /test/* API) -// supply the singleton while tests supply an in-memory core. -function createRemoveExpiredSubscriptions({ core }) { - return function removeExpiredSubscriptions() { - return core.removeExpired(); - }; -} - -module.exports = createRemoveExpiredSubscriptions; diff --git a/apps/server/services/remove-expired-subscriptions.test.js b/apps/server/services/remove-expired-subscriptions.test.js deleted file mode 100644 index b8524d0..0000000 --- a/apps/server/services/remove-expired-subscriptions.test.js +++ /dev/null @@ -1,152 +0,0 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { - createRssCloudCore, - createInMemoryStore, - resolveConfig -} = require('@rsscloud/core'); -const createRemoveExpiredSubscriptions = require('./remove-expired-subscriptions'); - -const coreConfig = resolveConfig({}); -const DAY_MS = 24 * 60 * 60 * 1000; -const at = offsetMs => new Date(Date.now() + offsetMs); - -const expired = () => at(-DAY_MS); -const active = () => at(DAY_MS); -const withinWindow = () => at(-DAY_MS); -const beyondWindow = () => at(-10 * DAY_MS); - -// A fresh in-memory-backed core + the service under it, fully isolated per test -// (no shared file store, so no clear-between-tests dance). -function setup() { - const core = createRssCloudCore({ - store: createInMemoryStore(), - plugins: [], - config: coreConfig - }); - return { core, removeExpiredSubscriptions: createRemoveExpiredSubscriptions({ core }) }; -} - -function makeResource(feedUrl, { whenLastUpdate = new Date(0) } = {}) { - return { - url: feedUrl, - lastHash: '', - lastSize: 0, - ctChecks: 0, - whenLastCheck: new Date(0), - ctUpdates: 0, - whenLastUpdate - }; -} - -function makeSubscription(overrides = {}) { - return { - url: 'http://sub.example.com/notify', - protocol: 'http-post', - ctUpdates: 0, - ctErrors: 0, - ctConsecutiveErrors: 0, - whenCreated: active(), - whenLastUpdate: null, - whenLastError: null, - whenExpires: active(), - ...overrides - }; -} - -async function entryFor(core, feedUrl) { - return (await core.listFeeds()).find(e => e.feedUrl === feedUrl); -} - -test('removes an expired subscription and prunes the now-empty feed', async() => { - const { core, removeExpiredSubscriptions } = setup(); - const feed = 'https://a.example.com/feed.xml'; - await core.seedSubscriptions(feed, [makeSubscription({ whenExpires: expired() })]); - - const result = await removeExpiredSubscriptions(); - - assert.equal(result.subscriptionsRemoved, 1); - assert.equal(await entryFor(core, feed), undefined); -}); - -test('clears an expired subscription but retains a recently-updated feed', async() => { - const { core, removeExpiredSubscriptions } = setup(); - const feed = 'https://b.example.com/feed.xml'; - await core.seedResource(feed, makeResource(feed, { whenLastUpdate: withinWindow() })); - await core.seedSubscriptions(feed, [makeSubscription({ whenExpires: expired() })]); - - const result = await removeExpiredSubscriptions(); - - assert.equal(result.subscriptionsRemoved, 1); - const entry = await entryFor(core, feed); - assert.ok(entry); - assert.deepEqual(entry.subscriptions, []); -}); - -test('removes a feed whose resource is older than the retention window', async() => { - const { core, removeExpiredSubscriptions } = setup(); - const feed = 'https://c.example.com/feed.xml'; - await core.seedResource(feed, makeResource(feed, { whenLastUpdate: beyondWindow() })); - await core.seedSubscriptions(feed, [makeSubscription({ whenExpires: expired() })]); - - await removeExpiredSubscriptions(); - - assert.equal(await entryFor(core, feed), undefined); -}); - -test('leaves active subscriptions untouched', async() => { - const { core, removeExpiredSubscriptions } = setup(); - const feed = 'https://d.example.com/feed.xml'; - await core.seedResource(feed, makeResource(feed, { whenLastUpdate: withinWindow() })); - await core.seedSubscriptions(feed, [makeSubscription({ whenExpires: active() })]); - - const result = await removeExpiredSubscriptions(); - - assert.equal(result.subscriptionsRemoved, 0); - const entry = await entryFor(core, feed); - assert.ok(entry); - assert.equal(entry.subscriptions.length, 1); -}); - -test('removes an orphaned resource with no subscriptions', async() => { - const { core, removeExpiredSubscriptions } = setup(); - const feed = 'https://e.example.com/feed.xml'; - await core.seedResource(feed, makeResource(feed, { whenLastUpdate: beyondWindow() })); - - await removeExpiredSubscriptions(); - - assert.equal(await entryFor(core, feed), undefined); -}); - -test('returns the core MaintenanceResult shape', async() => { - const { core, removeExpiredSubscriptions } = setup(); - const feed = 'https://f.example.com/feed.xml'; - await core.seedSubscriptions(feed, [makeSubscription({ whenExpires: expired() })]); - - const result = await removeExpiredSubscriptions(); - - assert.deepEqual(result, { - subscriptionsRemoved: 1, - feedsProcessed: 1, - feedsDeleted: 1, - orphanedResourcesRemoved: 0 - }); -}); - -test('removes a subscription that has reached the consecutive-error limit', async() => { - const { core, removeExpiredSubscriptions } = setup(); - const feed = 'https://g.example.com/feed.xml'; - await core.seedResource(feed, makeResource(feed, { whenLastUpdate: withinWindow() })); - await core.seedSubscriptions(feed, [ - makeSubscription({ - whenExpires: active(), - ctConsecutiveErrors: coreConfig.maxConsecutiveErrors - }) - ]); - - const result = await removeExpiredSubscriptions(); - - assert.equal(result.subscriptionsRemoved, 1); - const entry = await entryFor(core, feed); - assert.deepEqual(entry.subscriptions, []); -}); From 47ea1f5cc69a5b8ffff9f6fa67bf0713f7b28028 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 10:18:07 -0500 Subject: [PATCH 66/90] docs: log the pass-through removal; record second-review findings Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index 4e45447..2de84e0 100644 --- a/TODO.md +++ b/TODO.md @@ -40,10 +40,30 @@ trust the names over the numbers. > alongside `feedsChangedLastWindow`; `toLegacyStats` passes both through and > `views/stats.handlebars` interpolates the count ("changed in last > {{windowDays}} days"). Change `feedsChangedWindowDays` and the label follows. - -All three reviewed cleanup items are done. (The review's sixth item — extracting -the hand-rolled wire builders out of `apps/server/client.js` — is the "Client app -+ `@rsscloud/client` package" work below, not a separate task.) +> - **Collapsed the `remove-expired-subscriptions` pass-through** — the service +> was `() => core.removeExpired()` with a 152-line test re-verifying core's +> maintenance behaviour. Deleted both; the three call sites (startup + +> scheduled cleanup in `app.js`, `/test/removeExpired`) call core directly, +> and the behaviour stays owned + tested once in `engine/maintenance.test.ts`. + +All three items from the first review (2026-06-12) are done. + +### Second review (2026-06-13) — remaining small finds + +A pass over `apps/server` after the cleanups above. Package question answered: +**nothing new should be pulled into a package beyond the already-planned client** +(below) — the read-models (`feeds-json`, `feeds-opml`, the stats projection) each +have a single consumer, so they're hypothetical seams, not real ones; everything +else is host/composition. Two small optional follow-ons remain: + +1. **Concentrate the `/test/*` error-envelope.** All seven routes in + `controllers/test.js` repeat the same `try/catch` → `{ success, error }` + envelope. A small `wrap(handler)` would make the harness contract one seam. + (Test-only API; low stakes.) +2. **Retire the "legacy" framing in `services/stats.js`.** `toLegacyStats` is now + a misnomer — the label fix removed the last legacy field. Rename to + `toStatsView` and optionally export the pure projection so it's testable + without a file write. ## WebSub hub support (bigger — spans core + express) From eed7de7ed5cf4c96ab2a42eaeb46ac57a5a29dbc Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 10:20:05 -0500 Subject: [PATCH 67/90] refactor(server): rename toLegacyStats to toStatsView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stats-label fix removed the last legacy field (feedsChangedLast7Days), so "legacy" no longer describes the projection — it maps core's Stats onto the current wire shape the /stats view and /stats.json expose. Rename the function and clear the adjacent stale "legacy" wording. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/services/stats.js | 8 ++++---- apps/server/services/stats.test.js | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/server/services/stats.js b/apps/server/services/stats.js index 9133bbd..83cfb02 100644 --- a/apps/server/services/stats.js +++ b/apps/server/services/stats.js @@ -2,8 +2,8 @@ const fs = require('fs'); const path = require('path'); const config = require('../config'); -// Protocols the legacy stats shape always reports, even at zero. core only -// includes protocols it actually saw, so we seed these and merge core's counts. +// Protocols the stats view always reports, even at zero. core only includes +// protocols it actually saw, so we seed these and merge core's counts. const KNOWN_PROTOCOLS = ['http-post', 'https-post', 'xml-rpc']; function getStatsFilePath() { @@ -33,7 +33,7 @@ function getStats() { // change window (count + its size in days, so the label can't lie) and report // exactly the three known protocols (seeded at 0, dropping any core might // include outside that set). -function toLegacyStats(coreStats) { +function toStatsView(coreStats) { const protocolBreakdown = {}; for (const protocol of KNOWN_PROTOCOLS) { protocolBreakdown[protocol] = @@ -57,7 +57,7 @@ function toLegacyStats(coreStats) { // touch only the stats file (a host concern) and so don't depend on the store. function createStats({ core }) { async function generateStats() { - const stats = toLegacyStats(await core.generateStats()); + const stats = toStatsView(await core.generateStats()); // Write atomically const filePath = getStatsFilePath(); diff --git a/apps/server/services/stats.test.js b/apps/server/services/stats.test.js index 5811763..0389996 100644 --- a/apps/server/services/stats.test.js +++ b/apps/server/services/stats.test.js @@ -104,7 +104,7 @@ test('generateStats carries the configured change window through', async() => { assert.equal(generated.windowDays, 30); }); -test('generateStats aggregates active subscriptions into the legacy shape', async() => { +test('generateStats aggregates active subscriptions into the stats view', async() => { const { core, generateStats } = setup(); const recent = new Date(Date.now() - DAY_MS); const future = new Date(Date.now() + DAY_MS); From bebd2507557c06a4976d0a95fa2e0fa28a079076 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 10:20:55 -0500 Subject: [PATCH 68/90] docs: mark the toStatsView rename done Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/TODO.md b/TODO.md index 2de84e0..c13371a 100644 --- a/TODO.md +++ b/TODO.md @@ -37,14 +37,17 @@ trust the names over the numbers. > package root; the engine and both protocol plugins delegate. One home, one > fake-timer test suite (abort-on-timeout + clear-on-settle). Coverage 100%. > - **Stopped the stats label lying** — core's `Stats` now carries `windowDays` -> alongside `feedsChangedLastWindow`; `toLegacyStats` passes both through and -> `views/stats.handlebars` interpolates the count ("changed in last +> alongside `feedsChangedLastWindow`; the stats projection passes both through +> and `views/stats.handlebars` interpolates the count ("changed in last > {{windowDays}} days"). Change `feedsChangedWindowDays` and the label follows. > - **Collapsed the `remove-expired-subscriptions` pass-through** — the service > was `() => core.removeExpired()` with a 152-line test re-verifying core's > maintenance behaviour. Deleted both; the three call sites (startup + > scheduled cleanup in `app.js`, `/test/removeExpired`) call core directly, > and the behaviour stays owned + tested once in `engine/maintenance.test.ts`. +> - **Retired the "legacy" framing in `services/stats.js`** — `toLegacyStats` +> became a misnomer once the label fix dropped the last legacy field; renamed +> to `toStatsView` and cleared the adjacent stale wording. All three items from the first review (2026-06-12) are done. @@ -54,16 +57,12 @@ A pass over `apps/server` after the cleanups above. Package question answered: **nothing new should be pulled into a package beyond the already-planned client** (below) — the read-models (`feeds-json`, `feeds-opml`, the stats projection) each have a single consumer, so they're hypothetical seams, not real ones; everything -else is host/composition. Two small optional follow-ons remain: +else is host/composition. One small optional follow-on remains: 1. **Concentrate the `/test/*` error-envelope.** All seven routes in `controllers/test.js` repeat the same `try/catch` → `{ success, error }` envelope. A small `wrap(handler)` would make the harness contract one seam. (Test-only API; low stakes.) -2. **Retire the "legacy" framing in `services/stats.js`.** `toLegacyStats` is now - a misnomer — the label fix removed the last legacy field. Rename to - `toStatsView` and optionally export the pure projection so it's testable - without a file write. ## WebSub hub support (bigger — spans core + express) From 5f62675f22fd826c2886bf544b9a964417726d2d Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 10:27:03 -0500 Subject: [PATCH 69/90] refactor(server): concentrate the /test/* response envelope in wrap() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All seven test-API routes repeated the same try/catch: render { success: true, ...fields } on success, a 500 { success: false, error } on throw. Lift that contract into one wrap(handler) — each route returns its payload fields (or nothing) and owns only its own logic. Net -26 lines; behaviour-preserving (verified the success, merged-field, and error envelopes against an in-process mount). Controllers stay covered by the e2e suite per the recorded no-HTTP-unit-tests decision. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/controllers/test.js | 132 +++++++++++++------------------- 1 file changed, 53 insertions(+), 79 deletions(-) diff --git a/apps/server/controllers/test.js b/apps/server/controllers/test.js index e61c2f0..2429af2 100644 --- a/apps/server/controllers/test.js +++ b/apps/server/controllers/test.js @@ -25,6 +25,20 @@ function resourceFromInput(feedUrl, raw) { }); } +// Every /test/* route shares one envelope: a handler that returns the payload +// fields (or nothing) becomes `{ success: true, ...fields }`, and any throw +// becomes a 500 `{ success: false, error }`. The handler owns only its own +// logic; the success/failure contract lives here, once. +function wrap(handler) { + return async(req, res) => { + try { + res.json({ success: true, ...(await handler(req)) }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }; +} + // Built with an injected core (prod file-backed or in-memory in tests). The // /test/* API drives core's narrow seed/snapshot seam — it never reaches into // the store. Reads find the feed in the listFeeds() snapshot; writes go through @@ -44,91 +58,51 @@ function createTestController({ core }) { ); } - router.post('/clear', async(req, res) => { - try { - await core.clearFeeds(); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } - }); + router.post('/clear', wrap(async() => { + await core.clearFeeds(); + })); - router.post('/setResource', async(req, res) => { - try { - const { feedUrl, resource } = req.body; - await core.seedResource( - feedUrl, - resourceFromInput(feedUrl, resource) - ); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } - }); + router.post('/setResource', wrap(async(req) => { + const { feedUrl, resource } = req.body; + await core.seedResource(feedUrl, resourceFromInput(feedUrl, resource)); + })); - router.post('/getResource', async(req, res) => { - try { - const { feedUrl } = req.body; - const entry = await findEntry(feedUrl); - const resource = entry?.resource ?? null; - res.json({ - success: true, - found: resource !== null, - resource: resource !== null ? resourceToJson(resource) : null - }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } - }); + router.post('/getResource', wrap(async(req) => { + const { feedUrl } = req.body; + const entry = await findEntry(feedUrl); + const resource = entry?.resource ?? null; + return { + found: resource !== null, + resource: resource !== null ? resourceToJson(resource) : null + }; + })); - router.post('/setSubscriptions', async(req, res) => { - try { - const { feedUrl, subscriptions } = req.body; - await core.seedSubscriptions( - feedUrl, - subscriptions.map(subscriptionFromJson) - ); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } - }); + router.post('/setSubscriptions', wrap(async(req) => { + const { feedUrl, subscriptions } = req.body; + await core.seedSubscriptions( + feedUrl, + subscriptions.map(subscriptionFromJson) + ); + })); - router.post('/getSubscriptions', async(req, res) => { - try { - const { feedUrl } = req.body; - const entry = await findEntry(feedUrl); - res.json({ - success: true, - found: entry !== undefined, - subscriptions: entry - ? entry.subscriptions.map(subscriptionToJson) - : [] - }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } - }); + router.post('/getSubscriptions', wrap(async(req) => { + const { feedUrl } = req.body; + const entry = await findEntry(feedUrl); + return { + found: entry !== undefined, + subscriptions: entry + ? entry.subscriptions.map(subscriptionToJson) + : [] + }; + })); - router.post('/getData', async(req, res) => { - try { - res.json({ - success: true, - data: toFeedsJson(await core.listFeeds()) - }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } - }); + router.post('/getData', wrap(async() => ({ + data: toFeedsJson(await core.listFeeds()) + }))); - router.post('/removeExpired', async(req, res) => { - try { - const result = await core.removeExpired(); - res.json({ success: true, result }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } - }); + router.post('/removeExpired', wrap(async() => ({ + result: await core.removeExpired() + }))); return router; } From 9df1e81958afc117c2c8d0f159a45d9985714d89 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 10:27:28 -0500 Subject: [PATCH 70/90] docs: close out the architecture-cleanup reviews Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/TODO.md b/TODO.md index c13371a..111130a 100644 --- a/TODO.md +++ b/TODO.md @@ -48,21 +48,17 @@ trust the names over the numbers. > - **Retired the "legacy" framing in `services/stats.js`** — `toLegacyStats` > became a misnomer once the label fix dropped the last legacy field; renamed > to `toStatsView` and cleared the adjacent stale wording. - -All three items from the first review (2026-06-12) are done. - -### Second review (2026-06-13) — remaining small finds - -A pass over `apps/server` after the cleanups above. Package question answered: -**nothing new should be pulled into a package beyond the already-planned client** -(below) — the read-models (`feeds-json`, `feeds-opml`, the stats projection) each -have a single consumer, so they're hypothetical seams, not real ones; everything -else is host/composition. One small optional follow-on remains: - -1. **Concentrate the `/test/*` error-envelope.** All seven routes in - `controllers/test.js` repeat the same `try/catch` → `{ success, error }` - envelope. A small `wrap(handler)` would make the harness contract one seam. - (Test-only API; low stakes.) +> - **Concentrated the `/test/*` response envelope** — all seven test-API routes +> repeated the same `try/catch` → `{ success, ...fields }` / 500 `{ success, +> error }`. Lifted into one `wrap(handler)`; each route returns just its +> payload. Behaviour-preserving (verified via an in-process mount). + +Both reviews are fully closed out. The first review's (2026-06-12) three items +and the second review's (2026-06-13) finds are all done. The package question is +settled: **nothing new should be pulled into a package beyond the already-planned +client** (below) — the read-models (`feeds-json`, `feeds-opml`, the stats +projection) each have a single consumer, so they're hypothetical seams, not real +ones; everything else in `apps/server` is host/composition. ## WebSub hub support (bigger — spans core + express) From 47349720ca83de3b944d0803702aabc838ee9dfd Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 10:29:54 -0500 Subject: [PATCH 71/90] docs: drop the redundant done section from TODO The architecture-cleanup section was entirely completed; per the TODO's own "open work only" rule, that history belongs in git. Fold a brief "done, see git" mention into the preamble and keep the one forward-looking conclusion (client is the only warranted apps/server extraction) on the client section. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 72 ++++++++++----------------------------------------------- 1 file changed, 12 insertions(+), 60 deletions(-) diff --git a/TODO.md b/TODO.md index 111130a..30f1c1c 100644 --- a/TODO.md +++ b/TODO.md @@ -1,64 +1,13 @@ # TODO — rsscloud-server: open work -Outstanding + future work only. The `apps/server` → `@rsscloud/core` migration and -the on-disk **v2 format unification** (disk == domain model; `legacy-store-shape.js` -deleted; one-way legacy importer in `file-store.ts`) are both done — their history -lives in git (`feat(core):` / `refactor(server):` commits), not here. Per CLAUDE.md: -build with the `tdd` skill (red-green vertical slices); Conventional Commits enforced. -Architecture decisions are recorded in `docs/adr/`; domain vocabulary in `CONTEXT.md`. - -## Architecture cleanup (deepening opportunities) - -From an architecture review (2026-06-12). Ordered by payoff. Vocabulary: a -**shallow** module's interface is nearly as complex as its implementation; a -**deep** one hides a lot of behaviour behind a small interface; a **seam** is a -place behaviour can be swapped without editing in place; **leakage** is one -module's internals crossing a seam into another. File/line refs will drift — -trust the names over the numbers. - -> **Done (history in git):** -> - **Sealed the `core.store` port** — `RssCloudCore` exposes a narrow -> `listFeeds()` read seam plus `seedResource()` / `seedSubscriptions()` / -> `clearFeeds()` for the test API; `readonly store` is gone from the -> interface. All state access is concentrated in core. -> - **Opened the HTTP-edge seam** — controllers are a -> `createControllers({ core })` factory, so importing them no longer boots a -> `FileStore`. The near-identical `res.render` shells (`home`, `ping-form`, -> `please-notify-form`) collapsed into a table-driven mount, and `/docs` + -> `/LICENSE.md` share one `renderMarkdownDoc` service. HTTP behaviour stays -> covered by the e2e suite (no new HTTP-level unit tests, by decision). -> - **Lifted the maintenance jobs out of `create-core.ts`** — `removeExpired` -> and `generateStats` now live in `engine/maintenance.ts` as functions over -> `(store, config, now)`; the factory delegates. Shrank the factory ~143 -> lines and the maintenance suite exercises them directly against an -> in-memory store (one core-level smoke test per delegation). Coverage 100%. -> - **Collapsed the three `fetchWithTimeout` copies** — the abort/`clearTimeout` -> dance now lives in one `fetchWithTimeout(doFetch, ms, url, init)` at the -> package root; the engine and both protocol plugins delegate. One home, one -> fake-timer test suite (abort-on-timeout + clear-on-settle). Coverage 100%. -> - **Stopped the stats label lying** — core's `Stats` now carries `windowDays` -> alongside `feedsChangedLastWindow`; the stats projection passes both through -> and `views/stats.handlebars` interpolates the count ("changed in last -> {{windowDays}} days"). Change `feedsChangedWindowDays` and the label follows. -> - **Collapsed the `remove-expired-subscriptions` pass-through** — the service -> was `() => core.removeExpired()` with a 152-line test re-verifying core's -> maintenance behaviour. Deleted both; the three call sites (startup + -> scheduled cleanup in `app.js`, `/test/removeExpired`) call core directly, -> and the behaviour stays owned + tested once in `engine/maintenance.test.ts`. -> - **Retired the "legacy" framing in `services/stats.js`** — `toLegacyStats` -> became a misnomer once the label fix dropped the last legacy field; renamed -> to `toStatsView` and cleared the adjacent stale wording. -> - **Concentrated the `/test/*` response envelope** — all seven test-API routes -> repeated the same `try/catch` → `{ success, ...fields }` / 500 `{ success, -> error }`. Lifted into one `wrap(handler)`; each route returns just its -> payload. Behaviour-preserving (verified via an in-process mount). - -Both reviews are fully closed out. The first review's (2026-06-12) three items -and the second review's (2026-06-13) finds are all done. The package question is -settled: **nothing new should be pulled into a package beyond the already-planned -client** (below) — the read-models (`feeds-json`, `feeds-opml`, the stats -projection) each have a single consumer, so they're hypothetical seams, not real -ones; everything else in `apps/server` is host/composition. +Outstanding + future work only. Completed work lives in git history, not here — +that includes the `apps/server` → `@rsscloud/core` migration, the on-disk **v2 +format unification** (disk == domain model; `legacy-store-shape.js` deleted; one-way +legacy importer in `file-store.ts`), and the 2026-06 architecture-cleanup passes +across `@rsscloud/core` and `apps/server` (`refactor(core):` / `refactor(server):` +commits). Per CLAUDE.md: build with the `tdd` skill (red-green vertical slices); +Conventional Commits enforced. Architecture decisions are recorded in `docs/adr/`; +domain vocabulary in `CONTEXT.md`. ## WebSub hub support (bigger — spans core + express) @@ -101,7 +50,10 @@ HMAC, and leases. Pull `apps/server/client.js` into two layers, mirroring how `apps/server` consumes `@rsscloud/core`. It already works against the live server — this is extraction + -packaging, not a behaviour change. +packaging, not a behaviour change. The 2026-06-13 architecture review settled that +**this is the only extraction `apps/server` warrants** — the other read-models +(`feeds-json`, `feeds-opml`, the stats projection) have one consumer each, and the +rest is host/composition. *`@rsscloud/client` (`packages/client`)* — the **subscriber+publisher end** of the protocol (core is the hub end); reusable + published: From 2ec42432af393f156c52889775196924047ad2bf Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 10:39:36 -0500 Subject: [PATCH 72/90] docs: scope the client extraction with a shared @rsscloud/xml-rpc codec Replace the client sketch with the agreed three-workspace plan: a generic @rsscloud/xml-rpc codec both core and client build on, a published @rsscloud/client (factory API, full subscriber+publisher), and a private apps/client harness. Codec-first slicing keeps every step green. Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 109 ++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 78 insertions(+), 31 deletions(-) diff --git a/TODO.md b/TODO.md index 30f1c1c..5a71403 100644 --- a/TODO.md +++ b/TODO.md @@ -46,34 +46,81 @@ so new `Subscription` fields ride along with no extra mapping. express `websub` factory + an e2e callback handshake. Defer content distribution, HMAC, and leases. -## Client app + `@rsscloud/client` package (bigger) - -Pull `apps/server/client.js` into two layers, mirroring how `apps/server` consumes -`@rsscloud/core`. It already works against the live server — this is extraction + -packaging, not a behaviour change. The 2026-06-13 architecture review settled that -**this is the only extraction `apps/server` warrants** — the other read-models -(`feeds-json`, `feeds-opml`, the stats projection) have one consumer each, and the -rest is host/composition. - -*`@rsscloud/client` (`packages/client`)* — the **subscriber+publisher end** of the -protocol (core is the hub end); reusable + published: -- **Subscriber:** send `pleaseNotify` (REST + XML-RPC), do the http-post challenge - echo, receive/parse notifications (http-post + XML-RPC `rssCloud.notify`). -- **Publisher:** send `ping` (REST + XML-RPC); optional helper to emit a feed with the - `` element. The wire builders inline in `client.js` today move here. - -*`apps/client` (private, like `apps/e2e`)* — the interactive dev harness on the -package: the existing Express UI (Subscribe/Ping controls + request log, serving test -feeds). The manual counterpart to the automated e2e. - -*Notes:* -- **Wire format** is now known in core (hub side) and the e2e helpers; decide a shared - module vs. independent reimplementation in the client (leaning independent, per the - keep-e2e-independent convention). -- **WebSub-ready:** grows a WebSub subscriber/publisher once that lands. -- **Workspace:** `apps/client` private (not release-tracked); `packages/client` - release-tracked + 100% coverage, like `@rsscloud/core`. - -*First slice:* lift the wire builders + subscribe/ping calls into `packages/client` -with tests, thin `client.js` to a UI shell on the package, then relocate it to -`apps/client`. +## Client extraction + shared XML-RPC codec (bigger — three workspaces) + +Pull `apps/server/client.js` (568 lines: protocol wire logic + outbound calls + a +stateful Express dev UI) into a published `@rsscloud/client` package plus a private +`apps/client` harness — mirroring how `apps/server` consumes `@rsscloud/core`. It +already works against the live server, so this is extraction + packaging, not a +behaviour change. The 2026-06-13 architecture review settled that **this is the only +extraction `apps/server` warrants** — the other read-models (`feeds-json`, +`feeds-opml`, the stats projection) have one consumer each, and the rest is +host/composition. + +The wire logic is **not** independently reimplemented (the e2e convention is for the +test harness, not two libraries). Instead a focused **`@rsscloud/xml-rpc`** package +holds the generic XML-RPC codec that both core (hub) and client (subscriber/publisher) +build on — two production consumers, a real seam (delete it and encode/decode reappears +in both). Depending on `@rsscloud/core` for this would be the wrong direction (the +client would drag in the whole hub engine for ~150 lines of codec). e2e stays +independent — deliberately not a consumer. + +``` +@rsscloud/xml-rpc generic XML-RPC codec (no rssCloud semantics) + ├─ @rsscloud/core hub: parse pleaseNotify/ping, emit success/fault, build notify + └─ @rsscloud/client subscriber/publisher: build pleaseNotify/ping, parse notify, emit success +apps/client private Express dev harness on @rsscloud/client +``` + +### `@rsscloud/xml-rpc` (new, published, 100% coverage) +Generic XML-RPC only — no `rssCloud.*` knowledge. +- `parseMethodCall(xml)` + `parseMethodResponse(xml)` (the decoder moves out of core). +- `buildMethodCall(methodName, params)` — **new** typed-value builder (core's current + encoders are ad-hoc/untyped). +- `buildMethodResponse(value)` / `serializeFault(code, str)`. +- An **`XmlRpcValue` model** (`i4`/`string`/`boolean`/`array`/`struct`/…) — the one real + design piece; worth a short grill at that slice. +- Core refactor: `xml-rpc-dispatcher` + `xml-rpc-plugin` import from it; core keeps only + its rssCloud-specific shapes as thin wrappers. Core's 25 codec tests move here; core + stays green + 100%. + +### `@rsscloud/client` (new, published, 100% coverage) — factory API, full subscriber+publisher +``` +createRssCloudClient({ serverUrl, fetch? }) → { + pleaseNotify({ protocol, callback: { domain, port, path }, feedUrl }) → { status, body } + ping({ protocol, feedUrl }) → { status, body } +} +``` +Plus exported pure helpers: `parseNotify(body)` → feedUrl, `buildNotifyResponse()` (the +boolean XML), challenge echo, and `renderCloudFeed({ feedName, link, items, cloud })` +(RSS-with-``). The rssCloud XML-RPC builders (`buildPleaseNotifyCall`, +`buildPingCall`) live here over `@rsscloud/xml-rpc`'s `buildMethodCall`; REST bodies are +trivial `URLSearchParams`. + +### `apps/client` (private, like `apps/e2e`) +The Express UI, request log, feed store, and routes — consuming `@rsscloud/client`. The +`client` script + `body-parser`/`xmlbuilder`/`morgan` deps move here out of `apps/server`. + +### Slices (codec-first → no transient duplication; each stays green) +1. Scaffold `@rsscloud/xml-rpc`; add to `release-please-config.json`. +2. Move the decoder (`parseMethodCall` + value decode) + its tests into it. +3. Add the typed `XmlRpcValue` builder (`buildMethodCall`/`buildMethodResponse`/fault), TDD. +4. Refactor core onto it — dispatcher/plugin import the generic codec; core green + 100%. +5. Scaffold `@rsscloud/client`; add to release config. +6. Client XML-RPC builders (`buildPleaseNotifyCall`/`buildPingCall`) on the shared codec, TDD. +7. Client send layer — `createRssCloudClient` with injected `fetch`, REST + XML-RPC, TDD. +8. Client receive + feed emit — `parseNotify`/`buildNotifyResponse`/challenge + `renderCloudFeed`, TDD. +9. Thin `client.js` onto `@rsscloud/client` (still in `apps/server`, still runs). +10. Relocate to `apps/client` (new private workspace; drop the script/deps from `apps/server`; + handle `express.static('public')`). + +Steps 1–4 are a self-contained, shippable improvement (core slims, no client yet); 5–10 +build and land the client. + +*Workspace/release:* `pnpm-workspace.yaml` already globs `packages/*` + `apps/*` (no +change). `release-please-config.json` gains `packages/xml-rpc` + `packages/client` +(components `xml-rpc` / `client`); `apps/client` stays untracked like `apps/e2e`. Cascade: +`xml-rpc → core → express → server`, and `xml-rpc → client`. + +*Notes:* `CONTEXT.md` gains subscriber/publisher-end vocabulary during implementation. +**WebSub-ready:** the client grows a WebSub subscriber/publisher once that lands. From 02a9ce7788dd4ccb51acd653534c1fdf37e46a0f Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 10:44:00 -0500 Subject: [PATCH 73/90] chore(xml-rpc): scaffold the @rsscloud/xml-rpc package New published package mirroring the @rsscloud/express setup (tsup esm+cjs+dts, vitest at 100% coverage, tsconfig/eslint). Placeholder index for now; the generic XML-RPC codec lands in the next slices. Registered with release-please (config + manifest) so it tracks as its own component. Co-Authored-By: Claude Opus 4.8 (1M context) --- .release-please-manifest.json | 1 + packages/xml-rpc/LICENSE.md | 20 ++++++++ packages/xml-rpc/README.md | 15 ++++++ packages/xml-rpc/eslint.config.mjs | 17 +++++++ packages/xml-rpc/package.json | 73 ++++++++++++++++++++++++++++ packages/xml-rpc/src/index.test.ts | 8 +++ packages/xml-rpc/src/index.ts | 1 + packages/xml-rpc/tsconfig.build.json | 8 +++ packages/xml-rpc/tsconfig.json | 27 ++++++++++ packages/xml-rpc/tsup.config.ts | 13 +++++ packages/xml-rpc/vitest.config.ts | 19 ++++++++ pnpm-lock.yaml | 34 +++++++++++++ release-please-config.json | 6 +++ 13 files changed, 242 insertions(+) create mode 100644 packages/xml-rpc/LICENSE.md create mode 100644 packages/xml-rpc/README.md create mode 100644 packages/xml-rpc/eslint.config.mjs create mode 100644 packages/xml-rpc/package.json create mode 100644 packages/xml-rpc/src/index.test.ts create mode 100644 packages/xml-rpc/src/index.ts create mode 100644 packages/xml-rpc/tsconfig.build.json create mode 100644 packages/xml-rpc/tsconfig.json create mode 100644 packages/xml-rpc/tsup.config.ts create mode 100644 packages/xml-rpc/vitest.config.ts diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 4464681..0a2f68c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,6 @@ { "apps/server": "4.0.0", + "packages/xml-rpc": "0.0.0", "packages/core": "0.0.0", "packages/express": "0.0.0" } diff --git a/packages/xml-rpc/LICENSE.md b/packages/xml-rpc/LICENSE.md new file mode 100644 index 0000000..d81b273 --- /dev/null +++ b/packages/xml-rpc/LICENSE.md @@ -0,0 +1,20 @@ +Copyright (c) 2015-2026 Andrew Shell + +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. diff --git a/packages/xml-rpc/README.md b/packages/xml-rpc/README.md new file mode 100644 index 0000000..bbd2887 --- /dev/null +++ b/packages/xml-rpc/README.md @@ -0,0 +1,15 @@ +# @rsscloud/xml-rpc + +A small, generic [XML-RPC](http://xmlrpc.com/) codec — parse and build +`methodCall` / `methodResponse` documents — shared by the +[rssCloud](https://github.com/rsscloud/rsscloud-server) packages. + +`@rsscloud/core` (the hub) and `@rsscloud/client` (the subscriber/publisher end) +both speak XML-RPC over the `/RPC2` front door; this package is the one home for +the encode/decode dance, so a wire bug has a single place to live. It holds no +rssCloud semantics of its own — callers map their own `rssCloud.*` method shapes +onto it. + +## License + +MIT — see [LICENSE.md](./LICENSE.md). diff --git a/packages/xml-rpc/eslint.config.mjs b/packages/xml-rpc/eslint.config.mjs new file mode 100644 index 0000000..c6a5ff4 --- /dev/null +++ b/packages/xml-rpc/eslint.config.mjs @@ -0,0 +1,17 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist', 'coverage'] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + languageOptions: { + ecmaVersion: 2023, + sourceType: 'module', + parserOptions: { + tsconfigRootDir: import.meta.dirname + } + } + } +); diff --git a/packages/xml-rpc/package.json b/packages/xml-rpc/package.json new file mode 100644 index 0000000..923f276 --- /dev/null +++ b/packages/xml-rpc/package.json @@ -0,0 +1,73 @@ +{ + "name": "@rsscloud/xml-rpc", + "version": "0.0.0", + "description": "Generic XML-RPC codec for the rssCloud packages — parse and build methodCall/methodResponse documents", + "license": "MIT", + "author": "Andrew Shell ", + "repository": { + "type": "git", + "url": "https://github.com/rsscloud/rsscloud-server.git", + "directory": "packages/xml-rpc" + }, + "homepage": "https://github.com/rsscloud/rsscloud-server/tree/main/packages/xml-rpc#readme", + "bugs": { + "url": "https://github.com/rsscloud/rsscloud-server/issues" + }, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "LICENSE.md", + "CHANGELOG.md" + ], + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=22" + }, + "sideEffects": false, + "dependencies": { + "xml2js": "^0.6.2" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "clean": "rm -rf dist coverage", + "lint": "eslint src", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "coverage": "vitest run --coverage", + "check": "pnpm run typecheck && pnpm run lint && pnpm run test", + "prepack": "pnpm run build", + "prepublishOnly": "pnpm run build" + }, + "devDependencies": { + "@eslint/js": "^9.18.0", + "@types/node": "^22.10.0", + "@types/xml2js": "^0.4.14", + "@vitest/coverage-v8": "^3.2.4", + "eslint": "^9.18.0", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "typescript-eslint": "^8.20.0", + "vitest": "^3.2.4" + } +} diff --git a/packages/xml-rpc/src/index.test.ts b/packages/xml-rpc/src/index.test.ts new file mode 100644 index 0000000..d2d3a4d --- /dev/null +++ b/packages/xml-rpc/src/index.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest'; +import * as api from './index.js'; + +describe('@rsscloud/xml-rpc public API', () => { + it('exposes a version', () => { + expect(typeof api.version).toBe('string'); + }); +}); diff --git a/packages/xml-rpc/src/index.ts b/packages/xml-rpc/src/index.ts new file mode 100644 index 0000000..5e29e9c --- /dev/null +++ b/packages/xml-rpc/src/index.ts @@ -0,0 +1 @@ +export const version = '0.0.0'; diff --git a/packages/xml-rpc/tsconfig.build.json b/packages/xml-rpc/tsconfig.build.json new file mode 100644 index 0000000..9d88b88 --- /dev/null +++ b/packages/xml-rpc/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "coverage", "node_modules", "src/**/*.test.ts"] +} diff --git a/packages/xml-rpc/tsconfig.json b/packages/xml-rpc/tsconfig.json new file mode 100644 index 0000000..cd657bd --- /dev/null +++ b/packages/xml-rpc/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "moduleDetection": "force", + "isolatedModules": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts", "*.config.ts"], + "exclude": ["dist", "coverage", "node_modules"] +} diff --git a/packages/xml-rpc/tsup.config.ts b/packages/xml-rpc/tsup.config.ts new file mode 100644 index 0000000..0f29864 --- /dev/null +++ b/packages/xml-rpc/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + treeshake: true, + splitting: false, + target: 'node22', + tsconfig: './tsconfig.build.json' +}); diff --git a/packages/xml-rpc/vitest.config.ts b/packages/xml-rpc/vitest.config.ts new file mode 100644 index 0000000..f571462 --- /dev/null +++ b/packages/xml-rpc/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts'], + thresholds: { + lines: 100, + functions: 100, + branches: 100, + statements: 100 + } + } + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59fa42f..1737ce6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,40 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1) + packages/xml-rpc: + dependencies: + xml2js: + specifier: ^0.6.2 + version: 0.6.2 + devDependencies: + '@eslint/js': + specifier: ^9.18.0 + version: 9.39.4 + '@types/node': + specifier: ^22.10.0 + version: 22.19.19 + '@types/xml2js': + specifier: ^0.4.14 + version: 0.4.14 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1)) + eslint: + specifier: ^9.18.0 + version: 9.39.4(jiti@2.6.1) + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.15)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + typescript-eslint: + specifier: ^8.20.0 + version: 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1) + packages: '@ampproject/remapping@2.3.0': diff --git a/release-please-config.json b/release-please-config.json index 052c414..b3a826e 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -7,6 +7,12 @@ "component": "server", "changelog-path": "CHANGELOG.md" }, + "packages/xml-rpc": { + "release-type": "node", + "component": "xml-rpc", + "changelog-path": "CHANGELOG.md", + "package-name": "@rsscloud/xml-rpc" + }, "packages/core": { "release-type": "node", "component": "core", From db8658b7025635068ff93ad45c65d62b8092d2d7 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 10:46:46 -0500 Subject: [PATCH 74/90] feat(xml-rpc): add the methodCall decoder Port the generic XML-RPC value decoder + parseMethodCall (all value types: i4/int/double, string, boolean, dateTime.iso8601, base64, struct, array, and the untyped/unknown fallbacks) with its full spec. This is the decode half of the shared codec core and client will build on; core is re-pointed at it in a later slice. 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/xml-rpc/src/index.test.ts | 4 +- packages/xml-rpc/src/index.ts | 2 +- packages/xml-rpc/src/parse.test.ts | 227 +++++++++++++++++++++++++++++ packages/xml-rpc/src/parse.ts | 118 +++++++++++++++ 4 files changed, 348 insertions(+), 3 deletions(-) create mode 100644 packages/xml-rpc/src/parse.test.ts create mode 100644 packages/xml-rpc/src/parse.ts diff --git a/packages/xml-rpc/src/index.test.ts b/packages/xml-rpc/src/index.test.ts index d2d3a4d..775e4ef 100644 --- a/packages/xml-rpc/src/index.test.ts +++ b/packages/xml-rpc/src/index.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import * as api from './index.js'; describe('@rsscloud/xml-rpc public API', () => { - it('exposes a version', () => { - expect(typeof api.version).toBe('string'); + it('exports the methodCall decoder', () => { + expect(typeof api.parseMethodCall).toBe('function'); }); }); diff --git a/packages/xml-rpc/src/index.ts b/packages/xml-rpc/src/index.ts index 5e29e9c..61beb0e 100644 --- a/packages/xml-rpc/src/index.ts +++ b/packages/xml-rpc/src/index.ts @@ -1 +1 @@ -export const version = '0.0.0'; +export { parseMethodCall, type MethodCall } from './parse.js'; diff --git a/packages/xml-rpc/src/parse.test.ts b/packages/xml-rpc/src/parse.test.ts new file mode 100644 index 0000000..f3f2c6f --- /dev/null +++ b/packages/xml-rpc/src/parse.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import { parseMethodCall } from './parse.js'; + +describe('parseMethodCall', () => { + it('decodes the method name and a single string param', async () => { + const xml = ` + + rssCloud.ping + + http://feed.example/rss + + `; + + const call = await parseMethodCall(xml); + + expect(call.methodName).toBe('rssCloud.ping'); + expect(call.params).toEqual(['http://feed.example/rss']); + }); + + it('decodes an untyped (bare string) value', async () => { + const xml = + 'm' + + 'bare text' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual(['bare text']); + }); + + it('decodes several positional params in order', async () => { + const xml = + 'm' + + 'first' + + 'second' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual(['first', 'second']); + }); + + it('throws when the methodCall element is missing', async () => { + await expect(parseMethodCall('')).rejects.toThrow( + 'missing "methodCall" element' + ); + }); + + it('throws when the methodName element is missing', async () => { + const xml = ''; + + await expect(parseMethodCall(xml)).rejects.toThrow( + 'missing "methodName" element' + ); + }); + + it('rejects malformed XML', async () => { + await expect(parseMethodCall('')).rejects.toThrow(); + }); + + it('decodes a methodCall with no params into an empty list', async () => { + const xml = + 'rssCloud.hello'; + + const call = await parseMethodCall(xml); + + expect(call.methodName).toBe('rssCloud.hello'); + expect(call.params).toEqual([]); + }); + + it('decodes numeric types (i4, int, double) as numbers', async () => { + const xml = + 'm' + + '7' + + '5' + + '1.5' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([7, 5, 1.5]); + }); + + it('decodes booleans from textual "true" and numeric "1"', async () => { + const xml = + 'm' + + 'true' + + '1' + + '0' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([true, true, false]); + }); + + it('decodes a valid dateTime.iso8601 into a Date', async () => { + const xml = + 'm' + + '2013-01-02T03:04:05Z' + + '' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params[0]).toBeInstanceOf(Date); + expect((call.params[0] as Date).toISOString()).toBe( + '2013-01-02T03:04:05.000Z' + ); + }); + + it('keeps an unparseable dateTime.iso8601 as the raw string', async () => { + const xml = + 'm' + + 'not-a-date' + + '' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual(['not-a-date']); + }); + + it('decodes base64 into a UTF-8 string', async () => { + const encoded = Buffer.from('héllo', 'utf8').toString('base64'); + const xml = + 'm' + + `${encoded}` + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual(['héllo']); + }); + + it('decodes a struct with several members into an object', async () => { + const xml = + 'm' + + '' + + 'hostrpc.example' + + '' + + 'port80' + + '' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([{ host: 'rpc.example', port: 80 }]); + }); + + it('decodes a single-member struct into an object', async () => { + const xml = + 'm' + + 'onlyone' + + '' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([{ only: 'one' }]); + }); + + it('decodes an empty struct into an empty object', async () => { + const xml = + 'm' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([{}]); + }); + + it('decodes an array with several elements', async () => { + const xml = + 'm' + + '' + + 'a' + + 'b' + + '' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([['a', 'b']]); + }); + + it('coerces a single-element array', async () => { + const xml = + 'm' + + 'only' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([['only']]); + }); + + it('decodes an empty array into an empty list', async () => { + const xml = + 'm' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([[]]); + }); + + it('decodes an array node with no data into an empty list', async () => { + const xml = + 'm' + + ''; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([[]]); + }); + + it('returns the raw node for an unknown value type', async () => { + const xml = + 'm' + + 'x'; + + const call = await parseMethodCall(xml); + + expect(call.params).toEqual([{ unknownType: 'x' }]); + }); +}); diff --git a/packages/xml-rpc/src/parse.ts b/packages/xml-rpc/src/parse.ts new file mode 100644 index 0000000..70e4594 --- /dev/null +++ b/packages/xml-rpc/src/parse.ts @@ -0,0 +1,118 @@ +import { Parser } from 'xml2js'; + +/** A decoded XML-RPC `methodCall`: its method name and positional params. */ +export interface MethodCall { + methodName: string; + params: unknown[]; +} + +/** xml2js node as a record, or null for primitives (the `explicitArray:false` shape). */ +function asRecord(value: unknown): Record | null { + return typeof value === 'object' + ? (value as Record | null) + : null; +} + +/** Normalise xml2js's "scalar | single | array" into an array. */ +function toArray(value: unknown): unknown[] { + if (value === undefined) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +/** Decode `dateTime.iso8601` with native `Date`; keep the raw text if unparseable. */ +function decodeDate(raw: unknown): Date | string { + const text = String(raw); + const date = new Date(text); + return Number.isNaN(date.getTime()) ? text : date; +} + +/** Decode a `` node into a plain object keyed by member name. */ +function decodeStruct(node: unknown): Record { + const rec = asRecord(node); + const members = toArray(rec === null ? undefined : rec['member']); + const out: Record = {}; + for (const member of members) { + const m = member as Record; + out[String(m['name'])] = decode(member); + } + return out; +} + +/** Decode an `` node into a list, recursively decoding each ``. */ +function decodeArray(node: unknown): unknown[] { + const rec = asRecord(node); + const data = rec === null ? null : asRecord(rec['data']); + const values = toArray(data === null ? undefined : data['value']); + return values.map(decode); +} + +/** Decode a typed `` body (`{ : ... }`) by its single type tag. */ +function decodeTyped(typed: Record): unknown { + for (const tag of Object.keys(typed)) { + switch (tag) { + case 'i4': + case 'int': + case 'double': + return Number(typed[tag]); + case 'string': + return typed[tag]; + case 'boolean': + return typed[tag] === 'true' || Boolean(Number(typed[tag])); + case 'dateTime.iso8601': + return decodeDate(typed[tag]); + case 'base64': + return Buffer.from(String(typed[tag]), 'base64').toString( + 'utf8' + ); + case 'struct': + return decodeStruct(typed[tag]); + case 'array': + return decodeArray(typed[tag]); + } + } + return typed; +} + +/** Decode one `` (or its wrapping ``/``) into a JS value. */ +function decode(node: unknown): unknown { + const wrapper = asRecord(node); + const value = + wrapper !== null && 'value' in wrapper ? wrapper['value'] : node; + + const typed = asRecord(value); + if (typed === null) { + return value; + } + + return decodeTyped(typed); +} + +/** + * Decode an XML-RPC `methodCall` document. Throws on malformed XML or a missing + * `methodCall`/`methodName` element. + */ +export async function parseMethodCall(xml: string): Promise { + const parser = new Parser({ explicitArray: false }); + const parsed = (await parser.parseStringPromise(xml)) as Record< + string, + unknown + >; + + const methodCall = asRecord(parsed['methodCall']); + if (methodCall === null) { + throw new Error('Bad XML-RPC call, missing "methodCall" element.'); + } + + const methodName = methodCall['methodName']; + if (methodName === undefined) { + throw new Error('Bad XML-RPC call, missing "methodName" element.'); + } + + const paramsNode = asRecord(methodCall['params']); + const paramRaw = paramsNode === null ? undefined : paramsNode['param']; + const params = toArray(paramRaw).map(decode); + + return { methodName: String(methodName), params }; +} From 7f0361770a3e05c3c036c0c950a9df86397385e7 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 10:49:37 -0500 Subject: [PATCH 75/90] feat(xml-rpc): add the typed methodCall/response builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tagged XmlRpcValue model with constructor helpers (str/i4/int/bool/array/ struct) feeds buildMethodCall, buildMethodResponse, and buildFault. Explicit typing is deliberate — i4-vs-number and array-vs-scalar can't be inferred, and the rssCloud shapes depend on it (port is i4, urlList is array). Tests round-trip through parseMethodCall where possible. Only the value types core and client actually emit are built. 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/xml-rpc/src/build.test.ts | 142 +++++++++++++++++++++++++++++ packages/xml-rpc/src/build.ts | 107 ++++++++++++++++++++++ packages/xml-rpc/src/index.test.ts | 11 ++- packages/xml-rpc/src/index.ts | 12 +++ 4 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 packages/xml-rpc/src/build.test.ts create mode 100644 packages/xml-rpc/src/build.ts diff --git a/packages/xml-rpc/src/build.test.ts b/packages/xml-rpc/src/build.test.ts new file mode 100644 index 0000000..042b563 --- /dev/null +++ b/packages/xml-rpc/src/build.test.ts @@ -0,0 +1,142 @@ +import { Parser } from 'xml2js'; +import { describe, expect, it } from 'vitest'; +import { + array, + bool, + buildFault, + buildMethodCall, + buildMethodResponse, + i4, + int, + str, + struct +} from './build.js'; +import { parseMethodCall } from './parse.js'; + +function reparse(xml: string): Promise { + return new Parser({ explicitArray: false }).parseStringPromise(xml); +} + +describe('buildMethodCall', () => { + it('builds a methodCall that round-trips to its name and a string param', async () => { + const call = await parseMethodCall( + buildMethodCall('rssCloud.ping', [str('https://feed.example/rss')]) + ); + + expect(call.methodName).toBe('rssCloud.ping'); + expect(call.params).toEqual(['https://feed.example/rss']); + }); + + it('round-trips i4 and int as numbers', async () => { + const call = await parseMethodCall( + buildMethodCall('m', [i4(5337), int(80)]) + ); + + expect(call.params).toEqual([5337, 80]); + }); + + it('round-trips a boolean', async () => { + const call = await parseMethodCall( + buildMethodCall('m', [bool(true), bool(false)]) + ); + + expect(call.params).toEqual([true, false]); + }); + + it('round-trips an array of strings', async () => { + const call = await parseMethodCall( + buildMethodCall('m', [array([str('a'), str('b')])]) + ); + + expect(call.params).toEqual([['a', 'b']]); + }); + + it('round-trips an empty array', async () => { + const call = await parseMethodCall( + buildMethodCall('m', [array([])]) + ); + + expect(call.params).toEqual([[]]); + }); + + it('round-trips a struct keyed by member name', async () => { + const call = await parseMethodCall( + buildMethodCall('m', [ + struct({ host: str('rpc.example'), port: i4(80) }) + ]) + ); + + expect(call.params).toEqual([{ host: 'rpc.example', port: 80 }]); + }); + + it('preserves positional param order', async () => { + const call = await parseMethodCall( + buildMethodCall('rssCloud.pleaseNotify', [ + str('rssCloud.notify'), + i4(9000), + str('/RPC2'), + str('xml-rpc'), + array([str('https://feed.example/rss')]), + str('example.com') + ]) + ); + + expect(call.methodName).toBe('rssCloud.pleaseNotify'); + expect(call.params).toEqual([ + 'rssCloud.notify', + 9000, + '/RPC2', + 'xml-rpc', + ['https://feed.example/rss'], + 'example.com' + ]); + }); + + it('builds a no-param methodCall that decodes to an empty list', async () => { + const call = await parseMethodCall(buildMethodCall('rssCloud.hello', [])); + + expect(call.methodName).toBe('rssCloud.hello'); + expect(call.params).toEqual([]); + }); +}); + +describe('buildMethodResponse', () => { + it('emits a boolean methodResponse of 1 for true', async () => { + const parsed = (await reparse(buildMethodResponse(bool(true)))) as { + methodResponse: { params: { param: { value: { boolean: string } } } }; + }; + + expect(parsed.methodResponse.params.param.value.boolean).toBe('1'); + }); + + it('emits a boolean methodResponse of 0 for false', async () => { + const parsed = (await reparse(buildMethodResponse(bool(false)))) as { + methodResponse: { params: { param: { value: { boolean: string } } } }; + }; + + expect(parsed.methodResponse.params.param.value.boolean).toBe('0'); + }); +}); + +describe('buildFault', () => { + it('emits the faultCode/faultString struct, entities surviving', async () => { + const message = 'Bad protocol & stuff'; + const parsed = (await reparse(buildFault(4, message))) as { + methodResponse: { + fault: { + value: { + struct: { + member: { name: string; value: Record }[]; + }; + }; + }; + }; + }; + + const members = parsed.methodResponse.fault.value.struct.member; + expect(members[0]?.name).toBe('faultCode'); + expect(members[0]?.value['int']).toBe('4'); + expect(members[1]?.name).toBe('faultString'); + expect(members[1]?.value['string']).toBe(message); + }); +}); diff --git a/packages/xml-rpc/src/build.ts b/packages/xml-rpc/src/build.ts new file mode 100644 index 0000000..9f3339d --- /dev/null +++ b/packages/xml-rpc/src/build.ts @@ -0,0 +1,107 @@ +import { Builder } from 'xml2js'; + +/** + * A typed XML-RPC value to encode. Built with the constructor helpers below + * (`str`, `i4`, …) so callers stay explicit about the wire type — `i4` vs a + * bare number can't be inferred, and the rssCloud shapes depend on it (a port + * is an `i4`, a urlList is an `array`). + */ +export type XmlRpcValue = + | { type: 'string'; value: string } + | { type: 'i4'; value: number } + | { type: 'int'; value: number } + | { type: 'boolean'; value: boolean } + | { type: 'array'; value: XmlRpcValue[] } + | { type: 'struct'; value: Record }; + +/** A `` value. */ +export function str(value: string): XmlRpcValue { + return { type: 'string', value }; +} + +/** An `` value (32-bit integer). */ +export function i4(value: number): XmlRpcValue { + return { type: 'i4', value }; +} + +/** An `` value (synonym of i4; kept distinct to preserve emitted tags). */ +export function int(value: number): XmlRpcValue { + return { type: 'int', value }; +} + +/** A `` value, emitted as `1`/`0`. */ +export function bool(value: boolean): XmlRpcValue { + return { type: 'boolean', value }; +} + +/** An `` of values. */ +export function array(value: XmlRpcValue[]): XmlRpcValue { + return { type: 'array', value }; +} + +/** A `` keyed by member name. */ +export function struct(value: Record): XmlRpcValue { + return { type: 'struct', value }; +} + +/** Convert a typed value into the xml2js node shape for a `` body. */ +function toNode(v: XmlRpcValue): unknown { + switch (v.type) { + case 'string': + return { string: v.value }; + case 'i4': + return { i4: v.value }; + case 'int': + return { int: v.value }; + case 'boolean': + return { boolean: v.value ? 1 : 0 }; + case 'array': + return { array: { data: { value: v.value.map(toNode) } } }; + case 'struct': + return { + struct: { + member: Object.entries(v.value).map(([name, member]) => ({ + name, + value: toNode(member) + })) + } + }; + } +} + +/** Build an XML-RPC `methodCall` document for `methodName` with positional params. */ +export function buildMethodCall( + methodName: string, + params: XmlRpcValue[] +): string { + const methodCall: Record = { methodName }; + if (params.length > 0) { + methodCall['params'] = { + param: params.map(p => ({ value: toNode(p) })) + }; + } + return new Builder().buildObject({ methodCall }); +} + +/** Build an XML-RPC `methodResponse` carrying a single value. */ +export function buildMethodResponse(value: XmlRpcValue): string { + return new Builder().buildObject({ + methodResponse: { params: { param: { value: toNode(value) } } } + }); +} + +/** Build an XML-RPC fault `methodResponse` with the standard struct. */ +export function buildFault(code: number, faultString: string): string { + return new Builder().buildObject({ + methodResponse: { + fault: { + value: toNode( + struct({ + faultCode: int(code), + faultString: str(faultString) + }) + ) + } + } + }); +} diff --git a/packages/xml-rpc/src/index.test.ts b/packages/xml-rpc/src/index.test.ts index 775e4ef..3e77f6a 100644 --- a/packages/xml-rpc/src/index.test.ts +++ b/packages/xml-rpc/src/index.test.ts @@ -2,7 +2,16 @@ import { describe, it, expect } from 'vitest'; import * as api from './index.js'; describe('@rsscloud/xml-rpc public API', () => { - it('exports the methodCall decoder', () => { + it('exports the decoder and the builders', () => { expect(typeof api.parseMethodCall).toBe('function'); + expect(typeof api.buildMethodCall).toBe('function'); + expect(typeof api.buildMethodResponse).toBe('function'); + expect(typeof api.buildFault).toBe('function'); + }); + + it('exports the value constructors', () => { + for (const name of ['str', 'i4', 'int', 'bool', 'array', 'struct']) { + expect(typeof api[name as keyof typeof api]).toBe('function'); + } }); }); diff --git a/packages/xml-rpc/src/index.ts b/packages/xml-rpc/src/index.ts index 61beb0e..e248426 100644 --- a/packages/xml-rpc/src/index.ts +++ b/packages/xml-rpc/src/index.ts @@ -1 +1,13 @@ export { parseMethodCall, type MethodCall } from './parse.js'; +export { + array, + bool, + buildFault, + buildMethodCall, + buildMethodResponse, + i4, + int, + str, + struct, + type XmlRpcValue +} from './build.js'; From 37a209a6caefcac81d62ce7dc5df0f81c5ddff9c Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 10:54:53 -0500 Subject: [PATCH 76/90] refactor(core): build XML-RPC on the shared @rsscloud/xml-rpc codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic decoder + response builders moved to @rsscloud/xml-rpc; core now imports parseMethodCall and renders responses via buildMethodResponse/ buildFault (wrapped as the dispatcher's serializeSuccess/serializeFault). The one rssCloud-specific shape — the notify methodCall's untyped string param — stays core-local (inlined into the xml-rpc plugin) to preserve its exact wire bytes. core's generic codec tests now live in the shared package; output is byte-identical and coverage stays 100%. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/package.json | 1 + .../core/src/protocols/xml-rpc-codec.test.ts | 300 ------------------ packages/core/src/protocols/xml-rpc-codec.ts | 160 ---------- .../core/src/protocols/xml-rpc-dispatcher.ts | 19 +- .../core/src/protocols/xml-rpc-plugin.test.ts | 2 +- packages/core/src/protocols/xml-rpc-plugin.ts | 16 +- pnpm-lock.yaml | 3 + 7 files changed, 35 insertions(+), 466 deletions(-) delete mode 100644 packages/core/src/protocols/xml-rpc-codec.test.ts delete mode 100644 packages/core/src/protocols/xml-rpc-codec.ts diff --git a/packages/core/package.json b/packages/core/package.json index 459d404..8230f72 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -44,6 +44,7 @@ }, "sideEffects": false, "dependencies": { + "@rsscloud/xml-rpc": "workspace:*", "xml2js": "^0.6.2" }, "scripts": { diff --git a/packages/core/src/protocols/xml-rpc-codec.test.ts b/packages/core/src/protocols/xml-rpc-codec.test.ts deleted file mode 100644 index 46fd7dc..0000000 --- a/packages/core/src/protocols/xml-rpc-codec.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { Parser } from 'xml2js'; -import { describe, expect, it } from 'vitest'; -import { - buildNotifyCall, - parseMethodCall, - serializeFault, - serializeSuccess -} from './xml-rpc-codec.js'; - -function reparse(xml: string): Promise { - return new Parser({ explicitArray: false }).parseStringPromise(xml); -} - -describe('parseMethodCall', () => { - it('decodes the method name and a single string param', async () => { - const xml = ` - - rssCloud.ping - - http://feed.example/rss - - `; - - const call = await parseMethodCall(xml); - - expect(call.methodName).toBe('rssCloud.ping'); - expect(call.params).toEqual(['http://feed.example/rss']); - }); - - it('decodes an untyped (bare string) value', async () => { - const xml = - 'm' + - 'bare text' + - ''; - - const call = await parseMethodCall(xml); - - expect(call.params).toEqual(['bare text']); - }); - - it('decodes several positional params in order', async () => { - const xml = - 'm' + - 'first' + - 'second' + - ''; - - const call = await parseMethodCall(xml); - - expect(call.params).toEqual(['first', 'second']); - }); - - it('throws when the methodCall element is missing', async () => { - await expect(parseMethodCall('')).rejects.toThrow( - 'missing "methodCall" element' - ); - }); - - it('throws when the methodName element is missing', async () => { - const xml = ''; - - await expect(parseMethodCall(xml)).rejects.toThrow( - 'missing "methodName" element' - ); - }); - - it('rejects malformed XML', async () => { - await expect(parseMethodCall('')).rejects.toThrow(); - }); - - it('decodes a methodCall with no params into an empty list', async () => { - const xml = 'rssCloud.hello'; - - const call = await parseMethodCall(xml); - - expect(call.methodName).toBe('rssCloud.hello'); - expect(call.params).toEqual([]); - }); - - it('decodes numeric types (i4, int, double) as numbers', async () => { - const xml = - 'm' + - '7' + - '5' + - '1.5' + - ''; - - const call = await parseMethodCall(xml); - - expect(call.params).toEqual([7, 5, 1.5]); - }); - - it('decodes booleans from textual "true" and numeric "1"', async () => { - const xml = - 'm' + - 'true' + - '1' + - '0' + - ''; - - const call = await parseMethodCall(xml); - - expect(call.params).toEqual([true, true, false]); - }); - - it('decodes a valid dateTime.iso8601 into a Date', async () => { - const xml = - 'm' + - '2013-01-02T03:04:05Z' + - '' + - ''; - - const call = await parseMethodCall(xml); - - expect(call.params[0]).toBeInstanceOf(Date); - expect((call.params[0] as Date).toISOString()).toBe( - '2013-01-02T03:04:05.000Z' - ); - }); - - it('keeps an unparseable dateTime.iso8601 as the raw string', async () => { - const xml = - 'm' + - 'not-a-date' + - '' + - ''; - - const call = await parseMethodCall(xml); - - expect(call.params).toEqual(['not-a-date']); - }); - - it('decodes base64 into a UTF-8 string', async () => { - const encoded = Buffer.from('héllo', 'utf8').toString('base64'); - const xml = - 'm' + - `${encoded}` + - ''; - - const call = await parseMethodCall(xml); - - expect(call.params).toEqual(['héllo']); - }); - - it('decodes a struct with several members into an object', async () => { - const xml = - 'm' + - '' + - 'hostrpc.example' + - '' + - 'port80' + - '' + - ''; - - const call = await parseMethodCall(xml); - - expect(call.params).toEqual([{ host: 'rpc.example', port: 80 }]); - }); - - it('decodes a single-member struct into an object', async () => { - const xml = - 'm' + - 'onlyone' + - '' + - ''; - - const call = await parseMethodCall(xml); - - expect(call.params).toEqual([{ only: 'one' }]); - }); - - it('decodes an empty struct into an empty object', async () => { - const xml = - 'm' + - ''; - - const call = await parseMethodCall(xml); - - expect(call.params).toEqual([{}]); - }); - - it('decodes an array with several elements', async () => { - const xml = - 'm' + - '' + - 'a' + - 'b' + - '' + - ''; - - const call = await parseMethodCall(xml); - - expect(call.params).toEqual([['a', 'b']]); - }); - - it('coerces a single-element array', async () => { - const xml = - 'm' + - 'only' + - ''; - - const call = await parseMethodCall(xml); - - expect(call.params).toEqual([['only']]); - }); - - it('decodes an empty array into an empty list', async () => { - const xml = - 'm' + - ''; - - const call = await parseMethodCall(xml); - - expect(call.params).toEqual([[]]); - }); - - it('decodes an array node with no data into an empty list', async () => { - const xml = - 'm' + - ''; - - const call = await parseMethodCall(xml); - - expect(call.params).toEqual([[]]); - }); - - it('returns the raw node for an unknown value type', async () => { - const xml = - 'm' + - 'x'; - - const call = await parseMethodCall(xml); - - expect(call.params).toEqual([{ unknownType: 'x' }]); - }); -}); - -describe('serializeSuccess', () => { - it('emits a methodResponse boolean of 1 for true', async () => { - const parsed = (await reparse(serializeSuccess(true))) as { - methodResponse: { params: { param: { value: { boolean: string } } } }; - }; - - expect(parsed.methodResponse.params.param.value.boolean).toBe('1'); - }); - - it('emits a methodResponse boolean of 0 for false', async () => { - const parsed = (await reparse(serializeSuccess(false))) as { - methodResponse: { params: { param: { value: { boolean: string } } } }; - }; - - expect(parsed.methodResponse.params.param.value.boolean).toBe('0'); - }); -}); - -describe('serializeFault', () => { - it('emits the faultCode/faultString struct, entities surviving', async () => { - const message = 'Bad protocol & stuff'; - const parsed = (await reparse(serializeFault(4, message))) as { - methodResponse: { - fault: { - value: { - struct: { - member: { name: string; value: Record }[]; - }; - }; - }; - }; - }; - - const members = parsed.methodResponse.fault.value.struct.member; - expect(members[0]?.name).toBe('faultCode'); - expect(members[0]?.value['int']).toBe('4'); - expect(members[1]?.name).toBe('faultString'); - expect(members[1]?.value['string']).toBe(message); - }); -}); - -describe('buildNotifyCall', () => { - it('builds a methodCall that round-trips back to its procedure and url', async () => { - const xml = buildNotifyCall( - 'myCloud.notify', - 'https://feed.example/rss' - ); - - const call = await parseMethodCall(xml); - - expect(call.methodName).toBe('myCloud.notify'); - expect(call.params).toEqual(['https://feed.example/rss']); - }); - - it('emits an empty methodName when the procedure is blank', async () => { - const call = await parseMethodCall( - buildNotifyCall('', 'https://feed.example/rss') - ); - - expect(call.methodName).toBe(''); - expect(call.params).toEqual(['https://feed.example/rss']); - }); -}); diff --git a/packages/core/src/protocols/xml-rpc-codec.ts b/packages/core/src/protocols/xml-rpc-codec.ts deleted file mode 100644 index 5d561eb..0000000 --- a/packages/core/src/protocols/xml-rpc-codec.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { Builder, Parser } from 'xml2js'; - -/** A decoded XML-RPC `methodCall`: its method name and positional params. */ -export interface MethodCall { - methodName: string; - params: unknown[]; -} - -/** xml2js node as a record, or null for primitives (the `explicitArray:false` shape). */ -function asRecord(value: unknown): Record | null { - return typeof value === 'object' - ? (value as Record | null) - : null; -} - -/** Normalise xml2js's "scalar | single | array" into an array. */ -function toArray(value: unknown): unknown[] { - if (value === undefined) { - return []; - } - return Array.isArray(value) ? value : [value]; -} - -/** Decode `dateTime.iso8601` with native `Date`; keep the raw text if unparseable. */ -function decodeDate(raw: unknown): Date | string { - const text = String(raw); - const date = new Date(text); - return Number.isNaN(date.getTime()) ? text : date; -} - -/** Decode a `` node into a plain object keyed by member name. */ -function decodeStruct(node: unknown): Record { - const rec = asRecord(node); - const members = toArray(rec === null ? undefined : rec['member']); - const out: Record = {}; - for (const member of members) { - const m = member as Record; - out[String(m['name'])] = decode(member); - } - return out; -} - -/** Decode an `` node into a list, recursively decoding each ``. */ -function decodeArray(node: unknown): unknown[] { - const rec = asRecord(node); - const data = rec === null ? null : asRecord(rec['data']); - const values = toArray(data === null ? undefined : data['value']); - return values.map(decode); -} - -/** Decode a typed `` body (`{ : ... }`) by its single type tag. */ -function decodeTyped(typed: Record): unknown { - for (const tag of Object.keys(typed)) { - switch (tag) { - case 'i4': - case 'int': - case 'double': - return Number(typed[tag]); - case 'string': - return typed[tag]; - case 'boolean': - return typed[tag] === 'true' || Boolean(Number(typed[tag])); - case 'dateTime.iso8601': - return decodeDate(typed[tag]); - case 'base64': - return Buffer.from(String(typed[tag]), 'base64').toString( - 'utf8' - ); - case 'struct': - return decodeStruct(typed[tag]); - case 'array': - return decodeArray(typed[tag]); - } - } - return typed; -} - -/** Decode one `` (or its wrapping ``/``) into a JS value. */ -function decode(node: unknown): unknown { - const wrapper = asRecord(node); - const value = - wrapper !== null && 'value' in wrapper ? wrapper['value'] : node; - - const typed = asRecord(value); - if (typed === null) { - return value; - } - - return decodeTyped(typed); -} - -/** - * Decode an XML-RPC `methodCall` document. Throws on malformed XML or a missing - * `methodCall`/`methodName` element. - */ -export async function parseMethodCall(xml: string): Promise { - const parser = new Parser({ explicitArray: false }); - const parsed = (await parser.parseStringPromise(xml)) as Record< - string, - unknown - >; - - const methodCall = asRecord(parsed['methodCall']); - if (methodCall === null) { - throw new Error('Bad XML-RPC call, missing "methodCall" element.'); - } - - const methodName = methodCall['methodName']; - if (methodName === undefined) { - throw new Error('Bad XML-RPC call, missing "methodName" element.'); - } - - const paramsNode = asRecord(methodCall['params']); - const paramRaw = paramsNode === null ? undefined : paramsNode['param']; - const params = toArray(paramRaw).map(decode); - - return { methodName: String(methodName), params }; -} - -/** Serialize a `methodResponse` carrying a single boolean param. */ -export function serializeSuccess(success: boolean): string { - return new Builder().buildObject({ - methodResponse: { - params: { - param: { value: { boolean: success ? 1 : 0 } } - } - } - }); -} - -/** - * Build a `methodCall` to `procedure` carrying the resource URL as a single - * untyped (string) param — the rssCloud XML-RPC notify shape. - */ -export function buildNotifyCall(procedure: string, url: string): string { - return new Builder().buildObject({ - methodCall: { - methodName: procedure, - params: { param: { value: url } } - } - }); -} - -/** Serialize a `methodResponse` fault with the standard faultCode/faultString struct. */ -export function serializeFault(code: number, str: string): string { - return new Builder().buildObject({ - methodResponse: { - fault: { - value: { - struct: { - member: [ - { name: 'faultCode', value: { int: code } }, - { name: 'faultString', value: { string: str } } - ] - } - } - } - } - }); -} diff --git a/packages/core/src/protocols/xml-rpc-dispatcher.ts b/packages/core/src/protocols/xml-rpc-dispatcher.ts index 31585d3..05cea8a 100644 --- a/packages/core/src/protocols/xml-rpc-dispatcher.ts +++ b/packages/core/src/protocols/xml-rpc-dispatcher.ts @@ -11,10 +11,21 @@ import { type SubscribeParams } from './subscribe-request.js'; import { - parseMethodCall, - serializeFault, - serializeSuccess -} from './xml-rpc-codec.js'; + bool, + buildFault, + buildMethodResponse, + parseMethodCall +} from '@rsscloud/xml-rpc'; + +/** rssCloud success response: a methodResponse carrying boolean true. */ +function serializeSuccess(ok: boolean): string { + return buildMethodResponse(bool(ok)); +} + +/** rssCloud fault response: the standard faultCode/faultString struct. */ +function serializeFault(code: number, faultString: string): string { + return buildFault(code, faultString); +} /** Per-request context the adapter resolves before handing core the raw XML. */ export interface XmlRpcDispatchContext { diff --git a/packages/core/src/protocols/xml-rpc-plugin.test.ts b/packages/core/src/protocols/xml-rpc-plugin.test.ts index f9886b4..ee53c78 100644 --- a/packages/core/src/protocols/xml-rpc-plugin.test.ts +++ b/packages/core/src/protocols/xml-rpc-plugin.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { DeliveryContext, VerifyContext } from '../engine/plugin.js'; import type { Resource } from '../engine/resource.js'; import type { Subscription } from '../engine/subscription.js'; -import { parseMethodCall } from './xml-rpc-codec.js'; +import { parseMethodCall } from '@rsscloud/xml-rpc'; import { createXmlRpcProtocolPlugin } from './xml-rpc-plugin.js'; const epoch = new Date(0); diff --git a/packages/core/src/protocols/xml-rpc-plugin.ts b/packages/core/src/protocols/xml-rpc-plugin.ts index 6bcdb43..27d0737 100644 --- a/packages/core/src/protocols/xml-rpc-plugin.ts +++ b/packages/core/src/protocols/xml-rpc-plugin.ts @@ -6,7 +6,7 @@ import type { } from '../engine/plugin.js'; import type { Protocol } from '../engine/protocol.js'; import { fetchWithTimeout } from '../fetch-with-timeout.js'; -import { buildNotifyCall } from './xml-rpc-codec.js'; +import { Builder } from 'xml2js'; /** Construction-time dependencies for the rssCloud XML-RPC protocol plugin. */ export interface XmlRpcProtocolPluginOptions { @@ -21,6 +21,20 @@ const XML_RPC_PROTOCOLS: Protocol[] = ['xml-rpc']; /** Fallback request timeout when none is supplied (mirrors the server default). */ const DEFAULT_REQUEST_TIMEOUT_MS = 4000; +/** + * Build the rssCloud notify `methodCall`: the resource URL as a single untyped + * (bare-string) param — the historical rssCloud notify shape. Kept here rather + * than in the generic @rsscloud/xml-rpc builder, which only emits typed values. + */ +function buildNotifyCall(procedure: string, url: string): string { + return new Builder().buildObject({ + methodCall: { + methodName: procedure, + params: { param: { value: url } } + } + }); +} + /** * The rssCloud XML-RPC delivery protocol. Subscribers are notified with a * `methodCall` POST to their `notifyProcedure`. As with Dave's original diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1737ce6..051d3bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: packages/core: dependencies: + '@rsscloud/xml-rpc': + specifier: workspace:* + version: link:../xml-rpc xml2js: specifier: ^0.6.2 version: 0.6.2 From b3fe585722f97f71bdfa98a4a6bb97e0edff487f Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 10:57:26 -0500 Subject: [PATCH 77/90] chore(client): scaffold the @rsscloud/client package New published package for the subscriber/publisher end, mirroring the sibling package setup (tsup esm+cjs+dts, vitest at 100% coverage). Depends on @rsscloud/xml-rpc for its wire codec. Placeholder index for now; builders and the send/receive layers land in the next slices. Registered with release-please. Co-Authored-By: Claude Opus 4.8 (1M context) --- .release-please-manifest.json | 3 +- packages/client/LICENSE.md | 20 ++++++++ packages/client/README.md | 16 +++++++ packages/client/eslint.config.mjs | 17 +++++++ packages/client/package.json | 74 +++++++++++++++++++++++++++++ packages/client/src/index.test.ts | 8 ++++ packages/client/src/index.ts | 1 + packages/client/tsconfig.build.json | 8 ++++ packages/client/tsconfig.json | 27 +++++++++++ packages/client/tsup.config.ts | 13 +++++ packages/client/vitest.config.ts | 19 ++++++++ pnpm-lock.yaml | 37 +++++++++++++++ release-please-config.json | 6 +++ 13 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 packages/client/LICENSE.md create mode 100644 packages/client/README.md create mode 100644 packages/client/eslint.config.mjs create mode 100644 packages/client/package.json create mode 100644 packages/client/src/index.test.ts create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/tsconfig.build.json create mode 100644 packages/client/tsconfig.json create mode 100644 packages/client/tsup.config.ts create mode 100644 packages/client/vitest.config.ts diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0a2f68c..b169f25 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -2,5 +2,6 @@ "apps/server": "4.0.0", "packages/xml-rpc": "0.0.0", "packages/core": "0.0.0", - "packages/express": "0.0.0" + "packages/express": "0.0.0", + "packages/client": "0.0.0" } diff --git a/packages/client/LICENSE.md b/packages/client/LICENSE.md new file mode 100644 index 0000000..d81b273 --- /dev/null +++ b/packages/client/LICENSE.md @@ -0,0 +1,20 @@ +Copyright (c) 2015-2026 Andrew Shell + +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. diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000..99dd2ca --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,16 @@ +# @rsscloud/client + +The **subscriber + publisher end** of the [rssCloud](https://github.com/rsscloud/rsscloud-server) +notification protocol — the mirror of `@rsscloud/core` (the hub end). + +- **Subscriber:** send `pleaseNotify` (REST + XML-RPC), echo the http-post + challenge, and parse incoming notifications. +- **Publisher:** send `ping` (REST + XML-RPC), and emit a feed carrying the + `` element. + +It builds its XML-RPC on the shared [`@rsscloud/xml-rpc`](../xml-rpc) codec and +talks to a hub over an injectable `fetch`, so it has no server dependency. + +## License + +MIT — see [LICENSE.md](./LICENSE.md). diff --git a/packages/client/eslint.config.mjs b/packages/client/eslint.config.mjs new file mode 100644 index 0000000..c6a5ff4 --- /dev/null +++ b/packages/client/eslint.config.mjs @@ -0,0 +1,17 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist', 'coverage'] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + languageOptions: { + ecmaVersion: 2023, + sourceType: 'module', + parserOptions: { + tsconfigRootDir: import.meta.dirname + } + } + } +); diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..87886c0 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,74 @@ +{ + "name": "@rsscloud/client", + "version": "0.0.0", + "description": "The subscriber + publisher end of the rssCloud protocol — pleaseNotify, ping, notification handling, and feed emission", + "license": "MIT", + "author": "Andrew Shell ", + "repository": { + "type": "git", + "url": "https://github.com/rsscloud/rsscloud-server.git", + "directory": "packages/client" + }, + "homepage": "https://github.com/rsscloud/rsscloud-server/tree/main/packages/client#readme", + "bugs": { + "url": "https://github.com/rsscloud/rsscloud-server/issues" + }, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "LICENSE.md", + "CHANGELOG.md" + ], + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=22" + }, + "sideEffects": false, + "dependencies": { + "@rsscloud/xml-rpc": "workspace:*", + "xml2js": "^0.6.2" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "clean": "rm -rf dist coverage", + "lint": "eslint src", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "coverage": "vitest run --coverage", + "check": "pnpm run typecheck && pnpm run lint && pnpm run test", + "prepack": "pnpm run build", + "prepublishOnly": "pnpm run build" + }, + "devDependencies": { + "@eslint/js": "^9.18.0", + "@types/node": "^22.10.0", + "@types/xml2js": "^0.4.14", + "@vitest/coverage-v8": "^3.2.4", + "eslint": "^9.18.0", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "typescript-eslint": "^8.20.0", + "vitest": "^3.2.4" + } +} diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts new file mode 100644 index 0000000..3b235e2 --- /dev/null +++ b/packages/client/src/index.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest'; +import * as api from './index.js'; + +describe('@rsscloud/client public API', () => { + it('exposes a version', () => { + expect(typeof api.version).toBe('string'); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..5e29e9c --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1 @@ +export const version = '0.0.0'; diff --git a/packages/client/tsconfig.build.json b/packages/client/tsconfig.build.json new file mode 100644 index 0000000..9d88b88 --- /dev/null +++ b/packages/client/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "coverage", "node_modules", "src/**/*.test.ts"] +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..cd657bd --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "moduleDetection": "force", + "isolatedModules": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts", "*.config.ts"], + "exclude": ["dist", "coverage", "node_modules"] +} diff --git a/packages/client/tsup.config.ts b/packages/client/tsup.config.ts new file mode 100644 index 0000000..0f29864 --- /dev/null +++ b/packages/client/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + treeshake: true, + splitting: false, + target: 'node22', + tsconfig: './tsconfig.build.json' +}); diff --git a/packages/client/vitest.config.ts b/packages/client/vitest.config.ts new file mode 100644 index 0000000..f571462 --- /dev/null +++ b/packages/client/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts'], + thresholds: { + lines: 100, + functions: 100, + branches: 100, + statements: 100 + } + } + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 051d3bb..30ca8c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,43 @@ importers: specifier: 3.1.14 version: 3.1.14 + packages/client: + dependencies: + '@rsscloud/xml-rpc': + specifier: workspace:* + version: link:../xml-rpc + xml2js: + specifier: ^0.6.2 + version: 0.6.2 + devDependencies: + '@eslint/js': + specifier: ^9.18.0 + version: 9.39.4 + '@types/node': + specifier: ^22.10.0 + version: 22.19.19 + '@types/xml2js': + specifier: ^0.4.14 + version: 0.4.14 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1)) + eslint: + specifier: ^9.18.0 + version: 9.39.4(jiti@2.6.1) + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.15)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + typescript-eslint: + specifier: ^8.20.0 + version: 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1) + packages/core: dependencies: '@rsscloud/xml-rpc': diff --git a/release-please-config.json b/release-please-config.json index b3a826e..edce442 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -24,6 +24,12 @@ "component": "express", "changelog-path": "CHANGELOG.md", "package-name": "@rsscloud/express" + }, + "packages/client": { + "release-type": "node", + "component": "client", + "changelog-path": "CHANGELOG.md", + "package-name": "@rsscloud/client" } } } From 87c95fbb97c58c8caa586a2da587e6e614a00b0c Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 10:59:36 -0500 Subject: [PATCH 78/90] feat(client): add the rssCloud pleaseNotify/ping XML-RPC builders buildPleaseNotifyCall (the six wire params: notifyProcedure, port, path, protocol, urlList, domain) and buildPingCall, both over @rsscloud/xml-rpc's typed buildMethodCall. Tests round-trip through parseMethodCall. 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/src/index.test.ts | 5 ++- packages/client/src/index.ts | 6 ++- packages/client/src/rpc-calls.test.ts | 61 +++++++++++++++++++++++++++ packages/client/src/rpc-calls.ts | 38 +++++++++++++++++ 4 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 packages/client/src/rpc-calls.test.ts create mode 100644 packages/client/src/rpc-calls.ts diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 3b235e2..9ca9252 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect } from 'vitest'; import * as api from './index.js'; describe('@rsscloud/client public API', () => { - it('exposes a version', () => { - expect(typeof api.version).toBe('string'); + it('exports the rssCloud request builders', () => { + expect(typeof api.buildPleaseNotifyCall).toBe('function'); + expect(typeof api.buildPingCall).toBe('function'); }); }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 5e29e9c..9ba6458 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1 +1,5 @@ -export const version = '0.0.0'; +export { + buildPleaseNotifyCall, + buildPingCall, + type PleaseNotifyParams +} from './rpc-calls.js'; diff --git a/packages/client/src/rpc-calls.test.ts b/packages/client/src/rpc-calls.test.ts new file mode 100644 index 0000000..ca24388 --- /dev/null +++ b/packages/client/src/rpc-calls.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { parseMethodCall } from '@rsscloud/xml-rpc'; +import { buildPingCall, buildPleaseNotifyCall } from './rpc-calls.js'; + +describe('buildPingCall', () => { + it('builds a rssCloud.ping methodCall carrying the feed URL', async () => { + const call = await parseMethodCall( + buildPingCall('https://feed.example/rss') + ); + + expect(call.methodName).toBe('rssCloud.ping'); + expect(call.params).toEqual(['https://feed.example/rss']); + }); +}); + +describe('buildPleaseNotifyCall', () => { + it('builds the six pleaseNotify params in wire order', async () => { + const call = await parseMethodCall( + buildPleaseNotifyCall({ + notifyProcedure: 'rssCloud.notify', + port: 9000, + path: '/RPC2', + protocol: 'xml-rpc', + urls: ['https://feed.example/rss'], + domain: 'sub.example' + }) + ); + + expect(call.methodName).toBe('rssCloud.pleaseNotify'); + expect(call.params).toEqual([ + 'rssCloud.notify', + 9000, + '/RPC2', + 'xml-rpc', + ['https://feed.example/rss'], + 'sub.example' + ]); + }); + + it('carries an empty notifyProcedure and multiple urls', async () => { + const call = await parseMethodCall( + buildPleaseNotifyCall({ + notifyProcedure: '', + port: 80, + path: '/notify', + protocol: 'http-post', + urls: ['https://a.example/rss', 'https://b.example/rss'], + domain: 'sub.example' + }) + ); + + expect(call.params).toEqual([ + '', + 80, + '/notify', + 'http-post', + ['https://a.example/rss', 'https://b.example/rss'], + 'sub.example' + ]); + }); +}); diff --git a/packages/client/src/rpc-calls.ts b/packages/client/src/rpc-calls.ts new file mode 100644 index 0000000..02b4a3c --- /dev/null +++ b/packages/client/src/rpc-calls.ts @@ -0,0 +1,38 @@ +import { array, buildMethodCall, i4, str } from '@rsscloud/xml-rpc'; + +/** The wire-shaped inputs to the rssCloud `pleaseNotify` XML-RPC call. */ +export interface PleaseNotifyParams { + /** The procedure the hub should call to notify (e.g. `rssCloud.notify`); + * empty for non-XML-RPC callback protocols. */ + notifyProcedure: string; + /** Port the hub should reach the callback on. */ + port: number; + /** Path of the callback. */ + path: string; + /** Protocol the hub should notify with (`http-post`/`https-post`/`xml-rpc`). */ + protocol: string; + /** The feed URLs to subscribe to (the `urlList`). */ + urls: string[]; + /** The callback's domain. */ + domain: string; +} + +/** + * Build the rssCloud `pleaseNotify` `methodCall` — the six positional params in + * wire order: notifyProcedure, port, path, protocol, urlList, domain. + */ +export function buildPleaseNotifyCall(params: PleaseNotifyParams): string { + return buildMethodCall('rssCloud.pleaseNotify', [ + str(params.notifyProcedure), + i4(params.port), + str(params.path), + str(params.protocol), + array(params.urls.map(str)), + str(params.domain) + ]); +} + +/** Build the rssCloud `ping` `methodCall` carrying a single feed URL. */ +export function buildPingCall(feedUrl: string): string { + return buildMethodCall('rssCloud.ping', [str(feedUrl)]); +} From 13aac3cd031696e5ca9648107fd569043b43f3a6 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 14:23:08 -0500 Subject: [PATCH 79/90] feat(client): add the createRssCloudClient send layer A factory bound to one hub with an injectable fetch: pleaseNotify registers a callback (xml-rpc over /RPC2, http-post/https-post over the REST /pleaseNotify) and ping signals a change (/ping by default, /RPC2 with transport: 'xml-rpc'). Mirrors the reference test client's front-door selection. Fake-fetch tests cover both protocols, both operations, and the construction branches. 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/src/client.test.ts | 142 +++++++++++++++++++++++++++++ packages/client/src/client.ts | 114 +++++++++++++++++++++++ packages/client/src/index.test.ts | 4 + packages/client/src/index.ts | 10 ++ 4 files changed, 270 insertions(+) create mode 100644 packages/client/src/client.test.ts create mode 100644 packages/client/src/client.ts diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts new file mode 100644 index 0000000..21e15e6 --- /dev/null +++ b/packages/client/src/client.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { parseMethodCall } from '@rsscloud/xml-rpc'; +import { createRssCloudClient } from './client.js'; + +interface Captured { + url: string; + init: RequestInit; +} + +function fakeFetch(status = 200, responseBody = 'OK') { + const calls: Captured[] = []; + const fn = (async (url: string | URL, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }); + return new Response(responseBody, { status }); + }) as unknown as typeof fetch; + return { fn, calls }; +} + +function header(init: RequestInit, name: string): string | undefined { + return (init.headers as Record)[name]; +} + +function form(init: RequestInit): URLSearchParams { + return new URLSearchParams(init.body as string); +} + +describe('createRssCloudClient ping', () => { + it('pings over REST by default, posting the feed URL as a form', async () => { + const { fn, calls } = fakeFetch(); + const client = createRssCloudClient({ + serverUrl: 'http://hub.example:5337', + fetch: fn + }); + + const res = await client.ping({ feedUrl: 'https://feed.example/rss' }); + + expect(calls[0]?.url).toBe('http://hub.example:5337/ping'); + expect(calls[0]?.init.method).toBe('POST'); + expect(header(calls[0]!.init, 'Content-Type')).toBe( + 'application/x-www-form-urlencoded' + ); + expect(form(calls[0]!.init).get('url')).toBe( + 'https://feed.example/rss' + ); + expect(res).toEqual({ status: 200, body: 'OK' }); + }); + + it('pings over XML-RPC to /RPC2 when asked', async () => { + const { fn, calls } = fakeFetch(); + const client = createRssCloudClient({ + serverUrl: 'http://hub.example:5337', + fetch: fn + }); + + await client.ping({ + feedUrl: 'https://feed.example/rss', + transport: 'xml-rpc' + }); + + expect(calls[0]?.url).toBe('http://hub.example:5337/RPC2'); + expect(header(calls[0]!.init, 'Content-Type')).toBe('text/xml'); + const call = await parseMethodCall(calls[0]?.init.body as string); + expect(call.methodName).toBe('rssCloud.ping'); + expect(call.params).toEqual(['https://feed.example/rss']); + }); +}); + +describe('createRssCloudClient pleaseNotify', () => { + it('registers an http-post callback over the REST front door', async () => { + const { fn, calls } = fakeFetch(); + const client = createRssCloudClient({ + serverUrl: 'http://hub.example:5337', + fetch: fn + }); + + await client.pleaseNotify({ + protocol: 'http-post', + callback: { domain: 'sub.example', port: 9000, path: '/notify' }, + feedUrl: 'https://feed.example/rss' + }); + + expect(calls[0]?.url).toBe('http://hub.example:5337/pleaseNotify'); + expect(header(calls[0]!.init, 'Content-Type')).toBe( + 'application/x-www-form-urlencoded' + ); + const body = form(calls[0]!.init); + expect(body.get('port')).toBe('9000'); + expect(body.get('path')).toBe('/notify'); + expect(body.get('protocol')).toBe('http-post'); + expect(body.get('url1')).toBe('https://feed.example/rss'); + }); + + it('registers an xml-rpc callback over /RPC2 with the six params', async () => { + const { fn, calls } = fakeFetch(); + const client = createRssCloudClient({ + serverUrl: 'http://hub.example:5337', + fetch: fn + }); + + await client.pleaseNotify({ + protocol: 'xml-rpc', + callback: { domain: 'sub.example', port: 9000, path: '/RPC2' }, + feedUrl: 'https://feed.example/rss' + }); + + expect(calls[0]?.url).toBe('http://hub.example:5337/RPC2'); + expect(header(calls[0]!.init, 'Content-Type')).toBe('text/xml'); + const call = await parseMethodCall(calls[0]?.init.body as string); + expect(call.methodName).toBe('rssCloud.pleaseNotify'); + expect(call.params).toEqual([ + 'rssCloud.notify', + 9000, + '/RPC2', + 'xml-rpc', + ['https://feed.example/rss'], + 'sub.example' + ]); + }); +}); + +describe('createRssCloudClient construction', () => { + it('strips a trailing slash from the server URL', async () => { + const { fn, calls } = fakeFetch(); + const client = createRssCloudClient({ + serverUrl: 'http://hub.example:5337/', + fetch: fn + }); + + await client.ping({ feedUrl: 'https://feed.example/rss' }); + + expect(calls[0]?.url).toBe('http://hub.example:5337/ping'); + }); + + it('defaults to the global fetch when none is injected', () => { + const client = createRssCloudClient({ + serverUrl: 'http://hub.example:5337' + }); + + expect(typeof client.ping).toBe('function'); + expect(typeof client.pleaseNotify).toBe('function'); + }); +}); diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts new file mode 100644 index 0000000..eea73ce --- /dev/null +++ b/packages/client/src/client.ts @@ -0,0 +1,114 @@ +import { buildPingCall, buildPleaseNotifyCall } from './rpc-calls.js'; + +/** The notification protocol a subscriber asks the hub to use. */ +export type NotifyProtocol = 'http-post' | 'https-post' | 'xml-rpc'; + +/** Where the hub should deliver notifications for a subscription. */ +export interface Callback { + domain: string; + port: number; + path: string; +} + +/** Construction-time dependencies for the client. */ +export interface RssCloudClientOptions { + /** Base URL of the rssCloud hub (trailing slash optional). */ + serverUrl: string; + /** Injectable fetch (tests, edge runtimes); defaults to global fetch. */ + fetch?: typeof fetch; +} + +/** A `pleaseNotify` subscription request. */ +export interface PleaseNotifyOptions { + /** Notification protocol; `xml-rpc` registers over `/RPC2`, the rest over REST. */ + protocol: NotifyProtocol; + callback: Callback; + feedUrl: string; +} + +/** A `ping` change signal. */ +export interface PingOptions { + feedUrl: string; + /** Front door to use; `rest` posts to `/ping`, `xml-rpc` to `/RPC2`. Default `rest`. */ + transport?: 'rest' | 'xml-rpc'; +} + +/** The hub's raw reply. */ +export interface RssCloudResponse { + status: number; + body: string; +} + +/** The subscriber + publisher operations against one hub. */ +export interface RssCloudClient { + pleaseNotify(opts: PleaseNotifyOptions): Promise; + ping(opts: PingOptions): Promise; +} + +const FORM_TYPE = 'application/x-www-form-urlencoded'; +const XML_TYPE = 'text/xml'; + +/** + * Build a client bound to one hub. `pleaseNotify`/`ping` choose their front door + * from the request shape (mirroring the reference test client): an `xml-rpc` + * subscription and an `xml-rpc` ping go to `/RPC2`; everything else uses the REST + * front doors. The outbound `fetch` is injectable for tests. + */ +export function createRssCloudClient( + options: RssCloudClientOptions +): RssCloudClient { + const doFetch = options.fetch ?? fetch; + const base = options.serverUrl.replace(/\/$/, ''); + + async function send( + path: string, + contentType: string, + body: string + ): Promise { + const res = await doFetch(`${base}${path}`, { + method: 'POST', + headers: { 'Content-Type': contentType }, + body + }); + return { status: res.status, body: await res.text() }; + } + + async function pleaseNotify( + opts: PleaseNotifyOptions + ): Promise { + if (opts.protocol === 'xml-rpc') { + return send( + '/RPC2', + XML_TYPE, + buildPleaseNotifyCall({ + notifyProcedure: 'rssCloud.notify', + port: opts.callback.port, + path: opts.callback.path, + protocol: opts.protocol, + urls: [opts.feedUrl], + domain: opts.callback.domain + }) + ); + } + const form = new URLSearchParams({ + port: String(opts.callback.port), + path: opts.callback.path, + protocol: opts.protocol, + url1: opts.feedUrl + }); + return send('/pleaseNotify', FORM_TYPE, form.toString()); + } + + async function ping(opts: PingOptions): Promise { + if (opts.transport === 'xml-rpc') { + return send('/RPC2', XML_TYPE, buildPingCall(opts.feedUrl)); + } + return send( + '/ping', + FORM_TYPE, + new URLSearchParams({ url: opts.feedUrl }).toString() + ); + } + + return { pleaseNotify, ping }; +} diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 9ca9252..abb815d 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -2,6 +2,10 @@ import { describe, it, expect } from 'vitest'; import * as api from './index.js'; describe('@rsscloud/client public API', () => { + it('exports the client factory', () => { + expect(typeof api.createRssCloudClient).toBe('function'); + }); + it('exports the rssCloud request builders', () => { expect(typeof api.buildPleaseNotifyCall).toBe('function'); expect(typeof api.buildPingCall).toBe('function'); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 9ba6458..5394785 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,13 @@ +export { + createRssCloudClient, + type Callback, + type NotifyProtocol, + type PingOptions, + type PleaseNotifyOptions, + type RssCloudClient, + type RssCloudClientOptions, + type RssCloudResponse +} from './client.js'; export { buildPleaseNotifyCall, buildPingCall, From 1ac6fa357aaa6798e647a5dff58c80fe0ee75b84 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 14:27:07 -0500 Subject: [PATCH 80/90] feat(client): add notification receive helpers and the cloud feed renderer parseXmlRpcNotify / parseHttpPostNotify extract the changed resource URL from each notification shape, and buildNotifyResponse mints the boolean-true ack a subscriber returns to an XML-RPC notify. renderCloudFeed emits an RSS 2.0 document carrying the element a publisher advertises. Completes the subscriber+publisher surface. 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/src/feed.test.ts | 93 ++++++++++++++++++++++++++++++ packages/client/src/feed.ts | 60 +++++++++++++++++++ packages/client/src/index.test.ts | 10 ++++ packages/client/src/index.ts | 11 ++++ packages/client/src/notify.test.ts | 54 +++++++++++++++++ packages/client/src/notify.ts | 27 +++++++++ 6 files changed, 255 insertions(+) create mode 100644 packages/client/src/feed.test.ts create mode 100644 packages/client/src/feed.ts create mode 100644 packages/client/src/notify.test.ts create mode 100644 packages/client/src/notify.ts diff --git a/packages/client/src/feed.test.ts b/packages/client/src/feed.test.ts new file mode 100644 index 0000000..f7d03db --- /dev/null +++ b/packages/client/src/feed.test.ts @@ -0,0 +1,93 @@ +import { Parser } from 'xml2js'; +import { describe, expect, it } from 'vitest'; +import { renderCloudFeed } from './feed.js'; + +function reparse(xml: string): Promise { + return new Parser({ explicitArray: false }).parseStringPromise(xml); +} + +interface ParsedFeed { + rss: { + $: { version: string }; + channel: { + title: string; + link: string; + description: string; + cloud: { $: Record }; + item: + | { title: string; guid: string; pubDate: string } + | { title: string; guid: string; pubDate: string }[]; + }; + }; +} + +const CLOUD = { + domain: 'localhost', + port: 5337, + path: '/RPC2', + registerProcedure: 'rssCloud.pleaseNotify', + protocol: 'xml-rpc' +}; + +describe('renderCloudFeed', () => { + it('renders a channel with the element and an item', async () => { + const xml = renderCloudFeed({ + title: 'Test Feed', + link: 'http://sub.example:9000/rss-01.xml', + description: 'Test feed for rssCloud', + cloud: CLOUD, + items: [ + { + title: 'Update one', + description: 'first', + pubDate: new Date('2026-01-02T03:04:05Z'), + guid: 'rss-01-0' + } + ] + }); + + const { rss } = (await reparse(xml)) as ParsedFeed; + const channel = rss.channel; + const item = channel.item as { title: string; guid: string; pubDate: string }; + expect(rss.$.version).toBe('2.0'); + expect(channel.title).toBe('Test Feed'); + expect(channel.link).toBe('http://sub.example:9000/rss-01.xml'); + expect(channel.cloud.$).toEqual({ + domain: 'localhost', + port: '5337', + path: '/RPC2', + registerProcedure: 'rssCloud.pleaseNotify', + protocol: 'xml-rpc' + }); + expect(item.title).toBe('Update one'); + expect(item.guid).toBe('rss-01-0'); + expect(item.pubDate).toBe('Fri, 02 Jan 2026 03:04:05 GMT'); + }); + + it('renders multiple items in order', async () => { + const xml = renderCloudFeed({ + title: 'Test Feed', + link: 'http://sub.example:9000/rss-01.xml', + description: 'Test feed for rssCloud', + cloud: CLOUD, + items: [ + { + title: 'one', + description: 'a', + pubDate: new Date('2026-01-02T00:00:00Z'), + guid: 'g0' + }, + { + title: 'two', + description: 'b', + pubDate: new Date('2026-01-03T00:00:00Z'), + guid: 'g1' + } + ] + }); + + const { rss } = (await reparse(xml)) as ParsedFeed; + const items = rss.channel.item as { title: string }[]; + expect(items.map(i => i.title)).toEqual(['one', 'two']); + }); +}); diff --git a/packages/client/src/feed.ts b/packages/client/src/feed.ts new file mode 100644 index 0000000..9fc95c5 --- /dev/null +++ b/packages/client/src/feed.ts @@ -0,0 +1,60 @@ +import { Builder } from 'xml2js'; + +/** The `` element advertising where to subscribe for change notifications. */ +export interface CloudElement { + domain: string; + port: number; + path: string; + registerProcedure: string; + protocol: string; +} + +/** One `` in the rendered feed. */ +export interface FeedItem { + title: string; + description: string; + pubDate: Date; + guid: string; +} + +/** Inputs for {@link renderCloudFeed}. */ +export interface CloudFeedOptions { + title: string; + link: string; + description: string; + cloud: CloudElement; + items: FeedItem[]; +} + +/** + * Render an RSS 2.0 feed carrying a `` element — the document a publisher + * serves so a hub knows where to register for its change notifications. Item + * `pubDate`s are emitted in RFC 822 form. + */ +export function renderCloudFeed(opts: CloudFeedOptions): string { + return new Builder().buildObject({ + rss: { + $: { version: '2.0' }, + channel: { + title: opts.title, + link: opts.link, + description: opts.description, + cloud: { + $: { + domain: opts.cloud.domain, + port: String(opts.cloud.port), + path: opts.cloud.path, + registerProcedure: opts.cloud.registerProcedure, + protocol: opts.cloud.protocol + } + }, + item: opts.items.map(item => ({ + title: item.title, + description: item.description, + pubDate: item.pubDate.toUTCString(), + guid: item.guid + })) + } + } + }); +} diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index abb815d..63fc115 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -10,4 +10,14 @@ describe('@rsscloud/client public API', () => { expect(typeof api.buildPleaseNotifyCall).toBe('function'); expect(typeof api.buildPingCall).toBe('function'); }); + + it('exports the notification receive helpers', () => { + expect(typeof api.parseHttpPostNotify).toBe('function'); + expect(typeof api.parseXmlRpcNotify).toBe('function'); + expect(typeof api.buildNotifyResponse).toBe('function'); + }); + + it('exports the cloud feed renderer', () => { + expect(typeof api.renderCloudFeed).toBe('function'); + }); }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 5394785..e5bd154 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -13,3 +13,14 @@ export { buildPingCall, type PleaseNotifyParams } from './rpc-calls.js'; +export { + buildNotifyResponse, + parseHttpPostNotify, + parseXmlRpcNotify +} from './notify.js'; +export { + renderCloudFeed, + type CloudElement, + type CloudFeedOptions, + type FeedItem +} from './feed.js'; diff --git a/packages/client/src/notify.test.ts b/packages/client/src/notify.test.ts new file mode 100644 index 0000000..6dd7625 --- /dev/null +++ b/packages/client/src/notify.test.ts @@ -0,0 +1,54 @@ +import { Parser } from 'xml2js'; +import { describe, expect, it } from 'vitest'; +import { buildPingCall } from './rpc-calls.js'; +import { + buildNotifyResponse, + parseHttpPostNotify, + parseXmlRpcNotify +} from './notify.js'; + +// A notify methodCall has the same one-URL-param shape as ping, so buildPingCall +// is a convenient way to mint a well-formed notify body. +function notifyXml(url: string): string { + return buildPingCall(url).replace('rssCloud.ping', 'rssCloud.notify'); +} + +describe('parseXmlRpcNotify', () => { + it('extracts the changed resource URL from a notify methodCall', async () => { + const url = await parseXmlRpcNotify(notifyXml('https://feed.example/rss')); + + expect(url).toBe('https://feed.example/rss'); + }); + + it('returns an empty string when the call carries no param', async () => { + const url = await parseXmlRpcNotify( + 'rssCloud.notify' + ); + + expect(url).toBe(''); + }); +}); + +describe('parseHttpPostNotify', () => { + it('extracts the changed resource URL from the form body', () => { + expect(parseHttpPostNotify('url=https%3A%2F%2Ffeed.example%2Frss')).toBe( + 'https://feed.example/rss' + ); + }); + + it('returns an empty string when the form has no url', () => { + expect(parseHttpPostNotify('other=1')).toBe(''); + }); +}); + +describe('buildNotifyResponse', () => { + it('builds a boolean-true methodResponse', async () => { + const parsed = (await new Parser({ + explicitArray: false + }).parseStringPromise(buildNotifyResponse())) as { + methodResponse: { params: { param: { value: { boolean: string } } } }; + }; + + expect(parsed.methodResponse.params.param.value.boolean).toBe('1'); + }); +}); diff --git a/packages/client/src/notify.ts b/packages/client/src/notify.ts new file mode 100644 index 0000000..8107070 --- /dev/null +++ b/packages/client/src/notify.ts @@ -0,0 +1,27 @@ +import { bool, buildMethodResponse, parseMethodCall } from '@rsscloud/xml-rpc'; + +/** + * Extract the changed resource URL from an http-post notification body — the hub + * POSTs `url=` as an `application/x-www-form-urlencoded` form. + */ +export function parseHttpPostNotify(body: string): string { + return new URLSearchParams(body).get('url') ?? ''; +} + +/** + * Extract the changed resource URL from an XML-RPC `rssCloud.notify` methodCall + * (the resource URL is its single param). + */ +export async function parseXmlRpcNotify(xml: string): Promise { + const { params } = await parseMethodCall(xml); + const url = params[0]; + return typeof url === 'string' ? url : ''; +} + +/** + * Build the boolean-true `methodResponse` a subscriber returns to acknowledge an + * XML-RPC notification. + */ +export function buildNotifyResponse(): string { + return buildMethodResponse(bool(true)); +} From 48abae0a2e4fd7937e1f6dc2348bc988e9fcd839 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 14:31:09 -0500 Subject: [PATCH 81/90] refactor(server): thin client.js onto @rsscloud/client Replace the inline XML-RPC builders, the RSS-with- generator, the notify ack, and the raw fetch calls with @rsscloud/client (createRssCloudClient + renderCloudFeed + buildNotifyResponse). client.js is now just the dev-harness UI shell. Verified the render routes (home, feed, challenge echo, /RPC2 ack) against a live instance. Relocation to apps/client follows. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/client.js | 244 ++++++++------------------------------- apps/server/package.json | 1 + pnpm-lock.yaml | 3 + 3 files changed, 55 insertions(+), 193 deletions(-) diff --git a/apps/server/client.js b/apps/server/client.js index cd0f450..0a831a6 100644 --- a/apps/server/client.js +++ b/apps/server/client.js @@ -1,8 +1,12 @@ const bodyParser = require('body-parser'), - builder = require('xmlbuilder'), express = require('express'), morgan = require('morgan'), packageJson = require('./package.json'), + { + createRssCloudClient, + buildNotifyResponse, + renderCloudFeed + } = require('@rsscloud/client'), textParser = bodyParser.text({ type: '*/xml' }), urlencodedParser = bodyParser.urlencoded({ extended: false }); @@ -24,6 +28,10 @@ const clientConfig = { rsscloudServer: 'http://localhost:5337' }; +// All protocol wire work (pleaseNotify/ping calls, the XML-RPC notify ack, and +// feed rendering) lives in @rsscloud/client; this file is just the UI. +const client = createRssCloudClient({ serverUrl: clientConfig.rsscloudServer }); + // In-memory data stores (reset on restart) const requestLog = []; const feedItems = {}; @@ -91,11 +99,6 @@ app.use((req, res, next) => { next(); }); -// Helper function to generate RFC 2822 date string -function toRfc2822(date) { - return date.toUTCString(); -} - // Helper function to escape HTML entities function escapeHtml(text) { return text @@ -106,109 +109,6 @@ function escapeHtml(text) { .replace(/'/g, '''); } -// Helper function to build XML-RPC pleaseNotify call -function buildPleaseNotifyCall(port, path, protocol, feedUrl, domain) { - const notifyProcedure = protocol === 'xml-rpc' ? 'rssCloud.notify' : ''; - - const methodCall = { - methodCall: { - methodName: 'rssCloud.pleaseNotify', - params: { - param: [] - } - } - }; - - // Add notifyProcedure (string) - methodCall.methodCall.params.param.push({ - value: { string: notifyProcedure } - }); - - // Add port (integer) - methodCall.methodCall.params.param.push({ - value: { i4: port } - }); - - // Add path (string) - methodCall.methodCall.params.param.push({ - value: { string: path } - }); - - // Add protocol (string) - methodCall.methodCall.params.param.push({ - value: { string: protocol } - }); - - // Add urlList (array with single URL) - methodCall.methodCall.params.param.push({ - value: { - array: { - data: [ - { - value: { string: feedUrl } - } - ] - } - } - }); - - // Add domain (string) - methodCall.methodCall.params.param.push({ - value: { string: domain } - }); - - return builder.create(methodCall).end({ pretty: true }); -} - -// Helper function to build XML-RPC ping call -function buildPingCall(feedUrl) { - const methodCall = { - methodCall: { - methodName: 'rssCloud.ping', - params: { - param: { - value: { string: feedUrl } - } - } - } - }; - return builder.create(methodCall).end({ pretty: true }); -} - -// Helper function to generate RSS feed XML -function generateRssFeed(feedName) { - const items = feedItems[feedName] || [ - { title: 'initialized', timestamp: new Date() } - ]; - const feedUrl = `http://${clientConfig.domain}:${clientConfig.port}/${feedName}`; - - const rss = { - rss: { - '@version': '2.0', - channel: { - title: `Test Feed: ${feedName}`, - link: feedUrl, - description: 'Test feed for rssCloud', - cloud: { - '@domain': 'localhost', - '@port': '5337', - '@path': '/RPC2', - '@registerProcedure': 'rssCloud.pleaseNotify', - '@protocol': 'xml-rpc' - }, - item: items.map((item, index) => ({ - title: item.title, - description: `Feed item: ${item.title}`, - pubDate: toRfc2822(item.timestamp), - guid: `${feedName}-${index}` - })) - } - } - }; - - return builder.create(rss, { encoding: 'UTF-8' }).end({ pretty: true }); -} - // Helper function to format request body for display function formatBody(body) { if (!body) return ''; @@ -359,45 +259,16 @@ app.post('/subscribe', urlencodedParser, async(req, res) => { const feedUrl = `http://${clientConfig.domain}:${clientConfig.port}/${feedName}`; try { - let response; - - if (useXmlRpc) { - // XML-RPC request - const xmlBody = buildPleaseNotifyCall( - clientConfig.port, - '/RPC2', - 'xml-rpc', - feedUrl, - clientConfig.domain - ); + const { status, body } = await client.pleaseNotify({ + protocol: useXmlRpc ? 'xml-rpc' : 'http-post', + callback: { + domain: clientConfig.domain, + port: clientConfig.port, + path: useXmlRpc ? '/RPC2' : '/notify' + }, + feedUrl + }); - response = await fetch(`${clientConfig.rsscloudServer}/RPC2`, { - method: 'POST', - headers: { 'Content-Type': 'text/xml' }, - body: xmlBody - }); - } else { - // REST request - const formData = new URLSearchParams({ - port: clientConfig.port.toString(), - path: '/notify', - protocol: 'http-post', - url1: feedUrl - }); - - response = await fetch( - `${clientConfig.rsscloudServer}/pleaseNotify`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: formData.toString() - } - ); - } - - const responseText = await response.text(); res.type('html').send(` @@ -406,8 +277,8 @@ app.post('/subscribe', urlencodedParser, async(req, res) => {

Subscribe Result

Feed: ${escapeHtml(feedUrl)}

Protocol: ${useXmlRpc ? 'XML-RPC' : 'REST'}

-

Status: ${response.status}

-
${escapeHtml(responseText)}
+

Status: ${status}

+
${escapeHtml(body)}

Back to client

@@ -446,31 +317,11 @@ app.post('/ping-feed', urlencodedParser, async(req, res) => { }); try { - let response; - - if (useXmlRpc) { - // XML-RPC ping - const xmlBody = buildPingCall(feedUrl); - - response = await fetch(`${clientConfig.rsscloudServer}/RPC2`, { - method: 'POST', - headers: { 'Content-Type': 'text/xml' }, - body: xmlBody - }); - } else { - // REST ping - const formData = new URLSearchParams({ url: feedUrl }); - - response = await fetch(`${clientConfig.rsscloudServer}/ping`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: formData.toString() - }); - } + const { status, body } = await client.ping({ + feedUrl, + transport: useXmlRpc ? 'xml-rpc' : 'rest' + }); - const responseText = await response.text(); res.type('html').send(` @@ -480,8 +331,8 @@ app.post('/ping-feed', urlencodedParser, async(req, res) => {

Feed: ${escapeHtml(feedUrl)}

Protocol: ${useXmlRpc ? 'XML-RPC' : 'REST'}

Items in feed: ${feedItems[feedName].length}

-

Status: ${response.status}

-
${escapeHtml(responseText)}
+

Status: ${status}

+
${escapeHtml(body)}

Back to client

@@ -515,23 +366,8 @@ app.post('/notify', urlencodedParser, (req, res) => { // Route: Handle XML-RPC notifications app.post('/RPC2', textParser, (req, res) => { - // Body is already logged by middleware - // Return XML-RPC success response - const response = builder - .create({ - methodResponse: { - params: { - param: { - value: { - boolean: 1 - } - } - } - } - }) - .end({ pretty: true }); - - res.type('text/xml').send(response); + // Body is already logged by middleware; acknowledge with the boolean reply. + res.type('text/xml').send(buildNotifyResponse()); }); // Route: Serve RSS feeds (must be after specific routes) @@ -544,7 +380,29 @@ app.get('/:feedName', (req, res) => { return; } - const rssXml = generateRssFeed(feedName); + const items = feedItems[feedName] || [ + { title: 'initialized', timestamp: new Date() } + ]; + const feedUrl = `http://${clientConfig.domain}:${clientConfig.port}/${feedName}`; + + const rssXml = renderCloudFeed({ + title: `Test Feed: ${feedName}`, + link: feedUrl, + description: 'Test feed for rssCloud', + cloud: { + domain: 'localhost', + port: 5337, + path: '/RPC2', + registerProcedure: 'rssCloud.pleaseNotify', + protocol: 'xml-rpc' + }, + items: items.map((item, index) => ({ + title: item.title, + description: `Feed item: ${item.title}`, + pubDate: item.timestamp, + guid: `${feedName}-${index}` + })) + }); res.type('application/rss+xml').send(rssXml); }); diff --git a/apps/server/package.json b/apps/server/package.json index 189abd5..0301032 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,6 +16,7 @@ "author": "Andrew Shell ", "license": "MIT", "dependencies": { + "@rsscloud/client": "workspace:*", "@rsscloud/core": "workspace:*", "@rsscloud/express": "workspace:*", "body-parser": "^2.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30ca8c2..bd48654 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,9 @@ importers: apps/server: dependencies: + '@rsscloud/client': + specifier: workspace:* + version: link:../../packages/client '@rsscloud/core': specifier: workspace:* version: link:../../packages/core From fe32370e99fcef86c714a2cedeec8dad41ea542b Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 14:34:15 -0500 Subject: [PATCH 82/90] refactor(server): relocate the client harness to a private apps/client Move client.js out of apps/server into a new private @rsscloud/client-app workspace (the manual counterpart to apps/e2e), and drop the now-unused client script, body-parser, and @rsscloud/client dependency from apps/server. The root `client` script now targets the new app. Verified the harness runs from its new home (UI, feed, /RPC2 ack); full workspace build, typecheck, lint, and unit tests all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/{server => client}/client.js | 0 apps/client/package.json | 28 ++++++++++++++++++++++++++++ apps/server/package.json | 3 --- package.json | 2 +- pnpm-lock.yaml | 31 +++++++++++++++++++++++++------ 5 files changed, 54 insertions(+), 10 deletions(-) rename apps/{server => client}/client.js (100%) create mode 100644 apps/client/package.json diff --git a/apps/server/client.js b/apps/client/client.js similarity index 100% rename from apps/server/client.js rename to apps/client/client.js diff --git a/apps/client/package.json b/apps/client/package.json new file mode 100644 index 0000000..fed8370 --- /dev/null +++ b/apps/client/package.json @@ -0,0 +1,28 @@ +{ + "name": "@rsscloud/client-app", + "version": "0.0.0", + "private": true, + "description": "Interactive dev harness for the rssCloud client (Subscribe/Ping UI + request log)", + "main": "client.js", + "scripts": { + "start": "node --use_strict client.js", + "dev": "nodemon --use_strict client.js", + "lint": "eslint --fix *.js" + }, + "engines": { + "node": ">=22" + }, + "author": "Andrew Shell ", + "license": "MIT", + "dependencies": { + "@rsscloud/client": "workspace:*", + "body-parser": "^2.2.2", + "express": "^4.22.2", + "morgan": "^1.10.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.4.0", + "nodemon": "3.1.14" + } +} diff --git a/apps/server/package.json b/apps/server/package.json index 0301032..3405c50 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -6,7 +6,6 @@ "scripts": { "start": "node --use_strict app.js", "dev": "nodemon --use_strict --ignore data/ ./app.js", - "client": "nodemon --use_strict --ignore data/ ./client.js", "test": "node --test services/*.test.js", "lint": "eslint --fix controllers/ services/ *.js" }, @@ -16,10 +15,8 @@ "author": "Andrew Shell ", "license": "MIT", "dependencies": { - "@rsscloud/client": "workspace:*", "@rsscloud/core": "workspace:*", "@rsscloud/express": "workspace:*", - "body-parser": "^2.2.2", "cors": "^2.8.6", "dayjs": "^1.11.20", "dotenv": "^17.4.2", diff --git a/package.json b/package.json index 52072c7..98ecf30 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "rssCloud monorepo", "scripts": { "start": "pnpm --filter @rsscloud/server run dev", - "client": "pnpm --filter @rsscloud/server run client", + "client": "pnpm --filter @rsscloud/client-app run dev", "build": "turbo run build", "lint": "turbo run lint", "typecheck": "turbo run typecheck", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd48654..5a2efcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,31 @@ importers: specifier: ^2.9.14 version: 2.9.14 + apps/client: + dependencies: + '@rsscloud/client': + specifier: workspace:* + version: link:../../packages/client + body-parser: + specifier: ^2.2.2 + version: 2.2.2 + express: + specifier: ^4.22.2 + version: 4.22.2 + morgan: + specifier: ^1.10.1 + version: 1.10.1 + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.4.0(jiti@2.6.1)) + eslint: + specifier: ^10.4.0 + version: 10.4.0(jiti@2.6.1) + nodemon: + specifier: 3.1.14 + version: 3.1.14 + apps/e2e: devDependencies: '@eslint/js': @@ -76,18 +101,12 @@ importers: apps/server: dependencies: - '@rsscloud/client': - specifier: workspace:* - version: link:../../packages/client '@rsscloud/core': specifier: workspace:* version: link:../../packages/core '@rsscloud/express': specifier: workspace:* version: link:../../packages/express - body-parser: - specifier: ^2.2.2 - version: 2.2.2 cors: specifier: ^2.8.6 version: 2.8.6 From a8647cbe813ee147cd7a918f8bfad868d874d39e Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 14:35:03 -0500 Subject: [PATCH 83/90] docs: mark the client extraction done in TODO Co-Authored-By: Claude Opus 4.8 (1M context) --- TODO.md | 87 ++++----------------------------------------------------- 1 file changed, 5 insertions(+), 82 deletions(-) diff --git a/TODO.md b/TODO.md index 5a71403..5da7b0d 100644 --- a/TODO.md +++ b/TODO.md @@ -3,9 +3,11 @@ Outstanding + future work only. Completed work lives in git history, not here — that includes the `apps/server` → `@rsscloud/core` migration, the on-disk **v2 format unification** (disk == domain model; `legacy-store-shape.js` deleted; one-way -legacy importer in `file-store.ts`), and the 2026-06 architecture-cleanup passes -across `@rsscloud/core` and `apps/server` (`refactor(core):` / `refactor(server):` -commits). Per CLAUDE.md: build with the `tdd` skill (red-green vertical slices); +legacy importer in `file-store.ts`), the 2026-06 architecture-cleanup passes +across `@rsscloud/core` and `apps/server`, and the **client extraction** — a shared +`@rsscloud/xml-rpc` codec (core + client build on it), the published +`@rsscloud/client` (subscriber+publisher, factory API), and the private +`apps/client` harness. Per CLAUDE.md: build with the `tdd` skill (red-green vertical slices); Conventional Commits enforced. Architecture decisions are recorded in `docs/adr/`; domain vocabulary in `CONTEXT.md`. @@ -45,82 +47,3 @@ so new `Subscription` fields ride along with no extra mapping. *First slice:* core `subscribe` happy path (parse, verify intent, persist) + the express `websub` factory + an e2e callback handshake. Defer content distribution, HMAC, and leases. - -## Client extraction + shared XML-RPC codec (bigger — three workspaces) - -Pull `apps/server/client.js` (568 lines: protocol wire logic + outbound calls + a -stateful Express dev UI) into a published `@rsscloud/client` package plus a private -`apps/client` harness — mirroring how `apps/server` consumes `@rsscloud/core`. It -already works against the live server, so this is extraction + packaging, not a -behaviour change. The 2026-06-13 architecture review settled that **this is the only -extraction `apps/server` warrants** — the other read-models (`feeds-json`, -`feeds-opml`, the stats projection) have one consumer each, and the rest is -host/composition. - -The wire logic is **not** independently reimplemented (the e2e convention is for the -test harness, not two libraries). Instead a focused **`@rsscloud/xml-rpc`** package -holds the generic XML-RPC codec that both core (hub) and client (subscriber/publisher) -build on — two production consumers, a real seam (delete it and encode/decode reappears -in both). Depending on `@rsscloud/core` for this would be the wrong direction (the -client would drag in the whole hub engine for ~150 lines of codec). e2e stays -independent — deliberately not a consumer. - -``` -@rsscloud/xml-rpc generic XML-RPC codec (no rssCloud semantics) - ├─ @rsscloud/core hub: parse pleaseNotify/ping, emit success/fault, build notify - └─ @rsscloud/client subscriber/publisher: build pleaseNotify/ping, parse notify, emit success -apps/client private Express dev harness on @rsscloud/client -``` - -### `@rsscloud/xml-rpc` (new, published, 100% coverage) -Generic XML-RPC only — no `rssCloud.*` knowledge. -- `parseMethodCall(xml)` + `parseMethodResponse(xml)` (the decoder moves out of core). -- `buildMethodCall(methodName, params)` — **new** typed-value builder (core's current - encoders are ad-hoc/untyped). -- `buildMethodResponse(value)` / `serializeFault(code, str)`. -- An **`XmlRpcValue` model** (`i4`/`string`/`boolean`/`array`/`struct`/…) — the one real - design piece; worth a short grill at that slice. -- Core refactor: `xml-rpc-dispatcher` + `xml-rpc-plugin` import from it; core keeps only - its rssCloud-specific shapes as thin wrappers. Core's 25 codec tests move here; core - stays green + 100%. - -### `@rsscloud/client` (new, published, 100% coverage) — factory API, full subscriber+publisher -``` -createRssCloudClient({ serverUrl, fetch? }) → { - pleaseNotify({ protocol, callback: { domain, port, path }, feedUrl }) → { status, body } - ping({ protocol, feedUrl }) → { status, body } -} -``` -Plus exported pure helpers: `parseNotify(body)` → feedUrl, `buildNotifyResponse()` (the -boolean XML), challenge echo, and `renderCloudFeed({ feedName, link, items, cloud })` -(RSS-with-``). The rssCloud XML-RPC builders (`buildPleaseNotifyCall`, -`buildPingCall`) live here over `@rsscloud/xml-rpc`'s `buildMethodCall`; REST bodies are -trivial `URLSearchParams`. - -### `apps/client` (private, like `apps/e2e`) -The Express UI, request log, feed store, and routes — consuming `@rsscloud/client`. The -`client` script + `body-parser`/`xmlbuilder`/`morgan` deps move here out of `apps/server`. - -### Slices (codec-first → no transient duplication; each stays green) -1. Scaffold `@rsscloud/xml-rpc`; add to `release-please-config.json`. -2. Move the decoder (`parseMethodCall` + value decode) + its tests into it. -3. Add the typed `XmlRpcValue` builder (`buildMethodCall`/`buildMethodResponse`/fault), TDD. -4. Refactor core onto it — dispatcher/plugin import the generic codec; core green + 100%. -5. Scaffold `@rsscloud/client`; add to release config. -6. Client XML-RPC builders (`buildPleaseNotifyCall`/`buildPingCall`) on the shared codec, TDD. -7. Client send layer — `createRssCloudClient` with injected `fetch`, REST + XML-RPC, TDD. -8. Client receive + feed emit — `parseNotify`/`buildNotifyResponse`/challenge + `renderCloudFeed`, TDD. -9. Thin `client.js` onto `@rsscloud/client` (still in `apps/server`, still runs). -10. Relocate to `apps/client` (new private workspace; drop the script/deps from `apps/server`; - handle `express.static('public')`). - -Steps 1–4 are a self-contained, shippable improvement (core slims, no client yet); 5–10 -build and land the client. - -*Workspace/release:* `pnpm-workspace.yaml` already globs `packages/*` + `apps/*` (no -change). `release-please-config.json` gains `packages/xml-rpc` + `packages/client` -(components `xml-rpc` / `client`); `apps/client` stays untracked like `apps/e2e`. Cascade: -`xml-rpc → core → express → server`, and `xml-rpc → client`. - -*Notes:* `CONTEXT.md` gains subscriber/publisher-end vocabulary during implementation. -**WebSub-ready:** the client grows a WebSub subscriber/publisher once that lands. From fd364e07b0c166279ce5b34e1d8667136992be43 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 17:23:22 -0500 Subject: [PATCH 84/90] docs: add the client-end vocabulary to CONTEXT.md The glossary was hub-centric; the client extraction added a whole subscriber/publisher layer. Add terms for Hub, Client, Subscriber, Publisher, Notification, Cloud element, and the shared XML-RPC codec (each with its Avoid list), frame the new packages in the intro, and extend the example dialogue with a client/hub mirror exchange. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/CONTEXT.md b/CONTEXT.md index f0ab676..d0d93ef 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -3,7 +3,9 @@ The notification context of the [rssCloud](http://rsscloud.org/) protocol: subscribers register a callback for a feed, publishers signal when a feed changes, and the server fans notifications out to subscribers. `@rsscloud/core` is the protocol-neutral engine -(the **hub** end); the transports and HTTP edge wrap it. +(the **Hub** end); the transports and HTTP edge wrap it. `@rsscloud/client` is the +matching **Client** end (the **Subscriber** + **Publisher** side), and `@rsscloud/xml-rpc` +is the **XML-RPC codec** both ends share. ## Language @@ -68,9 +70,59 @@ challenge handshake at verify time. Set by **buildSubscribeRequest** from the pr an explicit `domain`. _Avoid_: cross-origin, external, remote. +**Hub**: +The server end of the protocol: it answers **pleaseNotify** and **Ping**, owns the +**Resource**/**Subscription** state, and fans **Notification**s out. `@rsscloud/core` is the +protocol-neutral hub engine; `apps/server` is one deployment of it. +_Avoid_: server (that's a deployment of the hub, not the role), broker. + +**Client**: +The **Subscriber** + **Publisher** end of the protocol — the mirror of the **Hub**. +`@rsscloud/client` builds the **pleaseNotify**/**Ping** calls, echoes the verify challenge, +parses inbound **Notification**s, and renders a feed's **Cloud element**; `apps/client` is +the interactive harness over it. +_Avoid_: agent, consumer, SDK. + +**Subscriber**: +The remote party — and the **Client** role — that registers to be notified: sends +**pleaseNotify**, answers the verify challenge, and receives **Notification**s. Its callback +host is what the stats `uniqueAggregators` count. +_Avoid_: subscription (that's the stored record), listener, consumer. + +**Publisher**: +The remote party — and the **Client** role — that signals a **Resource** changed: sends +**Ping** and advertises the **Cloud element** in its feed. One **Client** can be both +Subscriber and Publisher. +_Avoid_: feed (that's the parsed metadata on a **Resource**), source, producer. + +**Notification**: +The outbound delivery from the **Hub** to a **Subscriber**'s callback when a **Resource** +changes — an `http-post` `url=` form or an XML-RPC `rssCloud.notify` call. What a +**Protocol plugin** sends and the **Client** receives and acknowledges. +_Avoid_: ping (that's the inbound publisher signal), pleaseNotify (the inbound subscribe), +message, event. + +**Cloud element**: +The `` element a **Publisher** places in its RSS feed (domain / port / path / +registerProcedure / protocol) telling a **Subscriber** where to **pleaseNotify**. Built by +the **Client**'s `renderCloudFeed`; the **Hub** doesn't host it — publishers reference the +hub from their own feeds. +_Avoid_: cloud (ambiguous on its own), hub link. + +**XML-RPC codec**: +The generic XML-RPC `methodCall`/`methodResponse` encode + decode (`@rsscloud/xml-rpc`) +shared by the **Hub** and the **Client**. It speaks typed `XmlRpcValue`s and carries no +`rssCloud.*` semantics — each end maps its own method shapes onto it. +_Avoid_: parser (that's one half), serializer, XML library. + ## Example dialogue > **Dev:** When a `pleaseNotify` comes in over XML-RPC, who decides the callback is `diffDomain`? > **Domain expert:** The dispatcher just pulls the positional params into **SubscribeParams** and hands them to **buildSubscribeRequest**. The builder is the one that sees an explicit `domain`, sets `diffDomain`, and glues the callback URL. Same builder the REST front door uses. > **Dev:** So if the protocol's unsupported, that's the builder too? > **Domain expert:** Right — protocol validation moved inside the builder, so both front doors fault the same way. The dispatcher only owns its own wire's presence check and the wording it renders back. + +> **Dev:** The **Client** and the **Hub** both speak XML-RPC — do they share the builder? +> **Domain expert:** They share the **XML-RPC codec** (`@rsscloud/xml-rpc`), not each other's calls. The Client builds `rssCloud.pleaseNotify`/`rssCloud.ping`; the Hub parses those and sends a **Notification**. Each maps its own `rssCloud.*` shapes onto the codec's typed values. +> **Dev:** And how does a **Publisher** point a **Subscriber** at us? +> **Domain expert:** Via the **Cloud element** in the publisher's own feed — the Client's `renderCloudFeed` writes it. The Hub never hosts the feed; it just answers the **pleaseNotify** the subscriber sends after reading that ``. From 34ae9b4dc27fc40b9a695039a201eeca4584f987 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 17:31:33 -0500 Subject: [PATCH 85/90] docs(client): add subscribe/ping usage examples to the README Lead with the https-post/http-post path (createRssCloudClient, pleaseNotify, the verify-challenge + parseHttpPostNotify callback, ping, renderCloudFeed) and keep XML-RPC as a clearly-separated secondary section. Fix the Node engine note (22+). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/README.md | 153 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 147 insertions(+), 6 deletions(-) diff --git a/packages/client/README.md b/packages/client/README.md index 99dd2ca..55dbf2c 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -1,15 +1,156 @@ # @rsscloud/client The **subscriber + publisher end** of the [rssCloud](https://github.com/rsscloud/rsscloud-server) -notification protocol — the mirror of `@rsscloud/core` (the hub end). +notification protocol — the mirror of `@rsscloud/core` (the hub end). It talks to a +hub over an injectable `fetch`, so it has no server dependency. -- **Subscriber:** send `pleaseNotify` (REST + XML-RPC), echo the http-post - challenge, and parse incoming notifications. -- **Publisher:** send `ping` (REST + XML-RPC), and emit a feed carrying the +- **Subscriber:** send `pleaseNotify`, answer the verify challenge, and parse the + notifications the hub posts back. +- **Publisher:** send `ping` when a feed changes, and render a feed carrying the `` element. -It builds its XML-RPC on the shared [`@rsscloud/xml-rpc`](../xml-rpc) codec and -talks to a hub over an injectable `fetch`, so it has no server dependency. +The common transport is **`http-post` / `https-post`** (a plain form POST and a +form-POST callback). rssCloud's original **XML-RPC** transport is also supported as a +[secondary option](#xml-rpc-secondary). + +## Install + +```bash +pnpm add @rsscloud/client +``` + +Requires Node 22+ (uses the global `fetch`). + +## Subscribe (`https-post`) + +Register a callback so the hub notifies you when a feed changes: + +```ts +import { createRssCloudClient } from '@rsscloud/client'; + +const client = createRssCloudClient({ serverUrl: 'https://hub.example' }); + +const { status, body } = await client.pleaseNotify({ + protocol: 'https-post', + callback: { domain: 'sub.example', port: 443, path: '/notify' }, + feedUrl: 'https://feed.example/rss' +}); +``` + +> For `http-post` / `https-post` the hub derives the callback host from the +> connection, so `callback.domain` is only consulted for the XML-RPC transport — +> `port` and `path` are what matter here. Use `protocol: 'http-post'` for a plain +> (non-TLS) callback, e.g. in local development. + +Then serve the callback the hub will call. It does two things: answer the one-time +**verify challenge** (a `GET` carrying `?challenge=…`), and accept **notifications** +(a `POST` with the changed resource URL in a `url` form field): + +```ts +import express from 'express'; +import { parseHttpPostNotify } from '@rsscloud/client'; + +const app = express(); + +// 1. Verify handshake — echo the challenge back verbatim. +app.get('/notify', (req, res) => { + res.send(String(req.query.challenge ?? '')); +}); + +// 2. Notification — the changed resource URL arrives as `url`. +app.post( + '/notify', + express.text({ type: 'application/x-www-form-urlencoded' }), + (req, res) => { + const feedUrl = parseHttpPostNotify(req.body); + // ...re-fetch feedUrl and process the update... + res.end(); + } +); + +app.listen(443); +``` + +`pleaseNotify` resolves to the hub's raw reply (`{ status, body }`); it does not +throw on a non-2xx — inspect `status` yourself. + +## Ping (publish a change) + +When your feed updates, tell the hub: + +```ts +import { createRssCloudClient } from '@rsscloud/client'; + +const client = createRssCloudClient({ serverUrl: 'https://hub.example' }); + +await client.ping({ feedUrl: 'https://feed.example/rss' }); +``` + +`ping` posts to the hub's REST `/ping` front door by default. + +## Advertising the hub in your feed + +Publishers announce their hub with a `` element so subscribers know where to +`pleaseNotify`. `renderCloudFeed` emits an RSS 2.0 document with it: + +```ts +import { renderCloudFeed } from '@rsscloud/client'; + +const xml = renderCloudFeed({ + title: 'Example feed', + link: 'https://feed.example/rss', + description: 'An rssCloud-enabled feed', + cloud: { + domain: 'hub.example', + port: 443, + path: '/pleaseNotify', + registerProcedure: '', + protocol: 'http-post' + }, + items: [ + { + title: 'First post', + description: 'Hello, cloud', + pubDate: new Date(), + guid: 'https://feed.example/posts/1' + } + ] +}); +``` + +## XML-RPC (secondary) + +For hubs and subscribers that speak rssCloud's original XML-RPC transport. The shape +is the same; the protocol/transport switches change the front door used: + +```ts +// Subscribe over XML-RPC (POSTed to the hub's /RPC2). +await client.pleaseNotify({ + protocol: 'xml-rpc', + callback: { domain: 'sub.example', port: 9000, path: '/RPC2' }, + feedUrl: 'https://feed.example/rss' +}); + +// Ping over XML-RPC. +await client.ping({ feedUrl: 'https://feed.example/rss', transport: 'xml-rpc' }); +``` + +An XML-RPC callback receives an `rssCloud.notify` `methodCall` and must answer with a +boolean-true `methodResponse`: + +```ts +import { parseXmlRpcNotify, buildNotifyResponse } from '@rsscloud/client'; + +app.post('/RPC2', express.text({ type: '*/xml' }), async (req, res) => { + const feedUrl = await parseXmlRpcNotify(req.body); + // ...re-fetch feedUrl and process the update... + res.type('text/xml').send(buildNotifyResponse()); +}); +``` + +The low-level wire builders (`buildPleaseNotifyCall`, `buildPingCall`) are exported +too, over the shared [`@rsscloud/xml-rpc`](../xml-rpc) codec, if you need to drive the +XML-RPC calls directly. ## License From db8713ee1f8c3bb94c171efffbef8a55a68027ad Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 17:41:53 -0500 Subject: [PATCH 86/90] fix(client): honor an optional callback domain on every transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit callback.domain is now optional and drives the hub's verification flow on both transports: given, the hub uses that host (the diffDomain flow, with a challenge for http-post/https-post); omitted, it falls back to the caller's connection address. The REST pleaseNotify previously dropped domain entirely while still requiring it in the type — so an explicit https-post callback host was silently ignored. Send it in the form when present, pass '' over xml-rpc when absent (ADR-0001), and correct the README, which wrongly claimed domain was xml-rpc-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/README.md | 20 ++++++++++++---- packages/client/src/client.test.ts | 37 ++++++++++++++++++++++++++++++ packages/client/src/client.ts | 14 +++++++++-- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/packages/client/README.md b/packages/client/README.md index 55dbf2c..efd013d 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -37,10 +37,22 @@ const { status, body } = await client.pleaseNotify({ }); ``` -> For `http-post` / `https-post` the hub derives the callback host from the -> connection, so `callback.domain` is only consulted for the XML-RPC transport — -> `port` and `path` are what matter here. Use `protocol: 'http-post'` for a plain -> (non-TLS) callback, e.g. in local development. +> `callback.domain` is **optional** and selects the verification flow on every +> transport. Give it (as above) and the hub uses that host — confirming an +> `http-post`/`https-post` callback with a one-time challenge `GET` to it. Omit it +> and the hub falls back to your connection's address with no challenge: +> +> ```ts +> await client.pleaseNotify({ +> protocol: 'http-post', +> callback: { port: 9000, path: '/notify' }, // no domain → caller address +> feedUrl: 'https://feed.example/rss' +> }); +> ``` +> +> For a public HTTPS callback you'll usually want the explicit `domain` so the hub +> reaches your real host. Use `protocol: 'http-post'` for a plain (non-TLS) +> callback, e.g. in local development. Then serve the callback the hub will call. It does two things: answer the one-time **verify challenge** (a `GET` carrying `?challenge=…`), and accept **notifications** diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 21e15e6..7dff1f4 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -88,6 +88,25 @@ describe('createRssCloudClient pleaseNotify', () => { expect(body.get('path')).toBe('/notify'); expect(body.get('protocol')).toBe('http-post'); expect(body.get('url1')).toBe('https://feed.example/rss'); + // An explicit domain is sent, so the hub uses it (the diffDomain flow). + expect(body.get('domain')).toBe('sub.example'); + }); + + it('omits domain from the REST form when none is given', async () => { + const { fn, calls } = fakeFetch(); + const client = createRssCloudClient({ + serverUrl: 'http://hub.example:5337', + fetch: fn + }); + + await client.pleaseNotify({ + protocol: 'http-post', + callback: { port: 9000, path: '/notify' }, + feedUrl: 'https://feed.example/rss' + }); + + // No domain → the hub falls back to the caller's connection address. + expect(form(calls[0]!.init).has('domain')).toBe(false); }); it('registers an xml-rpc callback over /RPC2 with the six params', async () => { @@ -116,6 +135,24 @@ describe('createRssCloudClient pleaseNotify', () => { 'sub.example' ]); }); + + it('sends an empty domain param over xml-rpc when none is given', async () => { + const { fn, calls } = fakeFetch(); + const client = createRssCloudClient({ + serverUrl: 'http://hub.example:5337', + fetch: fn + }); + + await client.pleaseNotify({ + protocol: 'xml-rpc', + callback: { port: 9000, path: '/RPC2' }, + feedUrl: 'https://feed.example/rss' + }); + + const call = await parseMethodCall(calls[0]?.init.body as string); + // Empty domain → the hub treats it as absent (ADR-0001) and uses the caller. + expect(call.params[5]).toBe(''); + }); }); describe('createRssCloudClient construction', () => { diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index eea73ce..6d25d86 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -5,7 +5,12 @@ export type NotifyProtocol = 'http-post' | 'https-post' | 'xml-rpc'; /** Where the hub should deliver notifications for a subscription. */ export interface Callback { - domain: string; + /** + * Explicit callback host. When given, the hub uses it (the cross-domain + * `diffDomain` flow, with a verify challenge for http-post); when omitted, + * the hub falls back to the caller's connection address. + */ + domain?: string; port: number; path: string; } @@ -86,7 +91,8 @@ export function createRssCloudClient( path: opts.callback.path, protocol: opts.protocol, urls: [opts.feedUrl], - domain: opts.callback.domain + // Empty string = "no explicit domain" (ADR-0001). + domain: opts.callback.domain ?? '' }) ); } @@ -96,6 +102,10 @@ export function createRssCloudClient( protocol: opts.protocol, url1: opts.feedUrl }); + // Send domain only when explicit; otherwise the hub uses the caller address. + if (opts.callback.domain) { + form.set('domain', opts.callback.domain); + } return send('/pleaseNotify', FORM_TYPE, form.toString()); } From 31a8681c01cc9380a33f9cdfa6193efaa5de72dc Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 17:52:54 -0500 Subject: [PATCH 87/90] refactor: fold the @rsscloud/client package into apps/client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A subscriber must host its own notify endpoint, so the client is app-shaped, not a clean published library. Move the wire logic (createRssCloudClient, the pleaseNotify/ping builders, renderCloudFeed, the notify ack) into apps/client/lib as plain CommonJS, ported with node:test coverage (matching apps/server), and delete the packages/client workspace. apps/client now depends on @rsscloud/xml-rpc directly; the shared codec stays (core builds its /RPC2 dispatcher on it). Drops the unused inbound notify parsers — the harness only logs. Untracked from release-please; CONTEXT/TODO/xml-rpc docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .release-please-manifest.json | 3 +- CONTEXT.md | 15 ++- TODO.md | 8 +- apps/client/client.js | 4 +- apps/client/lib/client.js | 89 +++++++++++++ apps/client/lib/client.test.js | 157 ++++++++++++++++++++++ apps/client/lib/feed.js | 34 +++++ apps/client/lib/feed.test.js | 77 +++++++++++ apps/client/lib/index.js | 5 + apps/client/lib/notify.js | 9 ++ apps/client/lib/notify.test.js | 12 ++ apps/client/package.json | 8 +- packages/client/LICENSE.md | 20 --- packages/client/README.md | 169 ------------------------ packages/client/eslint.config.mjs | 17 --- packages/client/package.json | 74 ----------- packages/client/src/client.test.ts | 179 -------------------------- packages/client/src/client.ts | 124 ------------------ packages/client/src/feed.test.ts | 93 ------------- packages/client/src/feed.ts | 60 --------- packages/client/src/index.test.ts | 23 ---- packages/client/src/index.ts | 26 ---- packages/client/src/notify.test.ts | 54 -------- packages/client/src/notify.ts | 27 ---- packages/client/src/rpc-calls.test.ts | 61 --------- packages/client/src/rpc-calls.ts | 38 ------ packages/client/tsconfig.build.json | 8 -- packages/client/tsconfig.json | 27 ---- packages/client/tsup.config.ts | 13 -- packages/client/vitest.config.ts | 19 --- packages/xml-rpc/README.md | 6 +- pnpm-lock.yaml | 44 +------ release-please-config.json | 6 - 33 files changed, 411 insertions(+), 1098 deletions(-) create mode 100644 apps/client/lib/client.js create mode 100644 apps/client/lib/client.test.js create mode 100644 apps/client/lib/feed.js create mode 100644 apps/client/lib/feed.test.js create mode 100644 apps/client/lib/index.js create mode 100644 apps/client/lib/notify.js create mode 100644 apps/client/lib/notify.test.js delete mode 100644 packages/client/LICENSE.md delete mode 100644 packages/client/README.md delete mode 100644 packages/client/eslint.config.mjs delete mode 100644 packages/client/package.json delete mode 100644 packages/client/src/client.test.ts delete mode 100644 packages/client/src/client.ts delete mode 100644 packages/client/src/feed.test.ts delete mode 100644 packages/client/src/feed.ts delete mode 100644 packages/client/src/index.test.ts delete mode 100644 packages/client/src/index.ts delete mode 100644 packages/client/src/notify.test.ts delete mode 100644 packages/client/src/notify.ts delete mode 100644 packages/client/src/rpc-calls.test.ts delete mode 100644 packages/client/src/rpc-calls.ts delete mode 100644 packages/client/tsconfig.build.json delete mode 100644 packages/client/tsconfig.json delete mode 100644 packages/client/tsup.config.ts delete mode 100644 packages/client/vitest.config.ts diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b169f25..0a2f68c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -2,6 +2,5 @@ "apps/server": "4.0.0", "packages/xml-rpc": "0.0.0", "packages/core": "0.0.0", - "packages/express": "0.0.0", - "packages/client": "0.0.0" + "packages/express": "0.0.0" } diff --git a/CONTEXT.md b/CONTEXT.md index d0d93ef..69da97a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -3,9 +3,9 @@ The notification context of the [rssCloud](http://rsscloud.org/) protocol: subscribers register a callback for a feed, publishers signal when a feed changes, and the server fans notifications out to subscribers. `@rsscloud/core` is the protocol-neutral engine -(the **Hub** end); the transports and HTTP edge wrap it. `@rsscloud/client` is the -matching **Client** end (the **Subscriber** + **Publisher** side), and `@rsscloud/xml-rpc` -is the **XML-RPC codec** both ends share. +(the **Hub** end); the transports and HTTP edge wrap it. `apps/client` is the matching +**Client** end (the **Subscriber** + **Publisher** side), and `@rsscloud/xml-rpc` is the +**XML-RPC codec** both ends share. ## Language @@ -77,10 +77,11 @@ protocol-neutral hub engine; `apps/server` is one deployment of it. _Avoid_: server (that's a deployment of the hub, not the role), broker. **Client**: -The **Subscriber** + **Publisher** end of the protocol — the mirror of the **Hub**. -`@rsscloud/client` builds the **pleaseNotify**/**Ping** calls, echoes the verify challenge, -parses inbound **Notification**s, and renders a feed's **Cloud element**; `apps/client` is -the interactive harness over it. +The **Subscriber** + **Publisher** end of the protocol — the mirror of the **Hub**, +living in `apps/client`. Its `lib/` builds the **pleaseNotify**/**Ping** calls (on the +**XML-RPC codec**) and renders a feed's **Cloud element**; the app hosts the callback +endpoint that answers the verify challenge and acknowledges **Notification**s. Not a +published package — a real subscriber must host that endpoint, so it stays app logic. _Avoid_: agent, consumer, SDK. **Subscriber**: diff --git a/TODO.md b/TODO.md index 5da7b0d..d178f71 100644 --- a/TODO.md +++ b/TODO.md @@ -4,10 +4,10 @@ Outstanding + future work only. Completed work lives in git history, not here that includes the `apps/server` → `@rsscloud/core` migration, the on-disk **v2 format unification** (disk == domain model; `legacy-store-shape.js` deleted; one-way legacy importer in `file-store.ts`), the 2026-06 architecture-cleanup passes -across `@rsscloud/core` and `apps/server`, and the **client extraction** — a shared -`@rsscloud/xml-rpc` codec (core + client build on it), the published -`@rsscloud/client` (subscriber+publisher, factory API), and the private -`apps/client` harness. Per CLAUDE.md: build with the `tdd` skill (red-green vertical slices); +across `@rsscloud/core` and `apps/server`, and the shared **`@rsscloud/xml-rpc`** codec +(core builds its `/RPC2` dispatcher on it). The subscriber/publisher client logic lives +in `apps/client` (its `lib/`), not a published package — a real subscriber must host a +notify endpoint, so it's app logic for now. Per CLAUDE.md: build with the `tdd` skill (red-green vertical slices); Conventional Commits enforced. Architecture decisions are recorded in `docs/adr/`; domain vocabulary in `CONTEXT.md`. diff --git a/apps/client/client.js b/apps/client/client.js index 0a831a6..d4662c9 100644 --- a/apps/client/client.js +++ b/apps/client/client.js @@ -6,7 +6,7 @@ const bodyParser = require('body-parser'), createRssCloudClient, buildNotifyResponse, renderCloudFeed - } = require('@rsscloud/client'), + } = require('./lib'), textParser = bodyParser.text({ type: '*/xml' }), urlencodedParser = bodyParser.urlencoded({ extended: false }); @@ -29,7 +29,7 @@ const clientConfig = { }; // All protocol wire work (pleaseNotify/ping calls, the XML-RPC notify ack, and -// feed rendering) lives in @rsscloud/client; this file is just the UI. +// feed rendering) lives in ./lib; this file is just the UI. const client = createRssCloudClient({ serverUrl: clientConfig.rsscloudServer }); // In-memory data stores (reset on restart) diff --git a/apps/client/lib/client.js b/apps/client/lib/client.js new file mode 100644 index 0000000..f3b340b --- /dev/null +++ b/apps/client/lib/client.js @@ -0,0 +1,89 @@ +const { array, buildMethodCall, i4, str } = require('@rsscloud/xml-rpc'); + +// The subscriber+publisher logic the dev harness runs on. Lifted out of the +// retired @rsscloud/client package — a real subscriber must host a notify +// endpoint, so this is app logic, not a standalone library. It still builds its +// XML-RPC on the shared @rsscloud/xml-rpc codec and talks to a hub over an +// injectable fetch. + +const FORM_TYPE = 'application/x-www-form-urlencoded'; +const XML_TYPE = 'text/xml'; + +// Build the rssCloud pleaseNotify methodCall — six positional params in wire +// order: notifyProcedure, port, path, protocol, urlList, domain. +function buildPleaseNotifyCall(params) { + return buildMethodCall('rssCloud.pleaseNotify', [ + str(params.notifyProcedure), + i4(params.port), + str(params.path), + str(params.protocol), + array(params.urls.map(str)), + str(params.domain) + ]); +} + +// Build the rssCloud ping methodCall carrying a single feed URL. +function buildPingCall(feedUrl) { + return buildMethodCall('rssCloud.ping', [str(feedUrl)]); +} + +// Build a client bound to one hub. pleaseNotify/ping pick their front door from +// the request shape: an xml-rpc subscription and an xml-rpc ping go to /RPC2; +// everything else uses the REST front doors. `callback.domain` is optional and +// selects the hub's verification flow — given, the hub uses that host (with a +// challenge for http-post/https-post); omitted, it uses the caller's address. +function createRssCloudClient(options) { + const doFetch = options.fetch ?? fetch; + const base = options.serverUrl.replace(/\/$/, ''); + + async function send(path, contentType, body) { + const res = await doFetch(`${base}${path}`, { + method: 'POST', + headers: { 'Content-Type': contentType }, + body + }); + return { status: res.status, body: await res.text() }; + } + + async function pleaseNotify(opts) { + if (opts.protocol === 'xml-rpc') { + return send( + '/RPC2', + XML_TYPE, + buildPleaseNotifyCall({ + notifyProcedure: 'rssCloud.notify', + port: opts.callback.port, + path: opts.callback.path, + protocol: opts.protocol, + urls: [opts.feedUrl], + domain: opts.callback.domain ?? '' + }) + ); + } + const form = new URLSearchParams({ + port: String(opts.callback.port), + path: opts.callback.path, + protocol: opts.protocol, + url1: opts.feedUrl + }); + if (opts.callback.domain) { + form.set('domain', opts.callback.domain); + } + return send('/pleaseNotify', FORM_TYPE, form.toString()); + } + + async function ping(opts) { + if (opts.transport === 'xml-rpc') { + return send('/RPC2', XML_TYPE, buildPingCall(opts.feedUrl)); + } + return send( + '/ping', + FORM_TYPE, + new URLSearchParams({ url: opts.feedUrl }).toString() + ); + } + + return { pleaseNotify, ping }; +} + +module.exports = { createRssCloudClient }; diff --git a/apps/client/lib/client.test.js b/apps/client/lib/client.test.js new file mode 100644 index 0000000..7ef8747 --- /dev/null +++ b/apps/client/lib/client.test.js @@ -0,0 +1,157 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { parseMethodCall } = require('@rsscloud/xml-rpc'); +const { createRssCloudClient } = require('./client'); + +function fakeFetch(status = 200, responseBody = 'OK') { + const calls = []; + const fn = async(url, init) => { + calls.push({ url: String(url), init: init ?? {} }); + return { status, text: async() => responseBody }; + }; + return { fn, calls }; +} + +function form(init) { + return new URLSearchParams(init.body); +} + +test('ping posts the feed URL to /ping by default', async() => { + const { fn, calls } = fakeFetch(); + const client = createRssCloudClient({ + serverUrl: 'http://hub.example:5337', + fetch: fn + }); + + const res = await client.ping({ feedUrl: 'https://feed.example/rss' }); + + assert.equal(calls[0].url, 'http://hub.example:5337/ping'); + assert.equal(calls[0].init.method, 'POST'); + assert.equal( + calls[0].init.headers['Content-Type'], + 'application/x-www-form-urlencoded' + ); + assert.equal(form(calls[0].init).get('url'), 'https://feed.example/rss'); + assert.deepEqual(res, { status: 200, body: 'OK' }); +}); + +test('ping posts to /RPC2 over xml-rpc', async() => { + const { fn, calls } = fakeFetch(); + const client = createRssCloudClient({ + serverUrl: 'http://hub.example:5337', + fetch: fn + }); + + await client.ping({ + feedUrl: 'https://feed.example/rss', + transport: 'xml-rpc' + }); + + assert.equal(calls[0].url, 'http://hub.example:5337/RPC2'); + assert.equal(calls[0].init.headers['Content-Type'], 'text/xml'); + const call = await parseMethodCall(calls[0].init.body); + assert.equal(call.methodName, 'rssCloud.ping'); + assert.deepEqual(call.params, ['https://feed.example/rss']); +}); + +test('pleaseNotify over http-post sends the form with an explicit domain', async() => { + const { fn, calls } = fakeFetch(); + const client = createRssCloudClient({ + serverUrl: 'http://hub.example:5337', + fetch: fn + }); + + await client.pleaseNotify({ + protocol: 'http-post', + callback: { domain: 'sub.example', port: 9000, path: '/notify' }, + feedUrl: 'https://feed.example/rss' + }); + + assert.equal(calls[0].url, 'http://hub.example:5337/pleaseNotify'); + const body = form(calls[0].init); + assert.equal(body.get('port'), '9000'); + assert.equal(body.get('path'), '/notify'); + assert.equal(body.get('protocol'), 'http-post'); + assert.equal(body.get('url1'), 'https://feed.example/rss'); + assert.equal(body.get('domain'), 'sub.example'); +}); + +test('pleaseNotify over http-post omits domain when none is given', async() => { + const { fn, calls } = fakeFetch(); + const client = createRssCloudClient({ + serverUrl: 'http://hub.example:5337', + fetch: fn + }); + + await client.pleaseNotify({ + protocol: 'http-post', + callback: { port: 9000, path: '/notify' }, + feedUrl: 'https://feed.example/rss' + }); + + assert.equal(form(calls[0].init).has('domain'), false); +}); + +test('pleaseNotify over xml-rpc sends the six params', async() => { + const { fn, calls } = fakeFetch(); + const client = createRssCloudClient({ + serverUrl: 'http://hub.example:5337', + fetch: fn + }); + + await client.pleaseNotify({ + protocol: 'xml-rpc', + callback: { domain: 'sub.example', port: 9000, path: '/RPC2' }, + feedUrl: 'https://feed.example/rss' + }); + + assert.equal(calls[0].url, 'http://hub.example:5337/RPC2'); + const call = await parseMethodCall(calls[0].init.body); + assert.equal(call.methodName, 'rssCloud.pleaseNotify'); + assert.deepEqual(call.params, [ + 'rssCloud.notify', + 9000, + '/RPC2', + 'xml-rpc', + ['https://feed.example/rss'], + 'sub.example' + ]); +}); + +test('pleaseNotify over xml-rpc sends an empty domain when none is given', async() => { + const { fn, calls } = fakeFetch(); + const client = createRssCloudClient({ + serverUrl: 'http://hub.example:5337', + fetch: fn + }); + + await client.pleaseNotify({ + protocol: 'xml-rpc', + callback: { port: 9000, path: '/RPC2' }, + feedUrl: 'https://feed.example/rss' + }); + + const call = await parseMethodCall(calls[0].init.body); + assert.equal(call.params[5], ''); +}); + +test('strips a trailing slash from the server URL', async() => { + const { fn, calls } = fakeFetch(); + const client = createRssCloudClient({ + serverUrl: 'http://hub.example:5337/', + fetch: fn + }); + + await client.ping({ feedUrl: 'https://feed.example/rss' }); + + assert.equal(calls[0].url, 'http://hub.example:5337/ping'); +}); + +test('defaults to the global fetch when none is injected', () => { + const client = createRssCloudClient({ + serverUrl: 'http://hub.example:5337' + }); + + assert.equal(typeof client.ping, 'function'); + assert.equal(typeof client.pleaseNotify, 'function'); +}); diff --git a/apps/client/lib/feed.js b/apps/client/lib/feed.js new file mode 100644 index 0000000..ea1d50a --- /dev/null +++ b/apps/client/lib/feed.js @@ -0,0 +1,34 @@ +const { Builder } = require('xml2js'); + +// Render an RSS 2.0 feed carrying a element — the document a publisher +// serves so a hub knows where to register for change notifications. Item +// pubDates are emitted in RFC 822 form. +function renderCloudFeed(opts) { + return new Builder().buildObject({ + rss: { + $: { version: '2.0' }, + channel: { + title: opts.title, + link: opts.link, + description: opts.description, + cloud: { + $: { + domain: opts.cloud.domain, + port: String(opts.cloud.port), + path: opts.cloud.path, + registerProcedure: opts.cloud.registerProcedure, + protocol: opts.cloud.protocol + } + }, + item: opts.items.map(item => ({ + title: item.title, + description: item.description, + pubDate: item.pubDate.toUTCString(), + guid: item.guid + })) + } + } + }); +} + +module.exports = { renderCloudFeed }; diff --git a/apps/client/lib/feed.test.js b/apps/client/lib/feed.test.js new file mode 100644 index 0000000..d3e692a --- /dev/null +++ b/apps/client/lib/feed.test.js @@ -0,0 +1,77 @@ +const { Parser } = require('xml2js'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { renderCloudFeed } = require('./feed'); + +function reparse(xml) { + return new Parser({ explicitArray: false }).parseStringPromise(xml); +} + +const CLOUD = { + domain: 'localhost', + port: 5337, + path: '/RPC2', + registerProcedure: 'rssCloud.pleaseNotify', + protocol: 'xml-rpc' +}; + +test('renders a channel with the cloud element and an item', async() => { + const xml = renderCloudFeed({ + title: 'Test Feed', + link: 'http://sub.example:9000/rss-01.xml', + description: 'Test feed for rssCloud', + cloud: CLOUD, + items: [ + { + title: 'Update one', + description: 'first', + pubDate: new Date('2026-01-02T03:04:05Z'), + guid: 'rss-01-0' + } + ] + }); + + const { rss } = await reparse(xml); + assert.equal(rss.$.version, '2.0'); + assert.equal(rss.channel.title, 'Test Feed'); + assert.equal(rss.channel.link, 'http://sub.example:9000/rss-01.xml'); + assert.deepEqual(rss.channel.cloud.$, { + domain: 'localhost', + port: '5337', + path: '/RPC2', + registerProcedure: 'rssCloud.pleaseNotify', + protocol: 'xml-rpc' + }); + assert.equal(rss.channel.item.title, 'Update one'); + assert.equal(rss.channel.item.guid, 'rss-01-0'); + assert.equal(rss.channel.item.pubDate, 'Fri, 02 Jan 2026 03:04:05 GMT'); +}); + +test('renders multiple items in order', async() => { + const xml = renderCloudFeed({ + title: 'Test Feed', + link: 'http://sub.example:9000/rss-01.xml', + description: 'Test feed for rssCloud', + cloud: CLOUD, + items: [ + { + title: 'one', + description: 'a', + pubDate: new Date('2026-01-02T00:00:00Z'), + guid: 'g0' + }, + { + title: 'two', + description: 'b', + pubDate: new Date('2026-01-03T00:00:00Z'), + guid: 'g1' + } + ] + }); + + const { rss } = await reparse(xml); + assert.deepEqual( + rss.channel.item.map(i => i.title), + ['one', 'two'] + ); +}); diff --git a/apps/client/lib/index.js b/apps/client/lib/index.js new file mode 100644 index 0000000..18502d2 --- /dev/null +++ b/apps/client/lib/index.js @@ -0,0 +1,5 @@ +const { createRssCloudClient } = require('./client'); +const { renderCloudFeed } = require('./feed'); +const { buildNotifyResponse } = require('./notify'); + +module.exports = { createRssCloudClient, renderCloudFeed, buildNotifyResponse }; diff --git a/apps/client/lib/notify.js b/apps/client/lib/notify.js new file mode 100644 index 0000000..cc828c7 --- /dev/null +++ b/apps/client/lib/notify.js @@ -0,0 +1,9 @@ +const { bool, buildMethodResponse } = require('@rsscloud/xml-rpc'); + +// The boolean-true methodResponse a subscriber returns to acknowledge an +// XML-RPC notification. +function buildNotifyResponse() { + return buildMethodResponse(bool(true)); +} + +module.exports = { buildNotifyResponse }; diff --git a/apps/client/lib/notify.test.js b/apps/client/lib/notify.test.js new file mode 100644 index 0000000..b746d0f --- /dev/null +++ b/apps/client/lib/notify.test.js @@ -0,0 +1,12 @@ +const { Parser } = require('xml2js'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { buildNotifyResponse } = require('./notify'); + +test('builds a boolean-true methodResponse', async() => { + const parsed = await new Parser({ explicitArray: false }).parseStringPromise( + buildNotifyResponse() + ); + + assert.equal(parsed.methodResponse.params.param.value.boolean, '1'); +}); diff --git a/apps/client/package.json b/apps/client/package.json index fed8370..eddc6c4 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -7,7 +7,8 @@ "scripts": { "start": "node --use_strict client.js", "dev": "nodemon --use_strict client.js", - "lint": "eslint --fix *.js" + "test": "node --test lib/*.test.js", + "lint": "eslint --fix *.js lib/" }, "engines": { "node": ">=22" @@ -15,10 +16,11 @@ "author": "Andrew Shell ", "license": "MIT", "dependencies": { - "@rsscloud/client": "workspace:*", + "@rsscloud/xml-rpc": "workspace:*", "body-parser": "^2.2.2", "express": "^4.22.2", - "morgan": "^1.10.1" + "morgan": "^1.10.1", + "xml2js": "^0.6.2" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/packages/client/LICENSE.md b/packages/client/LICENSE.md deleted file mode 100644 index d81b273..0000000 --- a/packages/client/LICENSE.md +++ /dev/null @@ -1,20 +0,0 @@ -Copyright (c) 2015-2026 Andrew Shell - -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. diff --git a/packages/client/README.md b/packages/client/README.md deleted file mode 100644 index efd013d..0000000 --- a/packages/client/README.md +++ /dev/null @@ -1,169 +0,0 @@ -# @rsscloud/client - -The **subscriber + publisher end** of the [rssCloud](https://github.com/rsscloud/rsscloud-server) -notification protocol — the mirror of `@rsscloud/core` (the hub end). It talks to a -hub over an injectable `fetch`, so it has no server dependency. - -- **Subscriber:** send `pleaseNotify`, answer the verify challenge, and parse the - notifications the hub posts back. -- **Publisher:** send `ping` when a feed changes, and render a feed carrying the - `` element. - -The common transport is **`http-post` / `https-post`** (a plain form POST and a -form-POST callback). rssCloud's original **XML-RPC** transport is also supported as a -[secondary option](#xml-rpc-secondary). - -## Install - -```bash -pnpm add @rsscloud/client -``` - -Requires Node 22+ (uses the global `fetch`). - -## Subscribe (`https-post`) - -Register a callback so the hub notifies you when a feed changes: - -```ts -import { createRssCloudClient } from '@rsscloud/client'; - -const client = createRssCloudClient({ serverUrl: 'https://hub.example' }); - -const { status, body } = await client.pleaseNotify({ - protocol: 'https-post', - callback: { domain: 'sub.example', port: 443, path: '/notify' }, - feedUrl: 'https://feed.example/rss' -}); -``` - -> `callback.domain` is **optional** and selects the verification flow on every -> transport. Give it (as above) and the hub uses that host — confirming an -> `http-post`/`https-post` callback with a one-time challenge `GET` to it. Omit it -> and the hub falls back to your connection's address with no challenge: -> -> ```ts -> await client.pleaseNotify({ -> protocol: 'http-post', -> callback: { port: 9000, path: '/notify' }, // no domain → caller address -> feedUrl: 'https://feed.example/rss' -> }); -> ``` -> -> For a public HTTPS callback you'll usually want the explicit `domain` so the hub -> reaches your real host. Use `protocol: 'http-post'` for a plain (non-TLS) -> callback, e.g. in local development. - -Then serve the callback the hub will call. It does two things: answer the one-time -**verify challenge** (a `GET` carrying `?challenge=…`), and accept **notifications** -(a `POST` with the changed resource URL in a `url` form field): - -```ts -import express from 'express'; -import { parseHttpPostNotify } from '@rsscloud/client'; - -const app = express(); - -// 1. Verify handshake — echo the challenge back verbatim. -app.get('/notify', (req, res) => { - res.send(String(req.query.challenge ?? '')); -}); - -// 2. Notification — the changed resource URL arrives as `url`. -app.post( - '/notify', - express.text({ type: 'application/x-www-form-urlencoded' }), - (req, res) => { - const feedUrl = parseHttpPostNotify(req.body); - // ...re-fetch feedUrl and process the update... - res.end(); - } -); - -app.listen(443); -``` - -`pleaseNotify` resolves to the hub's raw reply (`{ status, body }`); it does not -throw on a non-2xx — inspect `status` yourself. - -## Ping (publish a change) - -When your feed updates, tell the hub: - -```ts -import { createRssCloudClient } from '@rsscloud/client'; - -const client = createRssCloudClient({ serverUrl: 'https://hub.example' }); - -await client.ping({ feedUrl: 'https://feed.example/rss' }); -``` - -`ping` posts to the hub's REST `/ping` front door by default. - -## Advertising the hub in your feed - -Publishers announce their hub with a `` element so subscribers know where to -`pleaseNotify`. `renderCloudFeed` emits an RSS 2.0 document with it: - -```ts -import { renderCloudFeed } from '@rsscloud/client'; - -const xml = renderCloudFeed({ - title: 'Example feed', - link: 'https://feed.example/rss', - description: 'An rssCloud-enabled feed', - cloud: { - domain: 'hub.example', - port: 443, - path: '/pleaseNotify', - registerProcedure: '', - protocol: 'http-post' - }, - items: [ - { - title: 'First post', - description: 'Hello, cloud', - pubDate: new Date(), - guid: 'https://feed.example/posts/1' - } - ] -}); -``` - -## XML-RPC (secondary) - -For hubs and subscribers that speak rssCloud's original XML-RPC transport. The shape -is the same; the protocol/transport switches change the front door used: - -```ts -// Subscribe over XML-RPC (POSTed to the hub's /RPC2). -await client.pleaseNotify({ - protocol: 'xml-rpc', - callback: { domain: 'sub.example', port: 9000, path: '/RPC2' }, - feedUrl: 'https://feed.example/rss' -}); - -// Ping over XML-RPC. -await client.ping({ feedUrl: 'https://feed.example/rss', transport: 'xml-rpc' }); -``` - -An XML-RPC callback receives an `rssCloud.notify` `methodCall` and must answer with a -boolean-true `methodResponse`: - -```ts -import { parseXmlRpcNotify, buildNotifyResponse } from '@rsscloud/client'; - -app.post('/RPC2', express.text({ type: '*/xml' }), async (req, res) => { - const feedUrl = await parseXmlRpcNotify(req.body); - // ...re-fetch feedUrl and process the update... - res.type('text/xml').send(buildNotifyResponse()); -}); -``` - -The low-level wire builders (`buildPleaseNotifyCall`, `buildPingCall`) are exported -too, over the shared [`@rsscloud/xml-rpc`](../xml-rpc) codec, if you need to drive the -XML-RPC calls directly. - -## License - -MIT — see [LICENSE.md](./LICENSE.md). diff --git a/packages/client/eslint.config.mjs b/packages/client/eslint.config.mjs deleted file mode 100644 index c6a5ff4..0000000 --- a/packages/client/eslint.config.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import js from '@eslint/js'; -import tseslint from 'typescript-eslint'; - -export default tseslint.config( - { ignores: ['dist', 'coverage'] }, - js.configs.recommended, - ...tseslint.configs.recommended, - { - languageOptions: { - ecmaVersion: 2023, - sourceType: 'module', - parserOptions: { - tsconfigRootDir: import.meta.dirname - } - } - } -); diff --git a/packages/client/package.json b/packages/client/package.json deleted file mode 100644 index 87886c0..0000000 --- a/packages/client/package.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "name": "@rsscloud/client", - "version": "0.0.0", - "description": "The subscriber + publisher end of the rssCloud protocol — pleaseNotify, ping, notification handling, and feed emission", - "license": "MIT", - "author": "Andrew Shell ", - "repository": { - "type": "git", - "url": "https://github.com/rsscloud/rsscloud-server.git", - "directory": "packages/client" - }, - "homepage": "https://github.com/rsscloud/rsscloud-server/tree/main/packages/client#readme", - "bugs": { - "url": "https://github.com/rsscloud/rsscloud-server/issues" - }, - "type": "module", - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } - }, - "./package.json": "./package.json" - }, - "files": [ - "dist", - "README.md", - "LICENSE.md", - "CHANGELOG.md" - ], - "publishConfig": { - "access": "public" - }, - "engines": { - "node": ">=22" - }, - "sideEffects": false, - "dependencies": { - "@rsscloud/xml-rpc": "workspace:*", - "xml2js": "^0.6.2" - }, - "scripts": { - "build": "tsup", - "dev": "tsup --watch", - "clean": "rm -rf dist coverage", - "lint": "eslint src", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest", - "coverage": "vitest run --coverage", - "check": "pnpm run typecheck && pnpm run lint && pnpm run test", - "prepack": "pnpm run build", - "prepublishOnly": "pnpm run build" - }, - "devDependencies": { - "@eslint/js": "^9.18.0", - "@types/node": "^22.10.0", - "@types/xml2js": "^0.4.14", - "@vitest/coverage-v8": "^3.2.4", - "eslint": "^9.18.0", - "tsup": "^8.3.5", - "typescript": "^5.7.2", - "typescript-eslint": "^8.20.0", - "vitest": "^3.2.4" - } -} diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts deleted file mode 100644 index 7dff1f4..0000000 --- a/packages/client/src/client.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { parseMethodCall } from '@rsscloud/xml-rpc'; -import { createRssCloudClient } from './client.js'; - -interface Captured { - url: string; - init: RequestInit; -} - -function fakeFetch(status = 200, responseBody = 'OK') { - const calls: Captured[] = []; - const fn = (async (url: string | URL, init?: RequestInit) => { - calls.push({ url: String(url), init: init ?? {} }); - return new Response(responseBody, { status }); - }) as unknown as typeof fetch; - return { fn, calls }; -} - -function header(init: RequestInit, name: string): string | undefined { - return (init.headers as Record)[name]; -} - -function form(init: RequestInit): URLSearchParams { - return new URLSearchParams(init.body as string); -} - -describe('createRssCloudClient ping', () => { - it('pings over REST by default, posting the feed URL as a form', async () => { - const { fn, calls } = fakeFetch(); - const client = createRssCloudClient({ - serverUrl: 'http://hub.example:5337', - fetch: fn - }); - - const res = await client.ping({ feedUrl: 'https://feed.example/rss' }); - - expect(calls[0]?.url).toBe('http://hub.example:5337/ping'); - expect(calls[0]?.init.method).toBe('POST'); - expect(header(calls[0]!.init, 'Content-Type')).toBe( - 'application/x-www-form-urlencoded' - ); - expect(form(calls[0]!.init).get('url')).toBe( - 'https://feed.example/rss' - ); - expect(res).toEqual({ status: 200, body: 'OK' }); - }); - - it('pings over XML-RPC to /RPC2 when asked', async () => { - const { fn, calls } = fakeFetch(); - const client = createRssCloudClient({ - serverUrl: 'http://hub.example:5337', - fetch: fn - }); - - await client.ping({ - feedUrl: 'https://feed.example/rss', - transport: 'xml-rpc' - }); - - expect(calls[0]?.url).toBe('http://hub.example:5337/RPC2'); - expect(header(calls[0]!.init, 'Content-Type')).toBe('text/xml'); - const call = await parseMethodCall(calls[0]?.init.body as string); - expect(call.methodName).toBe('rssCloud.ping'); - expect(call.params).toEqual(['https://feed.example/rss']); - }); -}); - -describe('createRssCloudClient pleaseNotify', () => { - it('registers an http-post callback over the REST front door', async () => { - const { fn, calls } = fakeFetch(); - const client = createRssCloudClient({ - serverUrl: 'http://hub.example:5337', - fetch: fn - }); - - await client.pleaseNotify({ - protocol: 'http-post', - callback: { domain: 'sub.example', port: 9000, path: '/notify' }, - feedUrl: 'https://feed.example/rss' - }); - - expect(calls[0]?.url).toBe('http://hub.example:5337/pleaseNotify'); - expect(header(calls[0]!.init, 'Content-Type')).toBe( - 'application/x-www-form-urlencoded' - ); - const body = form(calls[0]!.init); - expect(body.get('port')).toBe('9000'); - expect(body.get('path')).toBe('/notify'); - expect(body.get('protocol')).toBe('http-post'); - expect(body.get('url1')).toBe('https://feed.example/rss'); - // An explicit domain is sent, so the hub uses it (the diffDomain flow). - expect(body.get('domain')).toBe('sub.example'); - }); - - it('omits domain from the REST form when none is given', async () => { - const { fn, calls } = fakeFetch(); - const client = createRssCloudClient({ - serverUrl: 'http://hub.example:5337', - fetch: fn - }); - - await client.pleaseNotify({ - protocol: 'http-post', - callback: { port: 9000, path: '/notify' }, - feedUrl: 'https://feed.example/rss' - }); - - // No domain → the hub falls back to the caller's connection address. - expect(form(calls[0]!.init).has('domain')).toBe(false); - }); - - it('registers an xml-rpc callback over /RPC2 with the six params', async () => { - const { fn, calls } = fakeFetch(); - const client = createRssCloudClient({ - serverUrl: 'http://hub.example:5337', - fetch: fn - }); - - await client.pleaseNotify({ - protocol: 'xml-rpc', - callback: { domain: 'sub.example', port: 9000, path: '/RPC2' }, - feedUrl: 'https://feed.example/rss' - }); - - expect(calls[0]?.url).toBe('http://hub.example:5337/RPC2'); - expect(header(calls[0]!.init, 'Content-Type')).toBe('text/xml'); - const call = await parseMethodCall(calls[0]?.init.body as string); - expect(call.methodName).toBe('rssCloud.pleaseNotify'); - expect(call.params).toEqual([ - 'rssCloud.notify', - 9000, - '/RPC2', - 'xml-rpc', - ['https://feed.example/rss'], - 'sub.example' - ]); - }); - - it('sends an empty domain param over xml-rpc when none is given', async () => { - const { fn, calls } = fakeFetch(); - const client = createRssCloudClient({ - serverUrl: 'http://hub.example:5337', - fetch: fn - }); - - await client.pleaseNotify({ - protocol: 'xml-rpc', - callback: { port: 9000, path: '/RPC2' }, - feedUrl: 'https://feed.example/rss' - }); - - const call = await parseMethodCall(calls[0]?.init.body as string); - // Empty domain → the hub treats it as absent (ADR-0001) and uses the caller. - expect(call.params[5]).toBe(''); - }); -}); - -describe('createRssCloudClient construction', () => { - it('strips a trailing slash from the server URL', async () => { - const { fn, calls } = fakeFetch(); - const client = createRssCloudClient({ - serverUrl: 'http://hub.example:5337/', - fetch: fn - }); - - await client.ping({ feedUrl: 'https://feed.example/rss' }); - - expect(calls[0]?.url).toBe('http://hub.example:5337/ping'); - }); - - it('defaults to the global fetch when none is injected', () => { - const client = createRssCloudClient({ - serverUrl: 'http://hub.example:5337' - }); - - expect(typeof client.ping).toBe('function'); - expect(typeof client.pleaseNotify).toBe('function'); - }); -}); diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts deleted file mode 100644 index 6d25d86..0000000 --- a/packages/client/src/client.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { buildPingCall, buildPleaseNotifyCall } from './rpc-calls.js'; - -/** The notification protocol a subscriber asks the hub to use. */ -export type NotifyProtocol = 'http-post' | 'https-post' | 'xml-rpc'; - -/** Where the hub should deliver notifications for a subscription. */ -export interface Callback { - /** - * Explicit callback host. When given, the hub uses it (the cross-domain - * `diffDomain` flow, with a verify challenge for http-post); when omitted, - * the hub falls back to the caller's connection address. - */ - domain?: string; - port: number; - path: string; -} - -/** Construction-time dependencies for the client. */ -export interface RssCloudClientOptions { - /** Base URL of the rssCloud hub (trailing slash optional). */ - serverUrl: string; - /** Injectable fetch (tests, edge runtimes); defaults to global fetch. */ - fetch?: typeof fetch; -} - -/** A `pleaseNotify` subscription request. */ -export interface PleaseNotifyOptions { - /** Notification protocol; `xml-rpc` registers over `/RPC2`, the rest over REST. */ - protocol: NotifyProtocol; - callback: Callback; - feedUrl: string; -} - -/** A `ping` change signal. */ -export interface PingOptions { - feedUrl: string; - /** Front door to use; `rest` posts to `/ping`, `xml-rpc` to `/RPC2`. Default `rest`. */ - transport?: 'rest' | 'xml-rpc'; -} - -/** The hub's raw reply. */ -export interface RssCloudResponse { - status: number; - body: string; -} - -/** The subscriber + publisher operations against one hub. */ -export interface RssCloudClient { - pleaseNotify(opts: PleaseNotifyOptions): Promise; - ping(opts: PingOptions): Promise; -} - -const FORM_TYPE = 'application/x-www-form-urlencoded'; -const XML_TYPE = 'text/xml'; - -/** - * Build a client bound to one hub. `pleaseNotify`/`ping` choose their front door - * from the request shape (mirroring the reference test client): an `xml-rpc` - * subscription and an `xml-rpc` ping go to `/RPC2`; everything else uses the REST - * front doors. The outbound `fetch` is injectable for tests. - */ -export function createRssCloudClient( - options: RssCloudClientOptions -): RssCloudClient { - const doFetch = options.fetch ?? fetch; - const base = options.serverUrl.replace(/\/$/, ''); - - async function send( - path: string, - contentType: string, - body: string - ): Promise { - const res = await doFetch(`${base}${path}`, { - method: 'POST', - headers: { 'Content-Type': contentType }, - body - }); - return { status: res.status, body: await res.text() }; - } - - async function pleaseNotify( - opts: PleaseNotifyOptions - ): Promise { - if (opts.protocol === 'xml-rpc') { - return send( - '/RPC2', - XML_TYPE, - buildPleaseNotifyCall({ - notifyProcedure: 'rssCloud.notify', - port: opts.callback.port, - path: opts.callback.path, - protocol: opts.protocol, - urls: [opts.feedUrl], - // Empty string = "no explicit domain" (ADR-0001). - domain: opts.callback.domain ?? '' - }) - ); - } - const form = new URLSearchParams({ - port: String(opts.callback.port), - path: opts.callback.path, - protocol: opts.protocol, - url1: opts.feedUrl - }); - // Send domain only when explicit; otherwise the hub uses the caller address. - if (opts.callback.domain) { - form.set('domain', opts.callback.domain); - } - return send('/pleaseNotify', FORM_TYPE, form.toString()); - } - - async function ping(opts: PingOptions): Promise { - if (opts.transport === 'xml-rpc') { - return send('/RPC2', XML_TYPE, buildPingCall(opts.feedUrl)); - } - return send( - '/ping', - FORM_TYPE, - new URLSearchParams({ url: opts.feedUrl }).toString() - ); - } - - return { pleaseNotify, ping }; -} diff --git a/packages/client/src/feed.test.ts b/packages/client/src/feed.test.ts deleted file mode 100644 index f7d03db..0000000 --- a/packages/client/src/feed.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Parser } from 'xml2js'; -import { describe, expect, it } from 'vitest'; -import { renderCloudFeed } from './feed.js'; - -function reparse(xml: string): Promise { - return new Parser({ explicitArray: false }).parseStringPromise(xml); -} - -interface ParsedFeed { - rss: { - $: { version: string }; - channel: { - title: string; - link: string; - description: string; - cloud: { $: Record }; - item: - | { title: string; guid: string; pubDate: string } - | { title: string; guid: string; pubDate: string }[]; - }; - }; -} - -const CLOUD = { - domain: 'localhost', - port: 5337, - path: '/RPC2', - registerProcedure: 'rssCloud.pleaseNotify', - protocol: 'xml-rpc' -}; - -describe('renderCloudFeed', () => { - it('renders a channel with the element and an item', async () => { - const xml = renderCloudFeed({ - title: 'Test Feed', - link: 'http://sub.example:9000/rss-01.xml', - description: 'Test feed for rssCloud', - cloud: CLOUD, - items: [ - { - title: 'Update one', - description: 'first', - pubDate: new Date('2026-01-02T03:04:05Z'), - guid: 'rss-01-0' - } - ] - }); - - const { rss } = (await reparse(xml)) as ParsedFeed; - const channel = rss.channel; - const item = channel.item as { title: string; guid: string; pubDate: string }; - expect(rss.$.version).toBe('2.0'); - expect(channel.title).toBe('Test Feed'); - expect(channel.link).toBe('http://sub.example:9000/rss-01.xml'); - expect(channel.cloud.$).toEqual({ - domain: 'localhost', - port: '5337', - path: '/RPC2', - registerProcedure: 'rssCloud.pleaseNotify', - protocol: 'xml-rpc' - }); - expect(item.title).toBe('Update one'); - expect(item.guid).toBe('rss-01-0'); - expect(item.pubDate).toBe('Fri, 02 Jan 2026 03:04:05 GMT'); - }); - - it('renders multiple items in order', async () => { - const xml = renderCloudFeed({ - title: 'Test Feed', - link: 'http://sub.example:9000/rss-01.xml', - description: 'Test feed for rssCloud', - cloud: CLOUD, - items: [ - { - title: 'one', - description: 'a', - pubDate: new Date('2026-01-02T00:00:00Z'), - guid: 'g0' - }, - { - title: 'two', - description: 'b', - pubDate: new Date('2026-01-03T00:00:00Z'), - guid: 'g1' - } - ] - }); - - const { rss } = (await reparse(xml)) as ParsedFeed; - const items = rss.channel.item as { title: string }[]; - expect(items.map(i => i.title)).toEqual(['one', 'two']); - }); -}); diff --git a/packages/client/src/feed.ts b/packages/client/src/feed.ts deleted file mode 100644 index 9fc95c5..0000000 --- a/packages/client/src/feed.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Builder } from 'xml2js'; - -/** The `` element advertising where to subscribe for change notifications. */ -export interface CloudElement { - domain: string; - port: number; - path: string; - registerProcedure: string; - protocol: string; -} - -/** One `` in the rendered feed. */ -export interface FeedItem { - title: string; - description: string; - pubDate: Date; - guid: string; -} - -/** Inputs for {@link renderCloudFeed}. */ -export interface CloudFeedOptions { - title: string; - link: string; - description: string; - cloud: CloudElement; - items: FeedItem[]; -} - -/** - * Render an RSS 2.0 feed carrying a `` element — the document a publisher - * serves so a hub knows where to register for its change notifications. Item - * `pubDate`s are emitted in RFC 822 form. - */ -export function renderCloudFeed(opts: CloudFeedOptions): string { - return new Builder().buildObject({ - rss: { - $: { version: '2.0' }, - channel: { - title: opts.title, - link: opts.link, - description: opts.description, - cloud: { - $: { - domain: opts.cloud.domain, - port: String(opts.cloud.port), - path: opts.cloud.path, - registerProcedure: opts.cloud.registerProcedure, - protocol: opts.cloud.protocol - } - }, - item: opts.items.map(item => ({ - title: item.title, - description: item.description, - pubDate: item.pubDate.toUTCString(), - guid: item.guid - })) - } - } - }); -} diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts deleted file mode 100644 index 63fc115..0000000 --- a/packages/client/src/index.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as api from './index.js'; - -describe('@rsscloud/client public API', () => { - it('exports the client factory', () => { - expect(typeof api.createRssCloudClient).toBe('function'); - }); - - it('exports the rssCloud request builders', () => { - expect(typeof api.buildPleaseNotifyCall).toBe('function'); - expect(typeof api.buildPingCall).toBe('function'); - }); - - it('exports the notification receive helpers', () => { - expect(typeof api.parseHttpPostNotify).toBe('function'); - expect(typeof api.parseXmlRpcNotify).toBe('function'); - expect(typeof api.buildNotifyResponse).toBe('function'); - }); - - it('exports the cloud feed renderer', () => { - expect(typeof api.renderCloudFeed).toBe('function'); - }); -}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts deleted file mode 100644 index e5bd154..0000000 --- a/packages/client/src/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -export { - createRssCloudClient, - type Callback, - type NotifyProtocol, - type PingOptions, - type PleaseNotifyOptions, - type RssCloudClient, - type RssCloudClientOptions, - type RssCloudResponse -} from './client.js'; -export { - buildPleaseNotifyCall, - buildPingCall, - type PleaseNotifyParams -} from './rpc-calls.js'; -export { - buildNotifyResponse, - parseHttpPostNotify, - parseXmlRpcNotify -} from './notify.js'; -export { - renderCloudFeed, - type CloudElement, - type CloudFeedOptions, - type FeedItem -} from './feed.js'; diff --git a/packages/client/src/notify.test.ts b/packages/client/src/notify.test.ts deleted file mode 100644 index 6dd7625..0000000 --- a/packages/client/src/notify.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Parser } from 'xml2js'; -import { describe, expect, it } from 'vitest'; -import { buildPingCall } from './rpc-calls.js'; -import { - buildNotifyResponse, - parseHttpPostNotify, - parseXmlRpcNotify -} from './notify.js'; - -// A notify methodCall has the same one-URL-param shape as ping, so buildPingCall -// is a convenient way to mint a well-formed notify body. -function notifyXml(url: string): string { - return buildPingCall(url).replace('rssCloud.ping', 'rssCloud.notify'); -} - -describe('parseXmlRpcNotify', () => { - it('extracts the changed resource URL from a notify methodCall', async () => { - const url = await parseXmlRpcNotify(notifyXml('https://feed.example/rss')); - - expect(url).toBe('https://feed.example/rss'); - }); - - it('returns an empty string when the call carries no param', async () => { - const url = await parseXmlRpcNotify( - 'rssCloud.notify' - ); - - expect(url).toBe(''); - }); -}); - -describe('parseHttpPostNotify', () => { - it('extracts the changed resource URL from the form body', () => { - expect(parseHttpPostNotify('url=https%3A%2F%2Ffeed.example%2Frss')).toBe( - 'https://feed.example/rss' - ); - }); - - it('returns an empty string when the form has no url', () => { - expect(parseHttpPostNotify('other=1')).toBe(''); - }); -}); - -describe('buildNotifyResponse', () => { - it('builds a boolean-true methodResponse', async () => { - const parsed = (await new Parser({ - explicitArray: false - }).parseStringPromise(buildNotifyResponse())) as { - methodResponse: { params: { param: { value: { boolean: string } } } }; - }; - - expect(parsed.methodResponse.params.param.value.boolean).toBe('1'); - }); -}); diff --git a/packages/client/src/notify.ts b/packages/client/src/notify.ts deleted file mode 100644 index 8107070..0000000 --- a/packages/client/src/notify.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { bool, buildMethodResponse, parseMethodCall } from '@rsscloud/xml-rpc'; - -/** - * Extract the changed resource URL from an http-post notification body — the hub - * POSTs `url=` as an `application/x-www-form-urlencoded` form. - */ -export function parseHttpPostNotify(body: string): string { - return new URLSearchParams(body).get('url') ?? ''; -} - -/** - * Extract the changed resource URL from an XML-RPC `rssCloud.notify` methodCall - * (the resource URL is its single param). - */ -export async function parseXmlRpcNotify(xml: string): Promise { - const { params } = await parseMethodCall(xml); - const url = params[0]; - return typeof url === 'string' ? url : ''; -} - -/** - * Build the boolean-true `methodResponse` a subscriber returns to acknowledge an - * XML-RPC notification. - */ -export function buildNotifyResponse(): string { - return buildMethodResponse(bool(true)); -} diff --git a/packages/client/src/rpc-calls.test.ts b/packages/client/src/rpc-calls.test.ts deleted file mode 100644 index ca24388..0000000 --- a/packages/client/src/rpc-calls.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { parseMethodCall } from '@rsscloud/xml-rpc'; -import { buildPingCall, buildPleaseNotifyCall } from './rpc-calls.js'; - -describe('buildPingCall', () => { - it('builds a rssCloud.ping methodCall carrying the feed URL', async () => { - const call = await parseMethodCall( - buildPingCall('https://feed.example/rss') - ); - - expect(call.methodName).toBe('rssCloud.ping'); - expect(call.params).toEqual(['https://feed.example/rss']); - }); -}); - -describe('buildPleaseNotifyCall', () => { - it('builds the six pleaseNotify params in wire order', async () => { - const call = await parseMethodCall( - buildPleaseNotifyCall({ - notifyProcedure: 'rssCloud.notify', - port: 9000, - path: '/RPC2', - protocol: 'xml-rpc', - urls: ['https://feed.example/rss'], - domain: 'sub.example' - }) - ); - - expect(call.methodName).toBe('rssCloud.pleaseNotify'); - expect(call.params).toEqual([ - 'rssCloud.notify', - 9000, - '/RPC2', - 'xml-rpc', - ['https://feed.example/rss'], - 'sub.example' - ]); - }); - - it('carries an empty notifyProcedure and multiple urls', async () => { - const call = await parseMethodCall( - buildPleaseNotifyCall({ - notifyProcedure: '', - port: 80, - path: '/notify', - protocol: 'http-post', - urls: ['https://a.example/rss', 'https://b.example/rss'], - domain: 'sub.example' - }) - ); - - expect(call.params).toEqual([ - '', - 80, - '/notify', - 'http-post', - ['https://a.example/rss', 'https://b.example/rss'], - 'sub.example' - ]); - }); -}); diff --git a/packages/client/src/rpc-calls.ts b/packages/client/src/rpc-calls.ts deleted file mode 100644 index 02b4a3c..0000000 --- a/packages/client/src/rpc-calls.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { array, buildMethodCall, i4, str } from '@rsscloud/xml-rpc'; - -/** The wire-shaped inputs to the rssCloud `pleaseNotify` XML-RPC call. */ -export interface PleaseNotifyParams { - /** The procedure the hub should call to notify (e.g. `rssCloud.notify`); - * empty for non-XML-RPC callback protocols. */ - notifyProcedure: string; - /** Port the hub should reach the callback on. */ - port: number; - /** Path of the callback. */ - path: string; - /** Protocol the hub should notify with (`http-post`/`https-post`/`xml-rpc`). */ - protocol: string; - /** The feed URLs to subscribe to (the `urlList`). */ - urls: string[]; - /** The callback's domain. */ - domain: string; -} - -/** - * Build the rssCloud `pleaseNotify` `methodCall` — the six positional params in - * wire order: notifyProcedure, port, path, protocol, urlList, domain. - */ -export function buildPleaseNotifyCall(params: PleaseNotifyParams): string { - return buildMethodCall('rssCloud.pleaseNotify', [ - str(params.notifyProcedure), - i4(params.port), - str(params.path), - str(params.protocol), - array(params.urls.map(str)), - str(params.domain) - ]); -} - -/** Build the rssCloud `ping` `methodCall` carrying a single feed URL. */ -export function buildPingCall(feedUrl: string): string { - return buildMethodCall('rssCloud.ping', [str(feedUrl)]); -} diff --git a/packages/client/tsconfig.build.json b/packages/client/tsconfig.build.json deleted file mode 100644 index 9d88b88..0000000 --- a/packages/client/tsconfig.build.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "rootDir": "src" - }, - "include": ["src/**/*.ts"], - "exclude": ["dist", "coverage", "node_modules", "src/**/*.test.ts"] -} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json deleted file mode 100644 index cd657bd..0000000 --- a/packages/client/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022"], - "module": "ESNext", - "moduleResolution": "Bundler", - "moduleDetection": "force", - "isolatedModules": true, - "resolveJsonModule": true, - "esModuleInterop": true, - "strict": true, - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, - "noImplicitOverride": true, - "noFallthroughCasesInSwitch": true, - "noPropertyAccessFromIndexSignature": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "dist", - "types": ["node"] - }, - "include": ["src/**/*.ts", "*.config.ts"], - "exclude": ["dist", "coverage", "node_modules"] -} diff --git a/packages/client/tsup.config.ts b/packages/client/tsup.config.ts deleted file mode 100644 index 0f29864..0000000 --- a/packages/client/tsup.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from 'tsup'; - -export default defineConfig({ - entry: ['src/index.ts'], - format: ['esm', 'cjs'], - dts: true, - sourcemap: true, - clean: true, - treeshake: true, - splitting: false, - target: 'node22', - tsconfig: './tsconfig.build.json' -}); diff --git a/packages/client/vitest.config.ts b/packages/client/vitest.config.ts deleted file mode 100644 index f571462..0000000 --- a/packages/client/vitest.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - include: ['src/**/*.test.ts'], - coverage: { - provider: 'v8', - reporter: ['text', 'html', 'lcov'], - include: ['src/**/*.ts'], - exclude: ['src/**/*.test.ts'], - thresholds: { - lines: 100, - functions: 100, - branches: 100, - statements: 100 - } - } - } -}); diff --git a/packages/xml-rpc/README.md b/packages/xml-rpc/README.md index bbd2887..4e34cea 100644 --- a/packages/xml-rpc/README.md +++ b/packages/xml-rpc/README.md @@ -4,9 +4,9 @@ A small, generic [XML-RPC](http://xmlrpc.com/) codec — parse and build `methodCall` / `methodResponse` documents — shared by the [rssCloud](https://github.com/rsscloud/rsscloud-server) packages. -`@rsscloud/core` (the hub) and `@rsscloud/client` (the subscriber/publisher end) -both speak XML-RPC over the `/RPC2` front door; this package is the one home for -the encode/decode dance, so a wire bug has a single place to live. It holds no +`@rsscloud/core` (the hub) and the `apps/client` harness (the subscriber/publisher +end) both speak XML-RPC over the `/RPC2` front door; this package is the one home +for the encode/decode dance, so a wire bug has a single place to live. It holds no rssCloud semantics of its own — callers map their own `rssCloud.*` method shapes onto it. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a2efcb..f136496 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,9 +31,9 @@ importers: apps/client: dependencies: - '@rsscloud/client': + '@rsscloud/xml-rpc': specifier: workspace:* - version: link:../../packages/client + version: link:../../packages/xml-rpc body-parser: specifier: ^2.2.2 version: 2.2.2 @@ -43,6 +43,9 @@ importers: morgan: specifier: ^1.10.1 version: 1.10.1 + xml2js: + specifier: ^0.6.2 + version: 0.6.2 devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -148,43 +151,6 @@ importers: specifier: 3.1.14 version: 3.1.14 - packages/client: - dependencies: - '@rsscloud/xml-rpc': - specifier: workspace:* - version: link:../xml-rpc - xml2js: - specifier: ^0.6.2 - version: 0.6.2 - devDependencies: - '@eslint/js': - specifier: ^9.18.0 - version: 9.39.4 - '@types/node': - specifier: ^22.10.0 - version: 22.19.19 - '@types/xml2js': - specifier: ^0.4.14 - version: 0.4.14 - '@vitest/coverage-v8': - specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1)) - eslint: - specifier: ^9.18.0 - version: 9.39.4(jiti@2.6.1) - tsup: - specifier: ^8.3.5 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.15)(typescript@5.9.3) - typescript: - specifier: ^5.7.2 - version: 5.9.3 - typescript-eslint: - specifier: ^8.20.0 - version: 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1) - packages/core: dependencies: '@rsscloud/xml-rpc': diff --git a/release-please-config.json b/release-please-config.json index edce442..b3a826e 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -24,12 +24,6 @@ "component": "express", "changelog-path": "CHANGELOG.md", "package-name": "@rsscloud/express" - }, - "packages/client": { - "release-type": "node", - "component": "client", - "changelog-path": "CHANGELOG.md", - "package-name": "@rsscloud/client" } } } From aea23b8936dbd735a8b6d0bcc46a0cbcf5aaa229 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 20:44:18 -0500 Subject: [PATCH 88/90] fix(server): build @rsscloud/xml-rpc in the Docker image The server Dockerfile predated the @rsscloud/xml-rpc extraction and never built it, so @rsscloud/core's dts build could not resolve the module (TS2307) during the CI Docker build. Copy the package, build the express dependency graph topologically (xml-rpc -> core -> express), and ship its dist in the runtime stage. Also tidy dependencies surfaced while auditing the workspace: - e2e: drop unused chai-json and supertest - server: move xml2js to devDependencies (only the OPML test uses it) - root: declare @eslint/js, which eslint.config.js requires Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/e2e/package.json | 2 -- apps/server/Dockerfile | 6 ++-- apps/server/package.json | 4 +-- package.json | 1 + pnpm-lock.yaml | 63 ++++------------------------------------ 5 files changed, 13 insertions(+), 63 deletions(-) diff --git a/apps/e2e/package.json b/apps/e2e/package.json index ed75281..89b044e 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -18,14 +18,12 @@ "body-parser": "^2.2.2", "chai": "^4.5.0", "chai-http": "^4.4.0", - "chai-json": "^1.0.0", "chai-xml": "^0.4.1", "dayjs": "^1.11.20", "eslint": "^10.4.0", "express": "^4.22.2", "mocha": "^11.7.5", "mocha-multi": "^1.1.7", - "supertest": "^7.2.2", "xml2js": "^0.6.2", "xmlbuilder": "^15.1.1" } diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 3bb2c35..92429b9 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -7,17 +7,18 @@ WORKDIR /app COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ COPY apps/server/package.json apps/server/ COPY apps/e2e/package.json apps/e2e/ +COPY packages/xml-rpc/package.json packages/xml-rpc/ COPY packages/core/package.json packages/core/ COPY packages/express/package.json packages/express/ # Build the workspace packages (TS → CJS/ESM dist) the server now consumes. FROM base AS build +COPY packages/xml-rpc packages/xml-rpc COPY packages/core packages/core COPY packages/express packages/express RUN pnpm install --frozen-lockfile --filter "@rsscloud/express..." -RUN pnpm --filter @rsscloud/core run build \ - && pnpm --filter @rsscloud/express run build +RUN pnpm --filter "@rsscloud/express..." run build FROM base AS dependencies @@ -26,6 +27,7 @@ RUN pnpm install --frozen-lockfile --filter "@rsscloud/server..." --prod --ignor FROM dependencies AS runtime COPY apps/server apps/server +COPY --from=build /app/packages/xml-rpc/dist packages/xml-rpc/dist COPY --from=build /app/packages/core/dist packages/core/dist COPY --from=build /app/packages/express/dist packages/express/dist diff --git a/apps/server/package.json b/apps/server/package.json index 3405c50..37a7b59 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -25,12 +25,12 @@ "markdown-it": "^14.1.1", "morgan": "^1.10.1", "ws": "^8.20.1", - "xml2js": "^0.6.2", "xmlbuilder": "^15.1.1" }, "devDependencies": { "@eslint/js": "^10.0.1", "eslint": "^10.4.0", - "nodemon": "3.1.14" + "nodemon": "3.1.14", + "xml2js": "^0.6.2" } } diff --git a/package.json b/package.json index 98ecf30..41e6fec 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "devDependencies": { "@commitlint/cli": "^20.5.3", "@commitlint/config-conventional": "^20.5.3", + "@eslint/js": "^10.0.1", "husky": "^9.1.7", "prettier": "^3.8.3", "turbo": "^2.9.14" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f136496..3461db5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,9 @@ importers: '@commitlint/config-conventional': specifier: ^20.5.3 version: 20.5.3 + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.4.0(jiti@2.6.1)) husky: specifier: ^9.1.7 version: 9.1.7 @@ -71,9 +74,6 @@ importers: chai-http: specifier: ^4.4.0 version: 4.4.0 - chai-json: - specifier: ^1.0.0 - version: 1.0.0 chai-xml: specifier: ^0.4.1 version: 0.4.1(chai@4.5.0) @@ -92,9 +92,6 @@ importers: mocha-multi: specifier: ^1.1.7 version: 1.1.7(mocha@11.7.5) - supertest: - specifier: ^7.2.2 - version: 7.2.2 xml2js: specifier: ^0.6.2 version: 0.6.2 @@ -134,9 +131,6 @@ importers: ws: specifier: ^8.20.1 version: 8.20.1 - xml2js: - specifier: ^0.6.2 - version: 0.6.2 xmlbuilder: specifier: ^15.1.1 version: 15.1.1 @@ -150,6 +144,9 @@ importers: nodemon: specifier: 3.1.14 version: 3.1.14 + xml2js: + specifier: ^0.6.2 + version: 0.6.2 packages/core: dependencies: @@ -1245,19 +1242,12 @@ packages: resolution: {integrity: sha512-uswN3rZpawlRaa5NiDUHcDZ3v2dw5QgLyAwnQ2tnVNuP7CwIsOFuYJ0xR1WiR7ymD4roBnJIzOUep7w9jQMFJA==} engines: {node: '>=10'} - chai-json@1.0.0: - resolution: {integrity: sha512-p9eEYu3H2BkpU8PW8kqt0N6Ni6UG3LCCjlqHtZbrgvGRJYD4UnJuh704EFbGRxylzEPWsaOGhgXfdn0n1W3hhA==} - chai-xml@0.4.1: resolution: {integrity: sha512-VUf5Ol4ifOAsgz+lN4tfWENgQtrKxHPWsmpL5wdbqQdkpblZkcDlaT2aFvsPQH219Yvl8vc4064yFErgBIn9bw==} engines: {node: '>= 0.8.0'} peerDependencies: chai: '>=1.10.0 ' - chai@3.5.0: - resolution: {integrity: sha512-eRYY0vPS2a9zt5w5Z0aCeWbrXTEyvk7u/Xf71EzNObrjSCPgMm1Nku/D/u2tiqHBX5j40wWhj54YJLtgn8g55A==} - engines: {node: '>= 0.4.0'} - chai@4.5.0: resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} engines: {node: '>=4'} @@ -1412,9 +1402,6 @@ packages: resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} engines: {node: '>=10'} - deep-eql@0.1.3: - resolution: {integrity: sha512-6sEotTRGBFiNcqVoeHwnfopbSpi5NbH1VWJmYCVkmxMmaVTT0bUTrNaGyBwhgP4MZL012W/mkzIn3Da+iDYweg==} - deep-eql@4.1.4: resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} engines: {node: '>=6'} @@ -1948,9 +1935,6 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - jsonfile@3.0.1: - resolution: {integrity: sha512-oBko6ZHlubVB5mRFkur5vgYR1UyqX+S6Y/oCfLhqNdcc2fYFlDpIoNc7AfKS1KOGcnNAkvsr0grLck9ANM815w==} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2633,12 +2617,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-detect@0.1.1: - resolution: {integrity: sha512-5rqszGVwYgBoDkIm2oUtvkfZMQ0vk29iDMU0W2qCa3rG0vPDNczCMT4hV/bLBgLg8k8ri6+u3Zbt+S/14eMzlA==} - - type-detect@1.0.0: - resolution: {integrity: sha512-f9Uv6ezcpvCQjJU0Zqbg+65qdcszv3qUQsZfjdRbWiZ7AMenrX1u0lNk9EoWWX6e1F+NULyg27mtdeZ5WhpljA==} - type-detect@4.1.0: resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} engines: {node: '>=4'} @@ -2677,9 +2655,6 @@ packages: undefsafe@2.0.5: resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} - underscore@1.13.8: - resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} - undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -3794,23 +3769,11 @@ snapshots: transitivePeerDependencies: - supports-color - chai-json@1.0.0: - dependencies: - chai: 3.5.0 - jsonfile: 3.0.1 - underscore: 1.13.8 - chai-xml@0.4.1(chai@4.5.0): dependencies: chai: 4.5.0 xml2js: 0.5.0 - chai@3.5.0: - dependencies: - assertion-error: 1.1.0 - deep-eql: 0.1.3 - type-detect: 1.0.0 - chai@4.5.0: dependencies: assertion-error: 1.1.0 @@ -3969,10 +3932,6 @@ snapshots: decamelize@4.0.0: {} - deep-eql@0.1.3: - dependencies: - type-detect: 0.1.1 - deep-eql@4.1.4: dependencies: type-detect: 4.1.0 @@ -4568,10 +4527,6 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} - jsonfile@3.0.1: - optionalDependencies: - graceful-fs: 4.2.11 - keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -5285,10 +5240,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-detect@0.1.1: {} - - type-detect@1.0.0: {} - type-detect@4.1.0: {} type-is@1.6.18: @@ -5324,8 +5275,6 @@ snapshots: undefsafe@2.0.5: {} - underscore@1.13.8: {} - undici-types@6.21.0: {} undici-types@7.24.6: {} From 9d72411e0dc43d050b19fcb37ef4886175b4a2bc Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 21:27:55 -0500 Subject: [PATCH 89/90] docs: document all workspace packages in the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package overview only mentioned apps/server and packages/core. Add the units introduced on this branch — @rsscloud/express, @rsscloud/xml-rpc, and the private apps/client dev harness — and restore apps/client's own README (dropped in the package->app fold-in) so each entry links to live docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 3 ++ apps/client/README.md | 76 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 apps/client/README.md diff --git a/README.md b/README.md index 9564d90..9f8eb44 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,10 @@ A monorepo for the [rssCloud](http://rsscloud.org/) notification protocol. ## Packages - **[`apps/server`](apps/server/README.md)** — rssCloud Server: an Express implementation of the rssCloud notification protocol. Handles subscriptions, ping, and notifications for RSS feed updates. +- **[`apps/client`](apps/client/README.md)** — a private, interactive dev harness for the rssCloud client: a Subscribe/Ping UI with a request log that hosts a notify endpoint and drives a server. Not published. - **[`packages/core`](packages/core/README.md)** — `@rsscloud/core`: shared primitives for subscriptions, notifications, and feed processing. +- **[`packages/express`](packages/express/README.md)** — `@rsscloud/express`: Express middleware for the rssCloud front doors — `pleaseNotify`, `ping`, and the `RPC2` endpoint, built on `@rsscloud/core`. +- **[`packages/xml-rpc`](packages/xml-rpc/README.md)** — `@rsscloud/xml-rpc`: a generic XML-RPC codec — parse and build `methodCall`/`methodResponse` documents. ## Development diff --git a/apps/client/README.md b/apps/client/README.md new file mode 100644 index 0000000..31b88f2 --- /dev/null +++ b/apps/client/README.md @@ -0,0 +1,76 @@ +# @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. All the protocol wire +work lives in [`lib/`](lib/) and is reusable on its own. + +## Running + +From the repo root: + +```bash +pnpm client # start in watch mode (nodemon) +``` + +Or from this package: + +```bash +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`). + +| Env var | Default | Purpose | +| -------- | ----------- | ----------------------------------------- | +| `PORT` | `9000` | port the harness listens on | +| `DOMAIN` | `localhost` | host it advertises as the callback domain | + +## The `lib/` API + +`require('./lib')` exposes three helpers (CommonJS): + +- **`createRssCloudClient({ serverUrl, fetch? })`** — send `pleaseNotify` (subscribe) + and `ping` (publish) to a hub over an injectable `fetch`. Returns `{ pleaseNotify, ping }`. +- **`renderCloudFeed(feed)`** — emit an RSS 2.0 document carrying the `` element + that advertises a hub. +- **`buildNotifyResponse(success)`** — build the XML-RPC notify acknowledgement a + subscriber returns to the hub. + +### Subscribe + +```js +const { createRssCloudClient } = require('./lib'); + +const client = createRssCloudClient({ serverUrl: 'http://localhost:5337' }); + +const { status, body } = await client.pleaseNotify({ + protocol: 'https-post', + callback: { port: 443, path: '/notify' }, + feedUrl: 'https://feed.example/rss' +}); +``` + +`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 + +```js +const { createRssCloudClient } = require('./lib'); + +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 +``` From b13b558cf8287577a9d500752dbe2dd4a70b8018 Mon Sep 17 00:00:00 2001 From: Andrew Shell Date: Sat, 13 Jun 2026 21:38:42 -0500 Subject: [PATCH 90/90] ci: stop running every check twice on pull requests The workflow triggered on both push to 4.x and pull_request to main, so an open PR from 4.x ran each job twice (a push run and a pull_request run). Drop feature branches from the push trigger: PRs are covered by the pull_request event (which tests the merge result), and push CI stays on main for the post-merge run, release flow, and status badge. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a7789d..3d7da54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: pull_request: branches: [main] push: - branches: [main, 4.x] + branches: [main] concurrency: group: ci-${{ github.workflow }}-${{ github.ref }}
Feeds changed in last 7 days{{feedsChangedLast7Days}}Feeds changed in last {{windowDays}} days{{feedsChangedLastWindow}}
Feeds with subscribers