diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 51aff71..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 diff --git a/.dockerignore b/.dockerignore index cb09176..bddc5fe 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,11 @@ .git .github -.circleci -node_modules -data -xunit +**/node_modules +**/data +packages/*/dist +packages/*/coverage +.turbo +**/xunit .nyc_output .env .DS_Store diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3d7da54 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +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@v5 + + - name: Enable corepack + run: corepack enable + + - uses: actions/setup-node@v5 + 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@v5 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Run integration tests + 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: apps/e2e/xunit/test-results.xml + if-no-files-found: warn 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..0a1506f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,12 @@ .DS_Store .env .nyc_output/ -/data/ -/node_modules/ -/xunit/ +coverage/ +data/ +dist/ +node_modules/ +xunit/ +.turbo/ Procfile tunnel.sh +*.local.* \ No newline at end of file diff --git a/.jshintrc b/.jshintrc index af8a484..4b301a4 100644 --- a/.jshintrc +++ b/.jshintrc @@ -1 +1 @@ -{ "esversion":8 } +{ "esversion": 8 } 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/.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..0a2f68c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,6 @@ { - ".": "3.0.0" + "apps/server": "4.0.0", + "packages/xml-rpc": "0.0.0", + "packages/core": "0.0.0", + "packages/express": "0.0.0" } diff --git a/CLAUDE.md b/CLAUDE.md index 7b4420d..7b15eee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,100 +2,28 @@ 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 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. +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 Commands +## Development workflow -This project uses pnpm with corepack. Run `corepack enable` to set up pnpm automatically. +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. -### Start Development +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. -- `pnpm start` - Start server with nodemon (auto-reload on changes) -- `pnpm run client` - Start client with nodemon +## Data storage -### Testing & Quality +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. -- `pnpm test` - Run full API tests using Docker containers (MacOS tested) -- `pnpm run lint` - Run ESLint with auto-fix on controllers/, services/, test/ +## End-to-end tests -## Architecture +`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. -### Core Application Structure +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. -- **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 +## Releases -### Key Services +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. -- **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 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 - -- Unit tests in test/ directory using Mocha/Chai -- Docker-based API testing with mock endpoints -- Test fixtures and SSL certificates in test/keys/ - -## 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 (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) - -**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. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..69da97a --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,129 @@ +# 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. `apps/client` is the matching +**Client** end (the **Subscriber** + **Publisher** side), and `@rsscloud/xml-rpc` is the +**XML-RPC codec** both ends share. + +## 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. + +**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**, +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**: +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 ``. diff --git a/README.md b/README.md index e3f2e8c..9f8eb44 100644 --- a/README.md +++ b/README.md @@ -1,130 +1,34 @@ -# rssCloud Server +# rssCloud [![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 +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. +- **[`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 + +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/TODO.md b/TODO.md new file mode 100644 index 0000000..d178f71 --- /dev/null +++ b/TODO.md @@ -0,0 +1,49 @@ +# TODO — rsscloud-server: open work + +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 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`. + +## 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). 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 +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, +HMAC, and leases. diff --git a/app.js b/app.js deleted file mode 100644 index 404ef88..0000000 --- a/app.js +++ /dev/null @@ -1,132 +0,0 @@ -require('dotenv').config(); - -const config = require('./config'), - cors = require('cors'), - 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'); - -let app, hbs, server, dayjs; - -console.log(`${config.appName} ${config.appVersion}`); - -// Schedule cleanup tasks -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)); - - // 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 -} - -morgan.format('mydate', () => { - return new Date().toLocaleTimeString('en-US', { hour12: false, fractionalSecondDigits: 3 }).replace(/:/g, ':'); -}); - -// Initialize dayjs at startup -async function initializeDayjs() { - dayjs = await getDayjs(); -} - -app = express(); - -app.set('trust proxy', true); - -app.use(morgan('[:mydate] :method :url :status :res[content-length] - :remote-addr - :response-time ms')); - -app.use(cors()); - -// Configure handlebars template engine to work with dayjs -hbs = exphbs.create({ - helpers: { - formatDate: (datetime, format) => { - return dayjs(datetime).format(format); - } - } -}); - -// Configure express to use handlebars -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' -})); - -// Load controllers -app.use(require('./controllers')); - -async function gracefulShutdown() { - jsonStore.shutdown(); - process.exit(); -} - -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); - jsonStore.shutdown(); - process.exit(1); -}); - -process.on('unhandledRejection', (reason) => { - console.error('Unhandled promise rejection, flushing data store before exit:', reason); - jsonStore.shutdown(); - process.exit(1); -}); - -async function startServer() { - await initializeDayjs(); - - jsonStore.initialize(config.dataFilePath); - - // Start cleanup scheduling - scheduleCleanupTasks(); - - // Generate stats on startup, then schedule periodic regeneration - 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; - - if (app.locals.host.indexOf(':') > -1) { - app.locals.host = '[' + app.locals.host + ']'; - } - - // Initialize WebSocket server for /wsLog - websocket.initialize(server); - - 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); - } - }); -} - -startServer().catch(console.error); 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 +``` diff --git a/client.js b/apps/client/client.js similarity index 61% rename from client.js rename to apps/client/client.js index 29a986b..d4662c9 100644 --- a/client.js +++ b/apps/client/client.js @@ -1,9 +1,13 @@ const bodyParser = require('body-parser'), - builder = require('xmlbuilder'), express = require('express'), morgan = require('morgan'), packageJson = require('./package.json'), - textParser = bodyParser.text({ type: '*/xml'}), + { + createRssCloudClient, + buildNotifyResponse, + renderCloudFeed + } = require('./lib'), + textParser = bodyParser.text({ type: '*/xml' }), urlencodedParser = bodyParser.urlencoded({ extended: false }); // Simple config utility @@ -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 ./lib; this file is just the UI. +const client = createRssCloudClient({ serverUrl: clientConfig.rsscloudServer }); + // In-memory data stores (reset on restart) const requestLog = []; const feedItems = {}; @@ -34,18 +42,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) => { @@ -80,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 @@ -95,105 +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 ''; @@ -207,15 +122,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 ` @@ -342,40 +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(` @@ -384,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

@@ -424,29 +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(` @@ -456,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

@@ -491,21 +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) @@ -518,20 +380,45 @@ 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); }); -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.`); + console.log( + `Error: Port ${clientConfig.port} is already in use.` + ); break; default: console.log(error.code); 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 new file mode 100644 index 0000000..eddc6c4 --- /dev/null +++ b/apps/client/package.json @@ -0,0 +1,30 @@ +{ + "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", + "test": "node --test lib/*.test.js", + "lint": "eslint --fix *.js lib/" + }, + "engines": { + "node": ">=22" + }, + "author": "Andrew Shell ", + "license": "MIT", + "dependencies": { + "@rsscloud/xml-rpc": "workspace:*", + "body-parser": "^2.2.2", + "express": "^4.22.2", + "morgan": "^1.10.1", + "xml2js": "^0.6.2" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.4.0", + "nodemon": "3.1.14" + } +} diff --git a/.mocharc.js b/apps/e2e/.mocharc.js similarity index 52% rename from .mocharc.js rename to apps/e2e/.mocharc.js index 4763bbc..bd21175 100644 --- a/.mocharc.js +++ b/apps/e2e/.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/Dockerfile b/apps/e2e/Dockerfile similarity index 61% rename from Dockerfile rename to apps/e2e/Dockerfile index beb147c..ee97f6e 100644 --- a/Dockerfile +++ b/apps/e2e/Dockerfile @@ -6,20 +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-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 70% rename from docker-compose.yml rename to apps/e2e/docker-compose.yml index 04cbfc7..7f715ba 100644 --- a/docker-compose.yml +++ b/apps/e2e/docker-compose.yml @@ -1,8 +1,9 @@ services: rsscloud: - build: . - command: node --use_strict app.js + build: + context: ../.. + dockerfile: apps/server/Dockerfile environment: DOMAIN: rsscloud PORT: 5337 @@ -12,18 +13,19 @@ services: - 5337 rsscloud-tests: - build: . - command: dockerize -wait http://rsscloud:5337 -timeout 10s bash -c "pnpm exec mocha" + 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 MOCK_SERVER_DOMAIN: rsscloud-tests MOCK_SERVER_PORT: 8002 SECURE_MOCK_SERVER_PORT: 8003 volumes: - - ./xunit:/app/xunit + - ./xunit:/app/apps/e2e/xunit expose: - 8002 - 8003 depends_on: - rsscloud - diff --git a/apps/e2e/package.json b/apps/e2e/package.json new file mode 100644 index 0000000..89b044e --- /dev/null +++ b/apps/e2e/package.json @@ -0,0 +1,30 @@ +{ + "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-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", + "xml2js": "^0.6.2", + "xmlbuilder": "^15.1.1" + } +} diff --git a/test/fixtures.js b/apps/e2e/test/fixtures.js similarity index 100% rename from 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/services/dayjs-wrapper.js b/apps/e2e/test/helpers/dayjs-wrapper.js similarity index 85% rename from services/dayjs-wrapper.js rename to apps/e2e/test/helpers/dayjs-wrapper.js index bf2e5ec..1c1be18 100644 --- a/services/dayjs-wrapper.js +++ b/apps/e2e/test/helpers/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/apps/e2e/test/helpers/init-subscription.js b/apps/e2e/test/helpers/init-subscription.js new file mode 100644 index 0000000..fd85de6 --- /dev/null +++ b/apps/e2e/test/helpers/init-subscription.js @@ -0,0 +1,48 @@ +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, + apiurl, + protocol +) { + const dayjs = await getDayjs(); + const now = dayjs().utc(); + const defaultSubscription = { + url: apiurl, + protocol, + ctUpdates: 0, + ctErrors: 0, + ctConsecutiveErrors: 0, + whenCreated: new Date(now.format()), + whenLastUpdate: null, + whenLastError: null, + whenExpires: new Date( + now.add(ctSecsResourceExpire, 'seconds').format() + ), + ...(typeof notifyProcedure === 'string' ? { notifyProcedure } : {}) + }, + index = subscriptions.findIndex(subscription => { + return subscription.url === apiurl; + }); + + if (-1 === index) { + subscriptions.push(defaultSubscription); + } else { + subscriptions[index] = Object.assign( + {}, + defaultSubscription, + subscriptions[index] + ); + } + + return subscriptions; +} + +module.exports = initSubscription; diff --git a/services/parse-rpc-request.js b/apps/e2e/test/helpers/parse-rpc-request.js similarity index 93% rename from services/parse-rpc-request.js rename to apps/e2e/test/helpers/parse-rpc-request.js index 504de92..8c6f3ab 100644 --- a/services/parse-rpc-request.js +++ b/apps/e2e/test/helpers/parse-rpc-request.js @@ -20,10 +20,15 @@ async function parseRpcParam(param, dayjs) { returnedValue = 'true' === value[tag] || !!Number(value[tag]); break; case 'dateTime.iso8601': - returnedValue = dayjs.utc(value[tag], ['YYYYMMDDTHHmmss', dayjs.ISO_8601]); + returnedValue = dayjs.utc(value[tag], [ + 'YYYYMMDDTHHmmss', + dayjs.ISO_8601 + ]); break; case 'base64': - returnedValue = Buffer.from(value[tag], 'base64').toString('utf8'); + returnedValue = Buffer.from(value[tag], 'base64').toString( + 'utf8' + ); break; case 'struct': member = value[tag].member || []; 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/test/keys/README.md b/apps/e2e/test/keys/README.md similarity index 100% rename from test/keys/README.md rename to apps/e2e/test/keys/README.md diff --git a/test/keys/server.cert b/apps/e2e/test/keys/server.cert similarity index 100% rename from test/keys/server.cert rename to apps/e2e/test/keys/server.cert diff --git a/test/keys/server.key b/apps/e2e/test/keys/server.key similarity index 100% rename from test/keys/server.key rename to apps/e2e/test/keys/server.key diff --git a/test/mock.js b/apps/e2e/test/mock.js similarity index 56% rename from test/mock.js rename to apps/e2e/test/mock.js index 1e1ab83..083806a 100644 --- a/test/mock.js +++ b/apps/e2e/test/mock.js @@ -2,16 +2,20 @@ 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'), + 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, - 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}`, - rpcReturnFault = require('../services/rpc-return-fault'); + SECURE_MOCK_SERVER_URL = + process.env.SECURE_MOCK_SERVER_URL || + `https://${MOCK_SERVER_DOMAIN}:${SECURE_MOCK_SERVER_PORT}`, + rpcReturnFault = require('./helpers/rpc-return-fault'); async function restController(req, res) { const method = req.method, @@ -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,14 +81,14 @@ 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) { this.requests[method][path] = []; @@ -106,11 +112,18 @@ 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() { if (this.server) { @@ -121,9 +134,9 @@ module.exports = { delete this.secureServer; this.routes = { - 'GET': {}, - 'POST': {}, - 'RPC2': {} + GET: {}, + POST: {}, + RPC2: {} }; } }, @@ -132,9 +145,9 @@ module.exports = { }, afterEach: async function() { this.requests = { - 'GET': {}, - 'POST': {}, - 'RPC2': {} + GET: {}, + POST: {}, + RPC2: {} }; } }; diff --git a/apps/e2e/test/ping.js b/apps/e2e/test/ping.js new file mode 100644 index 0000000..48ef193 --- /dev/null +++ b/apps/e2e/test/ping.js @@ -0,0 +1,884 @@ +const chai = require('chai'), + chaiHttp = require('chai-http'), + chaiXml = require('chai-xml'), + config = require('./helpers/config'), + expect = chai.expect, + 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('./helpers/rpc-return-success'), + rpcReturnFault = require('./helpers/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/e2e/test/please-notify.js similarity index 53% rename from test/please-notify.js rename to apps/e2e/test/please-notify.js index 111c2dc..05c5ebe 100644 --- a/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); @@ -38,14 +38,12 @@ 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() { await storeApi.before(); await mock.before(); @@ -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 @@ -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,20 +217,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.POST).property(pingPath).lengthOf(1, `Missing POST ${pingPath}`); + expect(mock.requests.POST) + .property(pingPath) + .lengthOf(1, `Missing POST ${pingPath}`); } }); @@ -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) { @@ -259,7 +350,6 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { } describe(`PleaseNotify ${pingProtocol} to ${protocol} via redirect returning ${returnFormat}`, function() { - before(async function() { await storeApi.before(); await mock.before(); @@ -287,10 +377,12 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { 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,14 +423,23 @@ 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() { @@ -342,9 +449,11 @@ for (const protocol of ['http-post', 'https-post', 'xml-rpc']) { 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/e2e/test/remove-expired-subscriptions.js similarity index 75% rename from test/remove-expired-subscriptions.js rename to apps/e2e/test/remove-expired-subscriptions.js index 995ccdf..6937384 100644 --- a/test/remove-expired-subscriptions.js +++ b/apps/e2e/test/remove-expired-subscriptions.js @@ -1,12 +1,11 @@ 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'); describe('RemoveExpiredSubscriptions', function() { - before(async function() { await storeApi.before(); await mock.before(); @@ -34,8 +33,16 @@ describe('RemoveExpiredSubscriptions', function() { 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(); @@ -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(); @@ -86,8 +103,16 @@ describe('RemoveExpiredSubscriptions', function() { 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, { @@ -118,7 +143,9 @@ describe('RemoveExpiredSubscriptions', function() { 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', @@ -135,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() { @@ -146,7 +173,12 @@ describe('RemoveExpiredSubscriptions', function() { 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', @@ -175,10 +207,20 @@ describe('RemoveExpiredSubscriptions', function() { 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, { @@ -191,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() { @@ -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(); @@ -232,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); @@ -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/test/static.js b/apps/e2e/test/static.js similarity index 60% rename from test/static.js rename to apps/e2e/test/static.js index 2b1fee0..a2f796c 100644 --- a/test/static.js +++ b/apps/e2e/test/static.js @@ -6,45 +6,33 @@ const chai = require('chai'), chai.use(chaiHttp); describe('Static Pages', function() { - it('docs should return 200', async function() { - let res = await chai - .request(SERVER_URL) - .get('/docs'); + 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('/'); + 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'); + 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'); + 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'); + let res = await chai.request(SERVER_URL).get('/viewLog'); expect(res).status(200); }); - }); diff --git a/test/store-api.js b/apps/e2e/test/store-api.js similarity index 61% rename from test/store-api.js rename to apps/e2e/test/store-api.js index ffbce5a..3686dc7 100644 --- a/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'; @@ -16,52 +16,75 @@ 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 }); +async function setSubscriptions(resourceUrl, subscriptions) { + await postJson('/test/setSubscriptions', { + feedUrl: resourceUrl, + subscriptions + }); } module.exports = { addResource: async function(resourceUrl, resourceObj) { - await postJson('/test/setResource', { feedUrl: resourceUrl, resource: resourceObj }); + await postJson('/test/setResource', { + feedUrl: resourceUrl, + resource: resourceObj + }); }, findResource: async function(resourceUrl) { - const { found, resource } = await postJson('/test/getResource', { feedUrl: 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 }); + 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 setSubscriptions(resourceUrl, subscriptions.pleaseNotify); + await initSubscription( + subscriptions, + notifyProcedure, + apiurl, + protocol + ); + 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/test/xmlrpc-builder.js b/apps/e2e/test/xmlrpc-builder.js similarity index 100% rename from test/xmlrpc-builder.js rename to apps/e2e/test/xmlrpc-builder.js 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/apps/server/Dockerfile b/apps/server/Dockerfile new file mode 100644 index 0000000..92429b9 --- /dev/null +++ b/apps/server/Dockerfile @@ -0,0 +1,36 @@ +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/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/express..." run build + +FROM base AS dependencies + +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/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 + +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/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/app.js b/apps/server/app.js new file mode 100644 index 0000000..1a6c109 --- /dev/null +++ b/apps/server/app.js @@ -0,0 +1,167 @@ +require('dotenv').config(); + +const config = require('./config'), + cors = require('cors'), + express = require('express'), + exphbs = require('express-handlebars'), + getDayjs = require('./services/dayjs-wrapper'), + { createStats } = require('./services/stats'), + morgan = require('morgan'), + websocket = require('./services/websocket'), + { core, events: coreEvents } = require('./core'), + { createControllers } = require('./controllers'), + bridgeCoreEvents = require('./services/core-event-bridge'); + +const stats = createStats({ core }); + +let app, hbs, server, dayjs; + +console.log(`${config.appName} ${config.appVersion}`); + +// Schedule cleanup tasks +function scheduleCleanupTasks() { + // Run cleanup immediately on startup + core.removeExpired() + .then(() => console.log('Startup subscription cleanup completed')) + .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 core.removeExpired(); + } 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, ':'); +}); + +// Initialize dayjs at startup +async function initializeDayjs() { + dayjs = await getDayjs(); +} + +app = express(); + +app.set('trust proxy', true); + +app.use( + morgan( + '[:mydate] :method :url :status :res[content-length] - :remote-addr - :response-time ms' + ) +); + +app.use(cors()); + +// Configure handlebars template engine to work with dayjs +hbs = exphbs.create({ + helpers: { + formatDate: (datetime, format) => { + return dayjs(datetime).format(format); + } + } +}); + +// Configure express to use handlebars +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' + }) +); + +// Load controllers (includes the core-backed /ping + /pleaseNotify front doors) +app.use(createControllers({ core })); + +async function gracefulShutdown() { + await core.close(); + process.exit(); +} + +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 + ); + core.close().finally(() => process.exit(1)); +}); + +process.on('unhandledRejection', reason => { + console.error( + 'Unhandled promise rejection, flushing data store before exit:', + reason + ); + core.close().finally(() => process.exit(1)); +}); + +async function startServer() { + await initializeDayjs(); + + // Start cleanup scheduling + scheduleCleanupTasks(); + + // Generate stats on startup, then schedule periodic regeneration + 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; + + if (app.locals.host.indexOf(':') > -1) { + app.locals.host = '[' + app.locals.host + ']'; + } + + // 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}` + ); + }) + .on('error', error => { + switch (error.code) { + case 'EADDRINUSE': + console.log( + `Error: Port ${config.port} is already in use.` + ); + break; + default: + console.log(error.code); + } + }); +} + +startServer().catch(console.error); 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/index.js b/apps/server/controllers/index.js new file mode 100644 index 0000000..d42b04b --- /dev/null +++ b/apps/server/controllers/index.js @@ -0,0 +1,116 @@ +const express = require('express'), + { 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'), + { createTestController } = require('./test'); + +// 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' } +]; + +// 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 { + res.render('docs', { + title: `rssCloud Server: ${label}`, + heading: `rssCloud Server: ${label}`, + htmltext: renderMarkdownDoc(file, { stripH1 }) + }); + } catch (err) { + console.error(`Error reading ${file}:`, err.message); + res.status(500).send('Internal Server Error'); + } +} + +// 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 }); + + // 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('/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 })); + } + + return router; +} + +module.exports = { createControllers }; diff --git a/apps/server/controllers/test.js b/apps/server/controllers/test.js new file mode 100644 index 0000000..2429af2 --- /dev/null +++ b/apps/server/controllers/test.js @@ -0,0 +1,110 @@ +const express = require('express'), + { + resourceToJson, + resourceFromJson, + subscriptionToJson, + subscriptionFromJson + } = require('@rsscloud/core'), + { toFeedsJson } = require('../services/feeds-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 } : {}) + }); +} + +// 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 +// seedResource/seedSubscriptions/clearFeeds. +function createTestController({ core }) { + const router = new express.Router(); + + console.warn( + '[test-api] ENABLE_TEST_API=true — /test/* endpoints are mounted. Never enable in production.' + ); + + router.use(express.json()); + + async function findEntry(feedUrl) { + return (await core.listFeeds()).find( + entry => entry.feedUrl === feedUrl + ); + } + + router.post('/clear', wrap(async() => { + await core.clearFeeds(); + })); + + router.post('/setResource', wrap(async(req) => { + const { feedUrl, resource } = req.body; + await core.seedResource(feedUrl, resourceFromInput(feedUrl, resource)); + })); + + 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', wrap(async(req) => { + const { feedUrl, subscriptions } = req.body; + await core.seedSubscriptions( + feedUrl, + subscriptions.map(subscriptionFromJson) + ); + })); + + 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', wrap(async() => ({ + data: toFeedsJson(await core.listFeeds()) + }))); + + router.post('/removeExpired', wrap(async() => ({ + result: await core.removeExpired() + }))); + + return router; +} + +module.exports = { createTestController }; diff --git a/controllers/view-log.js b/apps/server/controllers/view-log.js similarity index 100% rename from controllers/view-log.js rename to apps/server/controllers/view-log.js diff --git a/apps/server/core.js b/apps/server/core.js new file mode 100644 index 0000000..f6c4afb --- /dev/null +++ b/apps/server/core.js @@ -0,0 +1,46 @@ +// Composition root for @rsscloud/core. Builds the protocol-neutral engine the +// 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 coreConfig = resolveConfig({ + minSecsBetweenPings: config.minSecsBetweenPings, + ctSecsResourceExpire: config.ctSecsResourceExpire, + maxConsecutiveErrors: config.maxConsecutiveErrors, + maxResourceSize: config.maxResourceSize, + requestTimeoutMs: config.requestTimeout, + feedsChangedWindowDays: config.feedsChangedWindowDays +}); + +const plugins = [ + createRestProtocolPlugin({ requestTimeoutMs: config.requestTimeout }), + createXmlRpcProtocolPlugin({ requestTimeoutMs: config.requestTimeout }) +]; + +// 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. 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, + onMigrate: ({ from, to, feedCount }) => + console.log( + `[file-store] migrated ${feedCount} feed(s) from legacy file ${from}; writes now target ${to}` + ) + }), + plugins, + config: coreConfig +}); + +module.exports = { core, events: core.events }; diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..37a7b59 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,36 @@ +{ + "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", + "test": "node --test services/*.test.js", + "lint": "eslint --fix controllers/ services/ *.js" + }, + "engines": { + "node": ">=22" + }, + "author": "Andrew Shell ", + "license": "MIT", + "dependencies": { + "@rsscloud/core": "workspace:*", + "@rsscloud/express": "workspace:*", + "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", + "xmlbuilder": "^15.1.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.4.0", + "nodemon": "3.1.14", + "xml2js": "^0.6.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/core-event-bridge.js b/apps/server/services/core-event-bridge.js new file mode 100644 index 0000000..38190c0 --- /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. 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). + +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/dayjs-wrapper.js b/apps/server/services/dayjs-wrapper.js new file mode 100644 index 0000000..1c1be18 --- /dev/null +++ b/apps/server/services/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/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/feeds-opml.js b/apps/server/services/feeds-opml.js new file mode 100644 index 0000000..7f2d199 --- /dev/null +++ b/apps/server/services/feeds-opml.js @@ -0,0 +1,56 @@ +const builder = require('xmlbuilder'); +const config = require('../config'); +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 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.listFeeds(); + 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); + } + + return opml.end({ pretty: true }); + } + + return { generateOpml }; +} + +module.exports = { createFeedsOpml }; diff --git a/apps/server/services/feeds-opml.test.js b/apps/server/services/feeds-opml.test.js new file mode 100644 index 0000000..229a2b3 --- /dev/null +++ b/apps/server/services/feeds-opml.test.js @@ -0,0 +1,123 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const xml2js = require('xml2js'); +const { + createRssCloudCore, + createInMemoryStore, + resolveConfig +} = require('@rsscloud/core'); +const config = require('../config'); +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 }) }; +} + +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; +} + +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 parseOpml(xml) { + return new xml2js.Parser().parseStringPromise(xml); +} + +test('generateOpml renders a feed with full metadata as an outline', async() => { + const { core, generateOpml } = setup(); + await core.seedResource('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()); + + 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() => { + 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.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; + + 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() => { + const { core, generateOpml } = setup(); + await core.seedSubscriptions('https://new.example.com/feed.xml', [makeSubscription()]); + + 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' + }); +}); 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')); +}); diff --git a/apps/server/services/stats.js b/apps/server/services/stats.js new file mode 100644 index 0000000..83cfb02 --- /dev/null +++ b/apps/server/services/stats.js @@ -0,0 +1,87 @@ +const fs = require('fs'); +const path = require('path'); +const config = require('../config'); + +// 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() { + return config.statsFilePath; +} + +function getStats() { + const filePath = getStatsFilePath(); + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return { + generatedAt: null, + feedsChangedLastWindow: 0, + windowDays: config.feedsChangedWindowDays, + feedsWithSubscribers: 0, + uniqueAggregators: 0, + totalActiveSubscriptions: 0, + topFeeds: [], + moreFeeds: [], + protocolBreakdown: { 'http-post': 0, 'https-post': 0, 'xml-rpc': 0 } + }; + } +} + +// 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 toStatsView(coreStats) { + const protocolBreakdown = {}; + for (const protocol of KNOWN_PROTOCOLS) { + protocolBreakdown[protocol] = + coreStats.protocolBreakdown[protocol] ?? 0; + } + return { + generatedAt: coreStats.generatedAt, + feedsChangedLastWindow: coreStats.feedsChangedLastWindow, + windowDays: coreStats.windowDays, + feedsWithSubscribers: coreStats.feedsWithSubscribers, + uniqueAggregators: coreStats.uniqueAggregators, + totalActiveSubscriptions: coreStats.totalActiveSubscriptions, + topFeeds: coreStats.topFeeds, + moreFeeds: coreStats.moreFeeds, + protocolBreakdown + }; +} + +// 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 = toStatsView(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); + + console.log('Stats generated successfully'); + return stats; + } + + function scheduleStatsGeneration() { + setInterval(async() => { + try { + await generateStats(); + } catch (error) { + console.error('Error generating stats:', error); + } + }, config.statsIntervalMs); + } + + return { generateStats, getStats, scheduleStatsGeneration }; +} + +module.exports = { createStats }; diff --git a/apps/server/services/stats.test.js b/apps/server/services/stats.test.js new file mode 100644 index 0000000..0389996 --- /dev/null +++ b/apps/server/services/stats.test.js @@ -0,0 +1,183 @@ +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'); +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'); + +const config = require('../config'); +const { createStats } = require('./stats'); + +const DAY_MS = 24 * 60 * 60 * 1000; + +// 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 }) }; +} + +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; +} + +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 + }; +} + +const EMPTY_STATS = { + generatedAt: null, + feedsChangedLastWindow: 0, + windowDays: 7, + 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', () => { + const { getStats } = setup(); + assert.deepEqual(getStats(), EMPTY_STATS); +}); + +test('generateStats persists an empty snapshot getStats reads back', async() => { + 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 }, EMPTY_STATS); + 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 stats view', async() => { + 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 core.seedResource('https://a.example.com/feed.xml', makeResource('https://a.example.com/feed.xml', { + title: 'Alpha', + whenLastUpdate: recent + })); + 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.seedResource('https://b.example.com/feed.xml', makeResource('https://b.example.com/feed.xml', { + title: 'Bravo', + whenLastUpdate: recent + })); + await core.seedSubscriptions('https://b.example.com/feed.xml', [ + makeSubscription({ url: 'http://sub1.example.com/notify', whenExpires: future }) + ]); + + const generated = await generateStats(); + + 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. + 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: recent.toISOString(), + feedTitle: 'Alpha' + }, + { + url: 'https://b.example.com/feed.xml', + subscriberCount: 1, + whenLastUpdate: recent.toISOString(), + feedTitle: 'Bravo' + } + ]); + assert.deepEqual(generated.moreFeeds, []); +}); + +test('generateStats omits feeds whose subscriptions have all expired', async() => { + const { core, generateStats } = setup(); + const past = new Date(Date.now() - DAY_MS); + + 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.seedSubscriptions('https://stale.example.com/feed.xml', [ + makeSubscription({ url: 'http://gone.example.com/notify', whenExpires: past }) + ]); + + const generated = await 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 + }); +}); 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/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 97% rename from views/stats.handlebars rename to apps/server/views/stats.handlebars index 3bbc0e4..a1ee628 100644 --- a/views/stats.handlebars +++ b/apps/server/views/stats.handlebars @@ -12,8 +12,8 @@

Overview

- - + + 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/index.js b/controllers/index.js deleted file mode 100644 index c61d847..0000000 --- a/controllers/index.js +++ /dev/null @@ -1,91 +0,0 @@ -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'), - router = new express.Router(); - -router.use('/', require('./home')); -router.use('/docs', require('./docs')); - -router.get('/LICENSE.md', (req, res) => { - try { - const htmltext = md.render(fs.readFileSync('LICENSE.md', { encoding: 'utf8' })); - res.render('docs', { - title: 'rssCloud Server: License', - heading: 'rssCloud Server: License', - htmltext - }); - } catch (err) { - console.error('Error reading LICENSE.md:', err.message); - res.status(500).send('Internal Server Error'); - } -}); -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')); -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)); -}); - -router.get('/subscriptions.json', (req, res) => { - res.set('Content-Type', 'application/json'); - res.send(JSON.stringify(jsonStore.getData(), null, 2)); -}); - -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 })); - } catch (err) { - next(err); - } -}); - -if (process.env.ENABLE_TEST_API === 'true') { - router.use('/test', require('./test')); -} - -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/ping.js b/controllers/ping.js deleted file mode 100644 index 304d05e..0000000 --- a/controllers/ping.js +++ /dev/null @@ -1,47 +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/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/please-notify.js b/controllers/please-notify.js deleted file mode 100644 index fb9139f..0000000 --- a/controllers/please-notify.js +++ /dev/null @@ -1,53 +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; 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/controllers/stats.js b/controllers/stats.js deleted file mode 100644 index 4a879d9..0000000 --- a/controllers/stats.js +++ /dev/null @@ -1,10 +0,0 @@ -const express = require('express'), - { getStats } = require('../services/stats'), - router = new express.Router(); - -router.get('/', function(req, res) { - const stats = getStats(); - res.render('stats', stats); -}); - -module.exports = router; diff --git a/controllers/test.js b/controllers/test.js deleted file mode 100644 index c6739db..0000000 --- a/controllers/test.js +++ /dev/null @@ -1,78 +0,0 @@ -const express = require('express'), - jsonStore = require('../services/json-store'), - 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.'); - -router.use(express.json()); - -router.post('/clear', (req, res) => { - try { - jsonStore.clear(); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } -}); - -router.post('/setResource', (req, res) => { - try { - const { feedUrl, resource } = req.body; - jsonStore.setResource(feedUrl, resource); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } -}); - -router.post('/getResource', (req, res) => { - try { - const { feedUrl } = req.body; - const resource = jsonStore.getResource(feedUrl); - res.json({ success: true, found: resource !== null, resource }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } -}); - -router.post('/setSubscriptions', (req, res) => { - try { - const { feedUrl, pleaseNotify } = req.body; - jsonStore.setSubscriptions(feedUrl, pleaseNotify); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } -}); - -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 subscriptions = jsonStore.getSubscriptions(feedUrl); - res.json({ success: true, found, subscriptions }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } -}); - -router.post('/getData', (req, res) => { - try { - res.json({ success: true, data: jsonStore.getData() }); - } 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 }); - } -}); - -module.exports = router; 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/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..41e6fec 100644 --- a/package.json +++ b/package.json @@ -1,58 +1,42 @@ { - "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/client-app run dev", + "build": "turbo run build", + "lint": "turbo run lint", + "typecheck": "turbo run typecheck", + "format": "prettier --write .", + "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", + "prepare": "husky" + }, + "packageManager": "pnpm@10.11.0", + "pnpm": { + "overrides": { + "serialize-javascript": ">=7.0.5", + "qs": ">=6.15.2", + "vite": ">=6.4.2" + }, + "onlyBuiltDependencies": [ + "esbuild" + ] + }, + "repository": { + "type": "git", + "url": "https://github.com/rsscloud/rsscloud-server.git" + }, + "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" } - }, - "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/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..db78cfd --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,58 @@ +# @rsscloud/core + +Core primitives for [rssCloud](https://github.com/rsscloud/rsscloud-server) — subscriptions, notifications, and feed-update processing. + +> **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 + +```bash +pnpm add @rsscloud/core +``` + +## Usage + +Assemble the engine in your composition root from a `Store`, the protocol +plugins you want, and resolved config: + +```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/eslint.config.mjs b/packages/core/eslint.config.mjs new file mode 100644 index 0000000..c6a5ff4 --- /dev/null +++ b/packages/core/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/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..8230f72 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,74 @@ +{ + "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, + "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/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 new file mode 100644 index 0000000..acead1f --- /dev/null +++ b/packages/core/src/config.ts @@ -0,0 +1,44 @@ +/** + * 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; + +/** 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/engine/core.ts b/packages/core/src/engine/core.ts new file mode 100644 index 0000000..d89a149 --- /dev/null +++ b/packages/core/src/engine/core.ts @@ -0,0 +1,103 @@ +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/feed.js'; +import type { ProtocolPlugin } from './plugin.js'; +import type { MaintenanceResult, Stats } from './stats.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 + * 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 { + /** + * 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). */ + 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; + + /** + * 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. + */ + 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). + * 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. */ + generateStats(): Promise; +} + +/** Signature of the core factory the implementation step will provide. */ +export type CreateRssCloudCore = ( + options: RssCloudCoreOptions +) => RssCloudCore; diff --git a/packages/core/src/engine/create-core.test.ts b/packages/core/src/engine/create-core.test.ts new file mode 100644 index 0000000..25a60c8 --- /dev/null +++ b/packages/core/src/engine/create-core.test.ts @@ -0,0 +1,892 @@ +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 '../store/memory-store.js'; +import type { ProtocolPlugin } from './plugin.js'; +import type { Resource } from './resource.js'; +import type { Store } from '../store/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, + errorCode: '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]?.errorCode).toBe('RESOURCE_READ_FAILED'); + }); + + 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); + }); + + it('delegates removeExpired to the maintenance job', async () => { + const store = createInMemoryStore(); + await store.putSubscriptions(FEED, [ + 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 core.removeExpired(); + + expect(result.subscriptionsRemoved).toBe(1); + 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('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(); + }); +}); + +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 new file mode 100644 index 0000000..1cf995e --- /dev/null +++ b/packages/core/src/engine/create-core.ts @@ -0,0 +1,465 @@ +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 { fetchWithTimeout } from '../fetch-with-timeout.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 { Subscription } from './subscription.js'; +import type { FeedEntry, Store } from '../store/store.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'); +} + +/** 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 + * plugins and persistence as a {@link RssCloudCoreOptions.store}. + */ +export function createRssCloudCore( + options: RssCloudCoreOptions +): RssCloudCore { + 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()); + 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); + } + + 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( + doFetch, + config.requestTimeoutMs, + 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, + errorCode: 'RESOURCE_READ_FAILED' + }; + } + } + + const subscriptions = ( + await store.getSubscriptions(resourceUrl) + ).slice(); + const subscription = upsertSubscription(subscriptions, req); + + try { + await plugin.verify({ subscription, resourceUrl, diffDomain }); + } catch { + return { + resourceUrl, + success: false, + errorCode: '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.' }; + } + + async function close(): Promise { + const resolved = await storeReady; + if (isClosable(resolved)) { + await resolved.close(); + } + } + + 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, + listFeeds, + seedResource, + seedSubscriptions, + clearFeeds, + close, + removeExpired: () => runRemoveExpired(store, config, now), + generateStats: () => runGenerateStats(store, config, now) + }; +} diff --git a/packages/core/src/engine/dto.ts b/packages/core/src/engine/dto.ts new file mode 100644 index 0000000..c6ddaae --- /dev/null +++ b/packages/core/src/engine/dto.ts @@ -0,0 +1,72 @@ +import type { RssCloudErrorCode } from '../errors.js'; +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; + /** + * 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 { + 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/engine/maintenance.test.ts b/packages/core/src/engine/maintenance.test.ts new file mode 100644 index 0000000..86b4c6d --- /dev/null +++ b/packages/core/src/engine/maintenance.test.ts @@ -0,0 +1,306 @@ +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('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(); + + 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..fb8715d --- /dev/null +++ b/packages/core/src/engine/maintenance.ts @@ -0,0 +1,158 @@ +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, + windowDays: config.feedsChangedWindowDays, + feedsWithSubscribers: feedStats.length, + uniqueAggregators: hostnames.size, + totalActiveSubscriptions, + topFeeds, + moreFeeds, + protocolBreakdown + }; +} diff --git a/packages/core/src/engine/plugin.ts b/packages/core/src/engine/plugin.ts new file mode 100644 index 0000000..a6d495c --- /dev/null +++ b/packages/core/src/engine/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/engine/protocol.ts b/packages/core/src/engine/protocol.ts new file mode 100644 index 0000000..491bc64 --- /dev/null +++ b/packages/core/src/engine/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/engine/resource.ts b/packages/core/src/engine/resource.ts new file mode 100644 index 0000000..5718ecb --- /dev/null +++ b/packages/core/src/engine/resource.ts @@ -0,0 +1,26 @@ +import type { FeedMetadata } from '../feed/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/engine/stats.ts b/packages/core/src/engine/stats.ts new file mode 100644 index 0000000..4903095 --- /dev/null +++ b/packages/core/src/engine/stats.ts @@ -0,0 +1,35 @@ +/** 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; + /** The size (days) of the change window {@link feedsChangedLastWindow} counts. */ + windowDays: 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/engine/subscription.ts b/packages/core/src/engine/subscription.ts new file mode 100644 index 0000000..63c07f3 --- /dev/null +++ b/packages/core/src/engine/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; +} 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 new file mode 100644 index 0000000..0e11fee --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,23 @@ +/** 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'; + +/** + * 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 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 new file mode 100644 index 0000000..846dd6e --- /dev/null +++ b/packages/core/src/events.ts @@ -0,0 +1,97 @@ +import type { Protocol } from './engine/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; + +/** 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/feed-parser.test.ts b/packages/core/src/feed/feed-parser.test.ts new file mode 100644 index 0000000..5a0e18c --- /dev/null +++ b/packages/core/src/feed/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/feed-parser.ts b/packages/core/src/feed/feed-parser.ts new file mode 100644 index 0000000..ac2b2e6 --- /dev/null +++ b/packages/core/src/feed/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/feed/feed.ts b/packages/core/src/feed/feed.ts new file mode 100644 index 0000000..6e73e82 --- /dev/null +++ b/packages/core/src/feed/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/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/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..5962985 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,79 @@ +export const version = '0.0.0'; + +// Implementations +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 './protocols/rest-plugin.js'; +export { + createXmlRpcProtocolPlugin, + type XmlRpcProtocolPluginOptions +} from './protocols/xml-rpc-plugin.js'; +export { + createXmlRpcDispatcher, + type XmlRpcDispatcher, + 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 +} from './feed/feed-parser.js'; +export { createInMemoryStore } from './store/memory-store.js'; +export { + createFileStore, + 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'; +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, + SubscribeResponse, + UnsubscribeRequest, + UnsubscribeResponse, + PingRequest, + PingResponse +} from './engine/dto.js'; +export type { + ResourcePayload, + DeliveryResult, + VerifyContext, + DeliveryContext, + ProtocolPlugin +} 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 './engine/stats.js'; +export type { + RssCloudCoreOptions, + RssCloudCore, + CreateRssCloudCore +} from './engine/core.js'; 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 new file mode 100644 index 0000000..4ce0dd2 --- /dev/null +++ b/packages/core/src/protocols/rest-dispatcher.test.ts @@ -0,0 +1,462 @@ +import { Parser } from 'xml2js'; +import { describe, expect, it } from 'vitest'; +import type { + PingRequest, + PingResponse, + SubscribeRequest, + SubscribeResponse +} from '../engine/dto.js'; +import { RssCloudError } from '../errors.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('renders the ping read-failure wording when core reports the resource is unreadable', async () => { + const core = fakeCore({ + async ping() { + throw new RssCloudError( + 'RESOURCE_READ_FAILED', + '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 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' + }); + }); + + 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: '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([ + { + 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: '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([ + { + 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('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 subscription read-failure message 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, + errorCode: 'RESOURCE_READ_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 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.' + }); + }); + + 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('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() { + 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..fdd60a2 --- /dev/null +++ b/packages/core/src/protocols/rest-dispatcher.ts @@ -0,0 +1,225 @@ +import { Builder } from 'xml2js'; +import type { RssCloudCore } from '../engine/core.js'; +import type { PingRequest, SubscribeRequest } from '../engine/dto.js'; +import { RssCloudError } from '../errors.js'; +import { + appMessages, + errorMessage, + 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; + +/** 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 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 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(appMessages.error.subscription.missingParams('url')); + } + return { resourceUrl: String(body['url']) }; +} + +/** 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; +} + +/** + * Map the REST `pleaseNotify` body (`port`, `path`, `protocol`, any `url*`, + * 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, + 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( + appMessages.error.subscription.missingParams(missing.join(', ')) + ); + } + + const params: SubscribeParams = { + resourceUrls: parseUrlList(body), + port: String(body['port']), + path: String(body['path']), + protocol: String(body['protocol']), + clientAddress + }; + + if (body['domain'] !== undefined) { + params.domain = String(body['domain']); + } + if (body['notifyProcedure']) { + params.notifyProcedure = String(body['notifyProcedure']); + } + + return buildSubscribeRequest(params); +} + +/** 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 + ? appMessages.success.subscription + : subscriptionFailureMessage( + response.results, + response.message + ) + }; + } catch (err) { + result = { + success: false, + message: subscriptionRequestErrorMessage(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: pingFailureMessage(err, body) }; + } + return render(ctx.format, 'result', result); + } + + return { pleaseNotify, ping }; +} diff --git a/packages/core/src/protocols/rest-plugin.test.ts b/packages/core/src/protocols/rest-plugin.test.ts new file mode 100644 index 0000000..1b23e89 --- /dev/null +++ b/packages/core/src/protocols/rest-plugin.test.ts @@ -0,0 +1,349 @@ +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 { 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/protocols/rest-plugin.ts b/packages/core/src/protocols/rest-plugin.ts new file mode 100644 index 0000000..d825709 --- /dev/null +++ b/packages/core/src/protocols/rest-plugin.ts @@ -0,0 +1,117 @@ +import type { + DeliveryContext, + DeliveryResult, + ProtocolPlugin, + 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 { + /** 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; + } + + /** POST the notification, following redirects; throws on timeout or non-2xx. */ + async function sendNotify( + targetUrl: string, + body: URLSearchParams + ): Promise { + const res = await fetchWithTimeout(doFetch, requestTimeoutMs, 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(doFetch, requestTimeoutMs, 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/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 new file mode 100644 index 0000000..ced68ad --- /dev/null +++ b/packages/core/src/protocols/xml-rpc-dispatcher.test.ts @@ -0,0 +1,487 @@ +import { Builder, Parser } from 'xml2js'; +import { describe, expect, it } from 'vitest'; +import type { + PingRequest, + PingResponse, + SubscribeRequest, + SubscribeResponse +} from '../engine/dto.js'; +import { RssCloudError } from '../errors.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('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', [ + '', + '5337', + '/RPC2', + 'http-post', + 'http://feed.example/rss', + '' + ]), + { clientAddress: '203.0.113.5' } + ); + + 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 () => { + 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('faults with the subscription read-failure message when 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, + 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 }); + + 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 the call failed when we tested the handler.' + ); + }); + + 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 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() { + 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/protocols/xml-rpc-dispatcher.ts b/packages/core/src/protocols/xml-rpc-dispatcher.ts new file mode 100644 index 0000000..05cea8a --- /dev/null +++ b/packages/core/src/protocols/xml-rpc-dispatcher.ts @@ -0,0 +1,184 @@ +import type { RssCloudCore } from '../engine/core.js'; +import type { PingRequest, SubscribeRequest } from '../engine/dto.js'; +import { + appMessages, + errorMessage, + subscriptionFailureMessage, + subscriptionRequestErrorMessage +} from './app-messages.js'; +import { + buildSubscribeRequest, + type SubscribeParams +} from './subscribe-request.js'; +import { + 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 { + /** 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; + +/** + * Map `pleaseNotify` positional params + * (`notifyProcedure, port, path, protocol, urlList[, domain]`) into a + * `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[], + clientAddress: string +): SubscribeRequest { + if (params.length < 5) { + throw new Error(appMessages.error.rpc.notEnoughParams('pleaseNotify')); + } + if (params.length > 6) { + throw new Error(appMessages.error.rpc.tooManyParams('pleaseNotify')); + } + + const urlList = params[4]; + 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 (params[5] !== undefined) { + subscribeParams.domain = String(params[5]); + } + if (params[0]) { + subscribeParams.notifyProcedure = String(params[0]); + } + + return buildSubscribeRequest(subscribeParams); +} + +/** 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(appMessages.error.rpc.notEnoughParams('ping')); + } + if (params.length > 1) { + throw new Error(appMessages.error.rpc.tooManyParams('ping')); + } + + 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) + ); + if (!result.success) { + return serializeFault( + FAULT_CODE, + subscriptionFailureMessage(result.results, result.message) + ); + } + return serializeSuccess(true); + } catch (err) { + return serializeFault( + FAULT_CODE, + subscriptionRequestErrorMessage(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 }; +} diff --git a/packages/core/src/protocols/xml-rpc-plugin.test.ts b/packages/core/src/protocols/xml-rpc-plugin.test.ts new file mode 100644 index 0000000..ee53c78 --- /dev/null +++ b/packages/core/src/protocols/xml-rpc-plugin.test.ts @@ -0,0 +1,245 @@ +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 '@rsscloud/xml-rpc'; +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/protocols/xml-rpc-plugin.ts b/packages/core/src/protocols/xml-rpc-plugin.ts new file mode 100644 index 0000000..27d0737 --- /dev/null +++ b/packages/core/src/protocols/xml-rpc-plugin.ts @@ -0,0 +1,90 @@ +import type { + DeliveryContext, + DeliveryResult, + ProtocolPlugin, + VerifyContext +} from '../engine/plugin.js'; +import type { Protocol } from '../engine/protocol.js'; +import { fetchWithTimeout } from '../fetch-with-timeout.js'; +import { Builder } from 'xml2js'; + +/** 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; + +/** + * 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 + * 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; + + /** POST the notify methodCall; throws on timeout or non-2xx. */ + async function sendNotify( + targetUrl: string, + procedure: string, + resourceUrl: string + ): Promise { + const res = await fetchWithTimeout(doFetch, requestTimeoutMs, 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 }; +} 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..aa11d67 --- /dev/null +++ b/packages/core/src/store/file-store.test.ts @@ -0,0 +1,561 @@ +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 v2Path: string; +let v1Path: string; +let stores: FileStore[]; + +beforeEach(async () => { + vi.useFakeTimers(); + 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 () => { + 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'; +const SUBS_ONLY_FEED = 'https://subsonly.example/rss'; + +async function readV2(): Promise { + return JSON.parse(await readFile(v2Path, 'utf8')); +} + +function v2Exists(): Promise { + return readFile(v2Path, '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 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', + 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') + }; +} + +// ---- the legacy (pre-v2) on-disk shape used only on the import path ---- + +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 writeLegacyAt(path: string): Promise { + await writeFile(path, JSON.stringify(LEGACY_FILE, null, 2)); +} + +describe('createFileStore — v2 persistence', () => { + it('writes the v2 envelope to subscriptions.v2.json', async () => { + const store = await makeStore(); + + 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('persists a subscriptions-only feed with a null resource', async () => { + const store = await makeStore(); + + 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([ + { + feedUrl: NEW_FEED, + resource: coreResourceWithFeed(), + subscriptions: [coreSubscription()] + } + ]); + }); + + 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.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 () => { + const store = await makeStore(); + await store.putResource(NEW_FEED, coreResource()); + await store.remove(NEW_FEED); + + expect(await store.getResource(NEW_FEED)).toBeNull(); + expect(await store.getSubscriptions(NEW_FEED)).toEqual([]); + expect(await store.list()).toEqual([]); + }); + + it('starts empty when no file exists', async () => { + const store = await makeStore(); + expect(await store.list()).toEqual([]); + }); + + it('starts empty when the v2 file is corrupt and there is no legacy file', async () => { + await writeFile(v2Path, 'not json at all'); + + 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); + + 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' + } + }); + 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('imports the legacy .v1.json file when present', async () => { + await writeLegacyAt(v1Path); + + const store = await makeStore(); + + expect((await store.list())[0]?.feedUrl).toBe(LEGACY_FEED); + }); + + it('migrates legacy data to v2 on first write, leaving the legacy file intact', async () => { + await writeLegacyAt(filePath); + const legacyBefore = await readFile(filePath, 'utf8'); + + const store = await makeStore(); + await store.putResource(NEW_FEED, coreResource()); + await store.flush(); + + // 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('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: [] } + } + }) + ); + + const store = await makeStore(); + + expect((await store.list()).map(e => e.feedUrl)).toEqual([NEW_FEED]); + expect(await store.getResource(LEGACY_FEED)).toBeNull(); + }); + + it('calls onMigrate once after importing a legacy file', async () => { + await writeLegacyAt(filePath); + const onMigrate = vi.fn(); + + await makeStore({ onMigrate }); + + expect(onMigrate).toHaveBeenCalledTimes(1); + expect(onMigrate).toHaveBeenCalledWith({ + from: filePath, + to: v2Path, + feedCount: 1 + }); + }); + + it('does not call onMigrate when loading a v2 file', async () => { + await writeFile( + v2Path, + JSON.stringify({ version: 2, feeds: {} }) + ); + const onMigrate = vi.fn(); + + await makeStore({ onMigrate }); + + expect(onMigrate).not.toHaveBeenCalled(); + }); + + 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( + 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([]); + expect(nosub?.resource).not.toBeNull(); + }); + + 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.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 () => { + // 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 v2Exists()).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 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 new file mode 100644 index 0000000..0863e98 --- /dev/null +++ b/packages/core/src/store/file-store.ts @@ -0,0 +1,402 @@ +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'; +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 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. */ +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 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; + 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; + +/** Epoch (`new Date(0)`) marks "never happened" in the legacy file. */ +function readWhen(value: string | undefined): Date { + return new Date(value ?? 0); +} + +/** 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 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 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 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 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 feeds; +} + +// ---- 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(path, 'utf8')) as unknown; + } catch { + 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}. 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 debounceMs = options.debounceMs ?? 1000; + const maxWaitMs = options.maxWaitMs ?? 60000; + 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): Entry { + const existing = feeds.get(feedUrl); + if (existing !== undefined) return existing; + 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(paths.v2Path), { recursive: true }); + // Snapshot synchronously so an in-flight write can't tear. + const data = snapshot(); + const tmp = `${paths.v2Path}.tmp`; + await writeFile(tmp, data); + await rename(tmp, paths.v2Path); + } + + 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 feeds.get(feedUrl)?.resource ?? null; + }, + + async putResource(feedUrl: string, resource: Resource): Promise { + entryFor(feedUrl).resource = resource; + markDirty(); + }, + + async getSubscriptions(feedUrl: string): Promise { + return feeds.get(feedUrl)?.subscriptions ?? []; + }, + + async putSubscriptions( + feedUrl: string, + subscriptions: Subscription[] + ): Promise { + entryFor(feedUrl).subscriptions = subscriptions; + markDirty(); + }, + + async list(): Promise { + return Array.from(feeds, ([feedUrl, entry]) => ({ + feedUrl, + resource: entry.resource, + subscriptions: entry.subscriptions + })); + }, + + async remove(feedUrl: string): Promise { + feeds.delete(feedUrl); + markDirty(); + }, + + async flush(): Promise { + await doFlush(); + }, + + async close(): Promise { + clearFlushTimer(); + await doFlush(); + } + }; +} diff --git a/packages/core/src/store/memory-store.test.ts b/packages/core/src/store/memory-store.test.ts new file mode 100644 index 0000000..7a4fac6 --- /dev/null +++ b/packages/core/src/store/memory-store.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { createInMemoryStore } from './memory-store.js'; +import type { Resource } from '../engine/resource.js'; +import type { Subscription } from '../engine/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/store/memory-store.ts b/packages/core/src/store/memory-store.ts new file mode 100644 index 0000000..646c63d --- /dev/null +++ b/packages/core/src/store/memory-store.ts @@ -0,0 +1,60 @@ +import type { Resource } from '../engine/resource.js'; +import type { FeedEntry, Store } from './store.js'; +import type { Subscription } from '../engine/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/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; +} diff --git a/packages/core/src/store/store.ts b/packages/core/src/store/store.ts new file mode 100644 index 0000000..d243316 --- /dev/null +++ b/packages/core/src/store/store.ts @@ -0,0 +1,31 @@ +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 { + 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/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/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..3e8bb42 --- /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:*", + "express": "^4.22.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/express": "^4.17.21", + "@types/node": "^22.10.0", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^3.2.4", + "eslint": "^9.18.0", + "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/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..4e34cea --- /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 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. + +## 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/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 new file mode 100644 index 0000000..3e77f6a --- /dev/null +++ b/packages/xml-rpc/src/index.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import * as api from './index.js'; + +describe('@rsscloud/xml-rpc public API', () => { + 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 new file mode 100644 index 0000000..e248426 --- /dev/null +++ b/packages/xml-rpc/src/index.ts @@ -0,0 +1,13 @@ +export { parseMethodCall, type MethodCall } from './parse.js'; +export { + array, + bool, + buildFault, + buildMethodCall, + buildMethodResponse, + i4, + int, + str, + struct, + type XmlRpcValue +} from './build.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 }; +} 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 c80f648..3461db5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,14 +6,107 @@ settings: overrides: serialize-javascript: '>=7.0.5' + qs: '>=6.15.2' + vite: '>=6.4.2' 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 + '@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 + prettier: + specifier: ^3.8.3 + version: 3.8.3 + turbo: + specifier: ^2.9.14 + version: 2.9.14 + + apps/client: dependencies: + '@rsscloud/xml-rpc': + specifier: workspace:* + version: link:../../packages/xml-rpc + 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 + xml2js: + specifier: ^0.6.2 + version: 0.6.2 + 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': + 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-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) + xml2js: + specifier: ^0.6.2 + version: 0.6.2 + xmlbuilder: + specifier: ^15.1.1 + version: 15.1.1 + + apps/server: + dependencies: + '@rsscloud/core': + specifier: workspace:* + version: link:../../packages/core + '@rsscloud/express': + specifier: workspace:* + version: link:../../packages/express cors: specifier: ^2.8.6 version: 2.8.6 @@ -38,66 +131,168 @@ 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 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/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) - 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 + xml2js: + specifier: ^0.6.2 + version: 0.6.2 + + packages/core: + 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/express: + dependencies: + '@rsscloud/core': + specifier: workspace:* + version: link:../core + express: + specifier: ^4.22.2 + version: 4.22.2 + 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) supertest: - specifier: ^7.2.2 + 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/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': + 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@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@commitlint/cli@20.5.3': resolution: {integrity: sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==} engines: {node: '>=v18'} @@ -179,6 +374,171 @@ packages: conventional-commits-parser: optional: true + '@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==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + 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.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + 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.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + 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.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + 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.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + 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.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + 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.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + 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.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.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.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + 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.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} @@ -189,22 +549,59 @@ packages: 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} @@ -233,10 +630,36 @@ packages: 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==} + + '@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==} @@ -244,6 +667,223 @@ 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] + 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'} @@ -252,28 +892,215 @@ 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] + + '@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==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@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/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==} - accepts@1.3.8: + '@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==} + + '@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@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: '>=6.4.2' + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@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==} engines: {node: '>= 0.6'} @@ -309,6 +1136,9 @@ packages: 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'} @@ -328,6 +1158,13 @@ packages: assertion-error@1.1.0: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + assertion-error@2.0.1: + 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==} @@ -371,10 +1208,20 @@ packages: 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'} @@ -395,23 +1242,20 @@ 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'} + 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'} @@ -423,6 +1267,10 @@ packages: 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'} @@ -446,6 +1294,10 @@ packages: 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==} @@ -455,6 +1307,13 @@ packages: 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'} @@ -543,13 +1402,14 @@ 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'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -565,6 +1425,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==} @@ -619,6 +1483,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -630,6 +1497,11 @@ packages: es-toolkit@1.46.1: resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -641,6 +1513,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-scope@9.1.2: resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -649,6 +1525,10 @@ packages: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + 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} + eslint-visitor-keys@5.0.1: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -663,6 +1543,20 @@ packages: 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 + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + espree@11.2.0: resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -679,6 +1573,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -687,6 +1584,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + express-handlebars@5.3.5: resolution: {integrity: sha512-r9pzDc94ZNJ7FVvtsxLfPybmN0eFAUnR61oimNPRpD0D7nkLcezrkpZzoXS5TI75wYHRbflPLTU39B62pwB4DA==} engines: {node: '>=v10.24.1'} @@ -710,6 +1611,15 @@ packages: fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -726,6 +1636,9 @@ packages: 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'} @@ -812,6 +1725,10 @@ packages: resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} engines: {node: '>=20'} + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -848,13 +1765,13 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + 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'} @@ -875,6 +1792,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -959,6 +1880,22 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -966,9 +1903,19 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + joycon@3.1.1: + 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 @@ -988,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==} @@ -998,16 +1942,97 @@ 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'} + 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==} + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} @@ -1018,9 +2043,22 @@ packages: loupe@2.3.7: resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + markdown-it@14.1.1: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true @@ -1100,6 +2138,9 @@ packages: engines: {node: '>=10'} hasBin: true + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + mocha-multi@1.1.7: resolution: {integrity: sha512-SXZRgHy0XiRTASyOp0p6fjOkdj+R62L6cqutnYyQOvIjNznJuUwzykxctypeRiOwPd+gfn4yt3NRulMQyI8Tzg==} engines: {node: '>=6.0.0'} @@ -1121,6 +2162,14 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -1209,9 +2258,16 @@ packages: path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1219,6 +2275,39 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} + 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'} @@ -1243,8 +2332,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: @@ -1283,6 +2372,16 @@ 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'} + hasBin: true + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -1340,6 +2439,9 @@ packages: 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'} @@ -1348,14 +2450,28 @@ packages: 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'} @@ -1376,6 +2492,14 @@ 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'} + hasBin: true + superagent@10.3.0: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} @@ -1401,10 +2525,43 @@ packages: 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@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1417,16 +2574,49 @@ packages: 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==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + 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 + + 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'} - 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'} @@ -1439,6 +2629,13 @@ packages: 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'} @@ -1447,6 +2644,9 @@ packages: 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'} @@ -1455,8 +2655,8 @@ 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==} undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} @@ -1476,11 +2676,92 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + 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@8.0.14: + resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@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 + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + 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/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': + 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'} @@ -1552,14 +2833,32 @@ packages: 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@1.0.2': {} + '@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 @@ -1678,31 +2977,173 @@ snapshots: optionalDependencies: conventional-commits-parser: 6.4.0 - '@eslint-community/eslint-utils@4.9.1(eslint@10.4.0(jiti@2.6.1))': + '@emnapi/core@1.10.0': dependencies: - eslint: 10.4.0(jiti@2.6.1) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true - '@eslint/config-array@0.23.5': + '@emnapi/runtime@1.10.0': dependencies: - '@eslint/object-schema': 3.0.5 - debug: 4.4.3 - minimatch: 10.2.5 - transitivePeerDependencies: - - supports-color + tslib: 2.8.1 + optional: true - '@eslint/config-helpers@0.6.0': + '@emnapi/wasi-threads@1.2.1': dependencies: - '@eslint/core': 1.2.1 + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + 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 @@ -1733,39 +3174,442 @@ snapshots: 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 + + '@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': {} - '@paralleldrive/cuid2@2.3.1': + '@oxc-project/types@0.132.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@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 + + '@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': {} + + '@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 + + '@tybys/wasm-util@0.10.2': + dependencies: + 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': + dependencies: + '@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': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.8': {} + + '@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 + + '@types/node@25.8.0': + 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 + + '@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: - '@noble/hashes': 1.8.0 - - '@pkgjs/parseargs@0.11.0': - optional: true + '@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 - '@simple-libs/child-process-utils@1.0.2': + '@typescript-eslint/visitor-keys@8.59.4': dependencies: - '@simple-libs/stream-utils': 1.2.0 + '@typescript-eslint/types': 8.59.4 + eslint-visitor-keys: 5.0.1 - '@simple-libs/stream-utils@1.2.0': {} + '@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': 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 + 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: 2.0.0 + vitest: 3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1) + transitivePeerDependencies: + - supports-color - '@types/chai@4.3.20': {} + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 - '@types/cookiejar@2.1.5': {} + '@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': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.14(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1) - '@types/esrecurse@4.3.1': {} + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 - '@types/estree@1.0.9': {} + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 - '@types/json-schema@7.0.15': {} + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 - '@types/node@25.8.0': + '@vitest/spy@3.2.4': dependencies: - undici-types: 7.24.6 + tinyspy: 4.0.4 - '@types/superagent@4.1.13': + '@vitest/utils@3.2.4': dependencies: - '@types/cookiejar': 2.1.5 - '@types/node': 25.8.0 + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 accepts@1.3.8: dependencies: @@ -1802,6 +3646,8 @@ snapshots: ansi-styles@6.2.3: {} + any-promise@1.3.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -1817,6 +3663,14 @@ snapshots: assertion-error@1.1.0: {} + 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: {} @@ -1839,7 +3693,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 @@ -1854,7 +3708,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: @@ -1879,8 +3733,15 @@ snapshots: 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 @@ -1903,28 +3764,16 @@ 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 - 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 @@ -1935,6 +3784,14 @@ snapshots: 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 @@ -1946,6 +3803,8 @@ snapshots: dependencies: get-func-name: 2.0.2 + check-error@2.1.3: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -1978,6 +3837,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@4.1.1: {} + compare-func@2.0.0: dependencies: array-ify: 1.0.0 @@ -1987,6 +3848,10 @@ snapshots: concat-map@0.0.1: {} + confbox@0.1.8: {} + + consola@3.4.2: {} + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -2067,14 +3932,12 @@ 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 + deep-eql@5.0.2: {} + deep-is@0.1.4: {} delayed-stream@1.0.0: {} @@ -2083,6 +3946,8 @@ snapshots: destroy@1.2.0: {} + detect-libc@2.1.2: {} + dezalgo@1.0.4: dependencies: asap: 2.0.6 @@ -2124,6 +3989,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -2137,12 +4004,46 @@ snapshots: es-toolkit@1.46.1: {} + 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 @@ -2152,6 +4053,8 @@ snapshots: 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): @@ -2191,6 +4094,53 @@ snapshots: 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 @@ -2207,10 +4157,16 @@ snapshots: 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 @@ -2240,7 +4196,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 @@ -2263,6 +4219,10 @@ snapshots: 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 @@ -2288,6 +4248,12 @@ snapshots: 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 @@ -2315,7 +4281,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: @@ -2394,6 +4360,8 @@ snapshots: dependencies: ini: 6.0.0 + globals@14.0.0: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -2423,6 +4391,8 @@ snapshots: he@1.2.0: {} + html-escaper@2.0.2: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -2431,8 +4401,6 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - https@1.0.0: {} - husky@9.1.7: {} iconv-lite@0.4.24: @@ -2447,6 +4415,8 @@ snapshots: ignore@5.3.2: {} + ignore@7.0.5: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -2506,6 +4476,27 @@ snapshots: 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 @@ -2514,8 +4505,14 @@ snapshots: jiti@2.6.1: {} + 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 @@ -2530,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 @@ -2543,16 +4536,71 @@ 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: {} 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: @@ -2564,8 +4612,24 @@ snapshots: 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 @@ -2623,6 +4687,13 @@ snapshots: 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 @@ -2672,6 +4743,14 @@ snapshots: 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: {} @@ -2756,12 +4835,39 @@ snapshots: path-to-regexp@0.1.13: {} + pathe@2.0.3: {} + pathval@1.1.1: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} + picomatch@4.0.4: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + 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 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prelude-ls@1.2.1: {} prettier@3.8.3: {} @@ -2777,7 +4883,7 @@ snapshots: punycode@2.3.1: {} - qs@6.15.1: + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -2811,6 +4917,58 @@ 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 + 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: {} @@ -2886,16 +5044,26 @@ snapshots: 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 @@ -2918,6 +5086,20 @@ 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 + 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 @@ -2928,7 +5110,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 @@ -2942,7 +5124,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 @@ -2967,8 +5149,37 @@ snapshots: 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@2.0.0: {} + + tinyspy@4.0.4: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -2977,13 +5188,57 @@ snapshots: touch@3.1.1: {} - type-check@0.4.0: + tree-kill@1.2.2: {} + + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: - prelude-ls: 1.2.1 + typescript: 5.9.3 + + 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) + 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-detect@0.1.1: {} + 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-detect@1.0.0: {} + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 type-detect@4.1.0: {} @@ -2998,16 +5253,29 @@ snapshots: 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: {} @@ -3021,10 +5289,92 @@ snapshots: vary@1.1.2: {} + 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: 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 + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + 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@3.2.4(@types/node@22.19.19)(esbuild@0.27.7)(jiti@2.6.1): + dependencies: + '@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: 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: 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 + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + 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: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..1e13144 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - 'apps/*' + - 'packages/*' diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..b3a826e --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "plugins": ["node-workspace"], + "packages": { + "apps/server": { + "release-type": "node", + "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", + "changelog-path": "CHANGELOG.md", + "package-name": "@rsscloud/core" + }, + "packages/express": { + "release-type": "node", + "component": "express", + "changelog-path": "CHANGELOG.md", + "package-name": "@rsscloud/express" + } + } +} 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/error-response.js b/services/error-response.js deleted file mode 100644 index 5e48ee4..0000000 --- a/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/services/error-result.js b/services/error-result.js deleted file mode 100644 index 48587fa..0000000 --- a/services/error-result.js +++ /dev/null @@ -1,8 +0,0 @@ -function errorResult(err) { - return { - 'success': false, - 'msg': err - }; -} - -module.exports = errorResult; diff --git a/services/get-random-password.js b/services/get-random-password.js deleted file mode 100644 index 8c4c1c4..0000000 --- a/services/get-random-password.js +++ /dev/null @@ -1,11 +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/services/init-resource.js b/services/init-resource.js deleted file mode 100644 index 2a7f008..0000000 --- a/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/services/init-subscription.js b/services/init-subscription.js deleted file mode 100644 index 1783e36..0000000 --- a/services/init-subscription.js +++ /dev/null @@ -1,35 +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/services/json-store.js b/services/json-store.js deleted file mode 100644 index a4b9200..0000000 --- a/services/json-store.js +++ /dev/null @@ -1,111 +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 -}; diff --git a/services/log-event.js b/services/log-event.js deleted file mode 100644 index 2f44f5a..0000000 --- a/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/services/notify-one-challenge.js b/services/notify-one-challenge.js deleted file mode 100644 index f7bc04a..0000000 --- a/services/notify-one-challenge.js +++ /dev/null @@ -1,48 +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/services/notify-one.js b/services/notify-one.js deleted file mode 100644 index e838daf..0000000 --- a/services/notify-one.js +++ /dev/null @@ -1,98 +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/services/notify-subscribers.js b/services/notify-subscribers.js deleted file mode 100644 index ec5354a..0000000 --- a/services/notify-subscribers.js +++ /dev/null @@ -1,99 +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/services/parse-feed.js b/services/parse-feed.js deleted file mode 100644 index 5285774..0000000 --- a/services/parse-feed.js +++ /dev/null @@ -1,116 +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/services/parse-notify-params.js b/services/parse-notify-params.js deleted file mode 100644 index ab909ae..0000000 --- a/services/parse-notify-params.js +++ /dev/null @@ -1,144 +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/services/parse-ping-params.js b/services/parse-ping-params.js deleted file mode 100644 index 8e975e5..0000000 --- a/services/parse-ping-params.js +++ /dev/null @@ -1,34 +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/services/ping.js b/services/ping.js deleted file mode 100644 index fdd5c32..0000000 --- a/services/ping.js +++ /dev/null @@ -1,129 +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/services/please-notify.js b/services/please-notify.js deleted file mode 100644 index e29a2bf..0000000 --- a/services/please-notify.js +++ /dev/null @@ -1,91 +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; diff --git a/services/remove-expired-subscriptions.js b/services/remove-expired-subscriptions.js deleted file mode 100644 index 1ec9891..0000000 --- a/services/remove-expired-subscriptions.js +++ /dev/null @@ -1,118 +0,0 @@ -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; - } -} - -module.exports = removeExpiredSubscriptions; diff --git a/services/rest-return-success.js b/services/rest-return-success.js deleted file mode 100644 index f7b6b52..0000000 --- a/services/rest-return-success.js +++ /dev/null @@ -1,12 +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/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/services/stats.js b/services/stats.js deleted file mode 100644 index d02044a..0000000 --- a/services/stats.js +++ /dev/null @@ -1,124 +0,0 @@ -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'); - -function getStatsFilePath() { - return config.statsFilePath; -} - -function getStats() { - const filePath = getStatsFilePath(); - try { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); - } catch { - return { - generatedAt: null, - feedsChangedLast7Days: 0, - feedsWithSubscribers: 0, - uniqueAggregators: 0, - totalActiveSubscriptions: 0, - topFeeds: [], - moreFeeds: [], - protocolBreakdown: { 'http-post': 0, 'https-post': 0, 'xml-rpc': 0 } - }; - } -} - -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); - } - const moreFeeds = feedCounts.slice(topFeeds.length); - - const stats = { - generatedAt: dayjs().utc().format(), - feedsChangedLast7Days, - feedsWithSubscribers: feedCounts.length, - uniqueAggregators: hostnames.size, - totalActiveSubscriptions, - topFeeds, - moreFeeds, - protocolBreakdown - }; - - // 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; -} - -function scheduleStatsGeneration() { - setInterval(async() => { - try { - await generateStats(); - } catch (error) { - console.error('Error generating stats:', error); - } - }, config.statsIntervalMs); -} - -module.exports = { generateStats, getStats, scheduleStatsGeneration }; 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/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 + } + } +} 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 -

- -
Feeds changed in last 7 days{{feedsChangedLast7Days}}Feeds changed in last {{windowDays}} days{{feedsChangedLastWindow}}
Feeds with subscribers