From b97ed4e496660bac4230dffc26ad4a84487e8537 Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Fri, 28 Aug 2026 21:08:24 -0400 Subject: [PATCH 01/26] docs: add enhancement roadmap --- docs/enhancements/01-redact-invalid-tokens.md | 47 ++++++ .../enhancements/02-partial-authentication.md | 45 ++++++ .../enhancements/03-exact-status-exception.md | 48 ++++++ ...4-standard-unsupported-method-responses.md | 46 ++++++ docs/enhancements/05-forwarded-metadata.md | 46 ++++++ .../06-package-manager-audit-path.md | 52 +++++++ .../07-worker-router-lifecycle-leaks.md | 51 +++++++ .../08-numeric-token-configuration.md | 49 ++++++ docs/enhancements/09-rate-limit-refresh.md | 47 ++++++ ...0-state-aware-proxy-errors-cancellation.md | 48 ++++++ .../11-swagger-stats-monitoring.md | 49 ++++++ .../12-http-forwarding-semantics.md | 50 ++++++ .../13-body-queue-request-lifetime.md | 51 +++++++ .../14-event-driven-dispatcher.md | 51 +++++++ .../15-documentation-developer-experience.md | 51 +++++++ docs/enhancements/AGENT-ROADMAP.md | 142 ++++++++++++++++++ docs/enhancements/README.md | 46 ++++++ 17 files changed, 919 insertions(+) create mode 100644 docs/enhancements/01-redact-invalid-tokens.md create mode 100644 docs/enhancements/02-partial-authentication.md create mode 100644 docs/enhancements/03-exact-status-exception.md create mode 100644 docs/enhancements/04-standard-unsupported-method-responses.md create mode 100644 docs/enhancements/05-forwarded-metadata.md create mode 100644 docs/enhancements/06-package-manager-audit-path.md create mode 100644 docs/enhancements/07-worker-router-lifecycle-leaks.md create mode 100644 docs/enhancements/08-numeric-token-configuration.md create mode 100644 docs/enhancements/09-rate-limit-refresh.md create mode 100644 docs/enhancements/10-state-aware-proxy-errors-cancellation.md create mode 100644 docs/enhancements/11-swagger-stats-monitoring.md create mode 100644 docs/enhancements/12-http-forwarding-semantics.md create mode 100644 docs/enhancements/13-body-queue-request-lifetime.md create mode 100644 docs/enhancements/14-event-driven-dispatcher.md create mode 100644 docs/enhancements/15-documentation-developer-experience.md create mode 100644 docs/enhancements/AGENT-ROADMAP.md create mode 100644 docs/enhancements/README.md diff --git a/docs/enhancements/01-redact-invalid-tokens.md b/docs/enhancements/01-redact-invalid-tokens.md new file mode 100644 index 0000000..a21a5f8 --- /dev/null +++ b/docs/enhancements/01-redact-invalid-tokens.md @@ -0,0 +1,47 @@ +--- +id: 01 +title: Redact invalid tokens from errors +status: planned +risk: very-low +urgency: urgent +scope: error reporting and token validation +--- + +**Status:** Planned; not yet implemented. + +## Problem + +An invalid GitHub token is included in an emitted error message, allowing secret material to reach +logs and error listeners. + +## Evidence + +- The full token is interpolated in `src/router.ts:223-227`. +- The error is forwarded by `src/router.ts:399-401` and `src/server.ts:169-170`. +- CLI error handling exposes the event at `src/cli.ts:116-119`. +- A safer last-four-character logging pattern already exists at `src/router.ts:249-259`. + +## Expected benefit + +Invalid credentials can be diagnosed without exposing the token in logs, events, or CLI output. + +## Dependencies/decisions + +Use the existing last-four pattern or an equivalent fixed redaction. Decide whether tests should +assert that the complete token never occurs in emitted errors. + +## Implementation notes + +Replace the full-token interpolation with a redacted representation and preserve the invalid-token +signal and existing error propagation. Do not change token values used for authentication. + +## Validation plan + +Add a regression test for invalid-token error emission that checks the full token is absent and the +diagnostic redaction remains useful. Run the required project checks in the roadmap. + +## Definition of done + +- No invalid-token error contains the full token. +- Existing error forwarding and invalid-token behavior remain intact. +- Regression coverage and validation evidence are reported. diff --git a/docs/enhancements/02-partial-authentication.md b/docs/enhancements/02-partial-authentication.md new file mode 100644 index 0000000..2942ba2 --- /dev/null +++ b/docs/enhancements/02-partial-authentication.md @@ -0,0 +1,45 @@ +--- +id: 02 +title: Fail closed for partial authentication +status: planned +risk: very-low +urgency: urgent +scope: CLI authentication configuration +--- + +**Status:** Planned; not yet implemented. + +## Problem + +Authentication is only configured when both username and password are present, so a partial +configuration can silently leave the proxy unauthenticated. + +## Evidence + +- The conditional construction of the authentication object is at `src/cli.ts:105-111`. +- The server middleware consumes that optional object at `src/server.ts:122-125`. + +## Expected benefit + +Misconfigured deployments fail closed instead of unexpectedly exposing proxy endpoints. + +## Dependencies/decisions + +Define whether exactly one credential is a startup error and what message/exit behavior should be +used. Coordinate the decision with the status-exception item and deployment documentation. + +## Implementation notes + +Detect a username/password mismatch during startup, reject the configuration, and retain the +current successful path when both credentials are supplied. Do not log credential values. + +## Validation plan + +Add tests for username-only, password-only, neither, and both credentials. Verify the proxy does not +start for partial configuration and that valid protected requests still authenticate. + +## Definition of done + +- Partial authentication configuration is rejected before serving traffic. +- Complete authentication configuration behaves as intended. +- Tests and command evidence are reported. diff --git a/docs/enhancements/03-exact-status-exception.md b/docs/enhancements/03-exact-status-exception.md new file mode 100644 index 0000000..1db4f51 --- /dev/null +++ b/docs/enhancements/03-exact-status-exception.md @@ -0,0 +1,48 @@ +--- +id: 03 +title: Tighten the exact status exception +status: planned +risk: very-low +urgency: urgent +scope: status endpoint authentication and deployment guidance +--- + +**Status:** Planned; not yet implemented. + +## Problem + +The authentication bypass uses a broad path prefix, which can expose routes beyond the intended +status endpoint namespace. + +## Evidence + +- The broad `req.path.startsWith('/status')` bypass is at `src/server.ts:122-125`. +- The server binds to `0.0.0.0` at `src/cli.ts:121`. +- HTTP startup and usage examples appear in `README.md:84-104`. + +## Expected benefit + +Only the deliberately public status surface is exempted from authentication, reducing accidental +exposure when the service is reachable on a network interface. + +## Dependencies/decisions + +Decide whether the exception is exact `/status` only or the intended `/status/*` namespace. Warn +and document that HTTP requires TLS termination at a trusted boundary when credentials or traffic +cross an untrusted network. + +## Implementation notes + +Implement the selected route matcher rather than a general prefix check. Keep the status behavior +needed by health checks and update deployment examples to reflect the chosen boundary. + +## Validation plan + +Test `/status`, intended nested status paths, and lookalike paths such as `/status-other` under +authentication. Verify the Docker health check and documented HTTP deployment behavior. + +## Definition of done + +- The exact status exception is documented and enforced. +- Lookalike paths require authentication. +- Health behavior and TLS-boundary guidance are validated and reported. diff --git a/docs/enhancements/04-standard-unsupported-method-responses.md b/docs/enhancements/04-standard-unsupported-method-responses.md new file mode 100644 index 0000000..d5974f2 --- /dev/null +++ b/docs/enhancements/04-standard-unsupported-method-responses.md @@ -0,0 +1,46 @@ +--- +id: 04 +title: Correct standard unsupported-method responses +status: planned +risk: low +urgency: normal +scope: HTTP method routing and response status handling +--- + +**Status:** Planned; not yet implemented. + +## Problem + +Unsupported methods return a non-standard status code, making the proxy harder for clients and +intermediaries to interpret. + +## Evidence + +- `ProxyRouterResponse.PROXY_ERROR` is status 600 at `src/router.ts:317-319`. +- Unsupported method routing is defined at `src/server.ts:176-187`. + +## Expected benefit + +Clients receive a standard HTTP response for unsupported operations while intentional write-method +rejection remains explicit. + +## Dependencies/decisions + +Choose the appropriate standard 4xx status and response contract. Preserve the intentional rejection +of write methods rather than turning them into proxied writes. + +## Implementation notes + +Change only the unsupported-method response path and any associated response type/name. Keep GET and +GraphQL POST routing unchanged unless tests demonstrate a directly related defect. + +## Validation plan + +Add route tests for DELETE, PATCH, PUT, and unsupported POST paths, asserting the selected standard +status and message. Confirm supported GET and `/graphql` POST behavior remains unchanged. + +## Definition of done + +- Unsupported methods return the selected standard status. +- Intentional write-method rejection is preserved. +- Regression tests and validation evidence are reported. diff --git a/docs/enhancements/05-forwarded-metadata.md b/docs/enhancements/05-forwarded-metadata.md new file mode 100644 index 0000000..969d79f --- /dev/null +++ b/docs/enhancements/05-forwarded-metadata.md @@ -0,0 +1,46 @@ +--- +id: 05 +title: Correct forwarded metadata +status: planned +risk: low +urgency: normal +scope: proxy request headers and upstream metadata +--- + +**Status:** Planned; not yet implemented. + +## Problem + +Forwarded metadata is derived after the host header has been deleted, and protocol detection checks +the wrong request-socket property. Upstream requests therefore receive incorrect host/protocol data. + +## Evidence + +- Host deletion and forwarded-header ordering, including the protocol check, are at + `src/proxy-client.ts:56-66`. + +## Expected benefit + +GitHub and downstream consumers receive consistent client, host, and protocol metadata at the proxy +boundary. + +## Dependencies/decisions + +Decide the trusted source for forwarded headers, including whether an existing `x-forwarded-for` +value may be retained. Coordinate trust-proxy and external-base-url decisions with item 12. + +## Implementation notes + +Capture the inbound host before deleting it, use a correct protocol source, and define behavior for +proxy chains without trusting spoofable headers by default. + +## Validation plan + +Add proxy-client tests that inspect outgoing host/protocol/forwarded headers for representative HTTP +and HTTPS requests and for absent host data. Verify existing authorization/header behavior. + +## Definition of done + +- Forwarded host and protocol values are derived in the intended order. +- The selected trust policy is documented and tested. +- No unrelated proxy header behavior changes. diff --git a/docs/enhancements/06-package-manager-audit-path.md b/docs/enhancements/06-package-manager-audit-path.md new file mode 100644 index 0000000..236dd2c --- /dev/null +++ b/docs/enhancements/06-package-manager-audit-path.md @@ -0,0 +1,52 @@ +--- +id: 06 +title: Establish one reproducible package-manager and dependency-audit path +status: planned +risk: low/moderate +urgency: normal +scope: dependency manifests, lockfiles, CI, and container builds +--- + +**Status:** Planned; not yet implemented. + +## Problem + +Local, CI, and Docker dependency installation paths are inconsistent, making installs and advisory +triage less reproducible. + +## Evidence + +- The tracked Yarn lockfile is excluded by `.dockerignore:6`. +- CI uses Yarn at `.github/workflows/ci.yml:37`, `54`, and `71`. +- Docker forces npm in `.dockerignore`/`Dockerfile:4-8`, `23-25`. +- Direct dependencies are listed in `package.json:46-58`. +- `dotenv-override-true` and `https-proxy-agent` are likely unused; `ip` is used only for host + display (`package.json:49`, `src/cli.ts:9`, `127`). + +## Expected benefit + +Fresh installs, CI, containers, and security audits use the same dependency resolution and produce +actionable results. + +## Dependencies/decisions + +Choose Yarn or npm consistently, including the committed lockfile, CI cache/install commands, and +Docker installation. Triage reachable advisories rather than treating the audit count alone as a +removal plan. Confirm dependency usage before removing anything. + +## Implementation notes + +Align manifests, lockfile handling, workflow commands, and Docker context/install instructions with +the selected package manager. Review the named dependencies and record why each remains or is removed. + +## Validation plan + +Run a clean install with the selected manager, CI-equivalent lint/build/test commands, container +build checks, and the supported audit command. Record the Yarn advisory caveat and do not claim npm +audit support without an npm lockfile. + +## Definition of done + +- One package manager and lockfile are authoritative across local, CI, and Docker paths. +- Reachable production advisories are triaged with evidence. +- Dependency usage decisions and reproducible install/audit results are reported. diff --git a/docs/enhancements/07-worker-router-lifecycle-leaks.md b/docs/enhancements/07-worker-router-lifecycle-leaks.md new file mode 100644 index 0000000..b65e986 --- /dev/null +++ b/docs/enhancements/07-worker-router-lifecycle-leaks.md @@ -0,0 +1,51 @@ +--- +id: 07 +title: Fix worker and router lifecycle leaks +status: planned +risk: low/moderate +urgency: normal +scope: worker timers, queues, agents, listeners, and shutdown +--- + +**Status:** Planned; not yet implemented. + +## Problem + +Worker and router lifecycle paths can leave timers, queues, agents, or listeners alive, and router +destruction mutates the collection being traversed. + +## Evidence + +- Router destruction mutates `clients` during `forEach` at `src/router.ts:461-463`. +- Refresh intervals are created and discarded at `src/router.ts:404-406`. +- Worker cleanup is at `src/router.ts:289-297`. +- Each worker creates its own Agent at `src/router.ts:101-112`. +- The global listener limit is raised at `src/cli.ts:90`. +- CLI shutdown currently closes only the server at `src/cli.ts:152-161`. + +## Expected benefit + +Repeated setup/teardown, token changes, tests, and process shutdown release resources predictably +without masking listener growth. + +## Dependencies/decisions + +Define ownership for refresh timers, workers, Agents, and the router; decide whether an Agent is +shared or explicitly closed. Coordinate with rate-limit refresh and dispatcher changes. + +## Implementation notes + +Track every timer and resource that must be disposed, destroy workers before removing collection +entries or iterate over a stable snapshot, and make CLI shutdown destroy the router as well as the +HTTP server. Avoid using a global listener limit as lifecycle management. + +## Validation plan + +Add lifecycle tests for add/remove/destroy and repeated startup/shutdown, including timer cleanup +and queue cancellation. Check listener/resource behavior without relying on a raised global limit. + +## Definition of done + +- Router and worker teardown is idempotent and complete. +- Refresh timers, queues, Agents, and listeners have defined ownership and cleanup. +- Regression tests demonstrate no skipped clients or retained lifecycle resources. diff --git a/docs/enhancements/08-numeric-token-configuration.md b/docs/enhancements/08-numeric-token-configuration.md new file mode 100644 index 0000000..18ad830 --- /dev/null +++ b/docs/enhancements/08-numeric-token-configuration.md @@ -0,0 +1,49 @@ +--- +id: 08 +title: Validate numeric and token configuration at startup +status: planned +risk: moderate +urgency: normal +scope: CLI option parsing and credential validation +--- + +**Status:** Planned; not yet implemented. + +## Problem + +Numeric CLI values are parsed directly, while token validation applies a hard 40-character rule +that may not describe all supported GitHub credential formats. + +## Evidence + +- Number parsers are used at `src/cli.ts:30-35` and `src/cli.ts:47-57`. +- The multiplier parser is at `src/cli.ts:18-24`, with its option at `src/cli.ts:58-63`. +- Router options are applied at `src/router.ts:352-359`. +- The hard 40-character token rule is at `src/server.ts:80-84` and is also described in + `AGENTS.md:183-186`. + +## Expected benefit + +Invalid, non-finite, negative, or unsafe operational settings fail at startup, while valid GitHub +credential formats are accepted deliberately. + +## Dependencies/decisions + +Define supported GitHub credential formats and bounded validation for opaque tokens. Set valid +ranges and defaults for port, timeout, minimum remaining requests, and multiplier. + +## Implementation notes + +Introduce explicit parsers/validators with clear option-specific errors. Keep secrets out of error +messages and preserve the selected credential-format policy in operator documentation. + +## Validation plan + +Test invalid and boundary values for every numeric option, supported token formats, duplicates, and +startup failure behavior. Run normal startup tests with default values. + +## Definition of done + +- All numeric configuration has finite, bounded validation. +- Supported token formats are explicitly defined and validated. +- Startup errors are safe, clear, tested, and reported. diff --git a/docs/enhancements/09-rate-limit-refresh.md b/docs/enhancements/09-rate-limit-refresh.md new file mode 100644 index 0000000..cb16bdc --- /dev/null +++ b/docs/enhancements/09-rate-limit-refresh.md @@ -0,0 +1,47 @@ +--- +id: 09 +title: Harden rate-limit refresh against outages and malformed responses +status: planned +risk: moderate +urgency: high +scope: rate-limit fetching, parsing, refresh scheduling, and token workers +--- + +**Status:** Planned; not yet implemented. + +## Problem + +Rate-limit refresh fetches and parses remote data without a resilient failure policy. Initial and +interval refresh promises are not handled, and each token creates four refresh streams. + +## Evidence + +- Fetch and response parsing are at `src/router.ts:216-235`. +- Initial and interval refresh handling is at `src/router.ts:399-406`. +- Four workers per token, each with refresh behavior, are created at `src/router.ts:394-409`. + +## Expected benefit + +GitHub outages or malformed responses do not produce unhandled failures or unsafe scheduling, and +refresh traffic is reduced without losing resource-specific state. + +## Dependencies/decisions + +Define stale-state behavior, retry/backoff limits, malformed-response handling, and whether one +`/rate_limit` response should fan out to all resource workers for a token. + +## Implementation notes + +Handle fetch, HTTP, JSON, and resource-shape failures explicitly. Centralize or coordinate refresh +per token, preserve safe stale values, and emit actionable redacted diagnostics. + +## Validation plan + +Test network failure, non-success responses, malformed JSON/resource data, backoff, stale state, and +successful refresh. Verify refresh fan-out and that no promise rejection is unhandled. + +## Definition of done + +- Refresh failures are contained, observable, and bounded by the selected retry policy. +- Valid responses update all required resource state from the intended refresh path. +- Tests cover outages and malformed responses with reported evidence. diff --git a/docs/enhancements/10-state-aware-proxy-errors-cancellation.md b/docs/enhancements/10-state-aware-proxy-errors-cancellation.md new file mode 100644 index 0000000..888dca9 --- /dev/null +++ b/docs/enhancements/10-state-aware-proxy-errors-cancellation.md @@ -0,0 +1,48 @@ +--- +id: 10 +title: Make proxy errors and cancellation state-aware +status: planned +risk: moderate +urgency: high +scope: proxy error responses, sockets, abort signals, and cancellation +--- + +**Status:** Planned; not yet implemented. + +## Problem + +The error path may send a response and then destroy it, checks the wrong request socket state, and +uses an abort controller that is never assigned to the request context. + +## Evidence + +- Send-then-destroy behavior is at `src/router.ts:197-211`. +- The request context declares an optional controller at `src/router.ts:18-21`, but the proxy call + does not assign one before `src/router.ts:209`. +- Existing error and cancellation tests are at `src/router.spec.ts:101-113` and `src/router.spec.ts:172-185`. + +## Expected benefit + +Disconnected clients and upstream failures do not trigger duplicate writes, noisy socket errors, or +ineffective cancellation. + +## Dependencies/decisions + +Coordinate request-body and stream timeout behavior with item 13. Define which side owns abort +controllers and which response/socket states permit an error response. + +## Implementation notes + +Make cancellation state explicit, attach the active controller to the request context, guard writes +with the correct response/request state, and avoid destroying a response after a completed send. + +## Validation plan + +Extend tests for timeout, upstream connection failure, client disconnect, completed response, and +partial response cases. Confirm no duplicate response writes and that in-flight work is cancelled. + +## Definition of done + +- Error handling is conditional on accurate request/response state. +- Cancellation reaches the active upstream operation. +- Existing and new timeout/disconnect regression tests pass. diff --git a/docs/enhancements/11-swagger-stats-monitoring.md b/docs/enhancements/11-swagger-stats-monitoring.md new file mode 100644 index 0000000..c37076b --- /dev/null +++ b/docs/enhancements/11-swagger-stats-monitoring.md @@ -0,0 +1,49 @@ +--- +id: 11 +title: Replace or isolate public swagger-stats monitoring +status: planned +risk: moderate +urgency: normal +scope: monitoring middleware, public status surface, and health checks +--- + +**Status:** Planned; not yet implemented. + +## Problem + +The optional swagger-stats middleware exposes a monitoring URI that is also excluded from basic +authentication, creating a public observability surface whose necessity and boundary are unclear. + +## Evidence + +- The dependency is declared at `package.json:56`. +- Middleware and public URI configuration are at `src/server.ts:154-161`. +- The authentication bypass is at `src/server.ts:122-125`. +- README documents public monitoring at `README.md:104`. +- Docker health checking uses the status path at `Dockerfile:33-34`. + +## Expected benefit + +Health checks remain reliable while operational metrics are either removed, protected, or isolated +according to an explicit exposure policy. + +## Dependencies/decisions + +Decide between health-only status and a separately protected metrics surface. Preserve the Docker +health behavior and determine whether swagger-stats remains an approved dependency. + +## Implementation notes + +Separate liveness/readiness behavior from detailed monitoring if needed, restrict monitoring access +to the intended trust boundary, and update dependency and README guidance consistently. + +## Validation plan + +Test enabled and disabled monitoring, authenticated and unauthenticated status/metrics access, and +the Docker health check. Verify the selected monitoring contract without exposing request secrets. + +## Definition of done + +- Monitoring exposure and authentication policy are explicit. +- Health checks continue to work. +- Dependency, route, documentation, and regression evidence support the selected design. diff --git a/docs/enhancements/12-http-forwarding-semantics.md b/docs/enhancements/12-http-forwarding-semantics.md new file mode 100644 index 0000000..0cf56cb --- /dev/null +++ b/docs/enhancements/12-http-forwarding-semantics.md @@ -0,0 +1,50 @@ +--- +id: 12 +title: Correct HTTP forwarding semantics at the proxy boundary +status: planned +risk: moderate/high +urgency: high +scope: request/response headers, hop-by-hop semantics, links, and proxy trust +--- + +**Status:** Planned; not yet implemented. + +## Problem + +The proxy copies headers without hop-by-hop filtering, flattens upstream response headers, and +rewrites links to hard-coded HTTP while trusting the inbound Host value. + +## Evidence + +- Request header copying is at `src/proxy-client.ts:48-60`. +- Response header flattening is at `src/proxy-client.ts:86-113`. +- No hop-by-hop filtering is present in those forwarding paths. +- Link rewriting hard-codes HTTP and uses inbound Host at `src/router.ts:147-153`. + +## Expected benefit + +Requests and responses follow HTTP proxy semantics, preserve meaningful metadata, and generate links +that match the externally visible deployment URL. + +## Dependencies/decisions + +Define the hop-by-hop header policy, trusted proxy behavior, and external base URL configuration. +Coordinate forwarded metadata changes with item 05 and timeout/stream work with item 13. + +## Implementation notes + +Filter connection-specific headers on both directions, preserve valid multi-value semantics, and +derive link rewriting from an explicit trusted external scheme/host rather than an untrusted inbound +value. + +## Validation plan + +Add integration tests for hop-by-hop headers, multi-value response headers, forwarded requests, and +HTTP/HTTPS external URL combinations. Verify redirects and Link headers under the selected trust +configuration. + +## Definition of done + +- Header forwarding follows the documented HTTP semantics. +- Link rewriting uses the selected trusted external URL policy. +- Integration/regression tests cover the proxy boundary and evidence is reported. diff --git a/docs/enhancements/13-body-queue-request-lifetime.md b/docs/enhancements/13-body-queue-request-lifetime.md new file mode 100644 index 0000000..8ea0035 --- /dev/null +++ b/docs/enhancements/13-body-queue-request-lifetime.md @@ -0,0 +1,51 @@ +--- +id: 13 +title: Bound request bodies, queue residency, and end-to-end request lifetime +status: planned +risk: high +urgency: high +scope: request bodies, queues, overload handling, and timeout budgets +--- + +**Status:** Planned; not yet implemented. + +## Problem + +Request bodies are fully buffered, proxy body reads are outside the existing fetch timeout window, +and queues have no depth or end-to-end request-lifetime bound. + +## Evidence + +- Body buffering is implemented at `src/proxy-client.ts:68-72` and `src/proxy-client.ts:149-158`. +- The timeout setup and fetch boundary are at `src/proxy-client.ts:41-42` and `src/proxy-client.ts:74-85`. +- Queues are unbounded at `src/router.ts:301-315`. +- Requests are enqueued at `src/router.ts:371-385`. + +## Expected benefit + +Memory use, queue latency, and work held for disconnected clients become bounded, and overload is +reported predictably instead of accumulating indefinitely. + +## Dependencies/decisions + +Define maximum body size, queue depth, queue-wait timeout, total request lifetime, and overload +status/response. Coordinate cancellation with item 10 and defer dispatcher changes until these +queue boundaries are explicit. + +## Implementation notes + +Enforce limits before unbounded buffering, account for body-read and queue-wait time in the request +budget, remove abandoned work, and select an explicit overload response. Preserve supported request +semantics while avoiding broad streaming rewrites without tests. + +## Validation plan + +Test body-size boundaries, slow uploads, queue saturation, queue expiry, client disconnects, and +end-to-end timeout behavior. Measure that rejected overload does not grow queue residency without +bound. + +## Definition of done + +- Body, queue, wait, and total-lifetime limits are configured and documented. +- Overload and timeout responses are deterministic. +- Regression tests cover memory-sensitive and cancellation-sensitive paths. diff --git a/docs/enhancements/14-event-driven-dispatcher.md b/docs/enhancements/14-event-driven-dispatcher.md new file mode 100644 index 0000000..78581a0 --- /dev/null +++ b/docs/enhancements/14-event-driven-dispatcher.md @@ -0,0 +1,51 @@ +--- +id: 14 +title: Replace per-worker polling with an event-driven bounded dispatcher +status: planned +risk: highest +urgency: normal +scope: scheduling architecture, queue notification, fairness, and bounded dispatch +--- + +**Status:** Planned; not yet implemented. + +## Problem + +Workers poll every 100ms, enqueueing does not notify workers, queue removal is O(n), and each token +creates four workers. This makes dispatch latency, fairness, and resource behavior harder to bound. + +## Evidence + +- Polling is implemented at `src/router.ts:276-287`. +- Queue enqueueing has no notification at `src/router.ts:301-306`. +- Dequeue uses O(n) `shift()` at `src/router.ts:308-310`. +- Four workers per token are created at `src/router.ts:394-409`. + +## Expected benefit + +Dispatch reacts immediately to available work, has explicit fairness and capacity behavior, and +avoids unnecessary polling and queue scans. + +## Dependencies/decisions + +Defer this item until lifecycle cleanup, rate-limit refresh, cancellation, and queue-boundary work +has landed. Define fairness, per-token/resource capacity, notification ownership, and overload +behavior before changing the scheduling architecture. + +## Implementation notes + +Replace polling with explicit queue/worker notifications and a bounded dispatcher. Preserve resource +routing, rate-limit constraints, cancellation, and intentional retry behavior. Avoid changing all +dispatch semantics in one untested rewrite. + +## Validation plan + +Benchmark and test dispatch latency, fairness across tokens/resources, capacity limits, retries, +shutdown, cancellation, and queue saturation. Compare behavior against the bounded queue and +request-lifetime contract from item 13. + +## Definition of done + +- Polling is removed or isolated behind a documented compatibility fallback. +- Dispatcher capacity, fairness, notification, and overload semantics are tested. +- Performance and regression evidence demonstrate no loss of supported proxy behavior. diff --git a/docs/enhancements/15-documentation-developer-experience.md b/docs/enhancements/15-documentation-developer-experience.md new file mode 100644 index 0000000..4d5754e --- /dev/null +++ b/docs/enhancements/15-documentation-developer-experience.md @@ -0,0 +1,51 @@ +--- +id: 15 +title: Documentation and developer-experience cleanup +status: planned +risk: low +urgency: optional +scope: README, CLI help, package-manager guidance, hooks, and Biome configuration +--- + +**Status:** Planned; not yet implemented. **Priority:** Optional cleanup. + +## Problem + +Project documentation and developer tooling contain small consistency and clarity gaps that can +mislead contributors or operators, but do not require product behavior changes. + +## Evidence + +- The README badge is at `README.md:3`. +- Package-manager commands are at `README.md:49-55`. +- CLI help text includes the relevant wording at `src/cli.ts:64` and `src/cli.ts:68`. +- README wording appears at `README.md:118-120`. +- The pre-commit hook is at `.husky/pre-commit:1-2`. +- Limited Biome rules are configured at `biome.json:25-33`. + +## Expected benefit + +Contributors get accurate commands, clearer help, and consistent automated feedback with less setup +friction. + +## Dependencies/decisions + +Apply this cleanup after the package-manager decision in item 06 and any CI policy decisions in the +earlier items. Keep scope limited to documentation and developer-experience consistency. + +## Implementation notes + +Refresh stale badges and package commands, correct CLI/README wording, review hook behavior, and +document only Biome rules that the project intentionally supports. Do not use this item to hide +failures or perform a broad formatting rewrite. + +## Validation plan + +Verify every documented command against the selected package manager, inspect CLI help output, and +run the existing hook/lint checks after any configuration change. + +## Definition of done + +- README, CLI help, hooks, and Biome guidance are internally consistent. +- Documented commands are reproducible. +- Changes remain limited to optional cleanup and evidence is reported. diff --git a/docs/enhancements/AGENT-ROADMAP.md b/docs/enhancements/AGENT-ROADMAP.md new file mode 100644 index 0000000..ecc09aa --- /dev/null +++ b/docs/enhancements/AGENT-ROADMAP.md @@ -0,0 +1,142 @@ +# Agent roadmap + +## Mission and scope + +This roadmap guides agents implementing the enhancement dossier in small, reviewable increments. +The mission is to improve security, correctness, operability, and developer experience without +changing unrelated behavior. The dossier itself is documentation-only; its recommendations are not +implemented by creating these files. + +The numbered order in [README.md](./README.md) is the authoritative risk order. Dependencies can +make a later item wait for an earlier item even when the later item has a smaller isolated change. + +## Status vocabulary + +Use these statuses in recommendation frontmatter: + +- `planned`: documented, not started, and not implemented. +- `in-progress`: an assigned implementation is actively being changed. +- `blocked`: work cannot proceed until a named dependency or decision is resolved. +- `implemented`: code and tests are complete, but the review gate is not yet closed. +- `verified`: review and required validation are complete, with evidence recorded. +- `deferred`: intentionally postponed with a reason and owner/decision recorded. + +Every agent must update the relevant recommendation status as work changes. Do not mark an item +`implemented` or `verified` based only on documentation edits. + +## Ordered phases and dependencies + +1. **Baseline and security containment (01-03).** Redact secrets, reject partial authentication, + and decide/enforce the exact status namespace. These are urgent and should precede public + deployment changes. +2. **Contract corrections (04-06).** Standardize unsupported-method responses, forwarded metadata, + and the package-manager/audit path. Item 05 should coordinate its trust decision with item 12; + item 06 precedes optional documentation cleanup. +3. **Resource safety and configuration (07-10).** Fix lifecycle ownership, validate configuration, + harden rate-limit refresh, and make errors/cancellation state-aware. Items 07 and 09 should land + before architectural dispatch work; item 10 coordinates with request lifetime limits. +4. **Boundary and capacity work (11-13).** Decide monitoring exposure, correct HTTP forwarding, + and bound bodies, queues, and request lifetime. Item 13 establishes limits needed by the + dispatcher. +5. **Architecture (14).** Replace polling with an event-driven bounded dispatcher only after the + lifecycle, refresh, cancellation, and queue-boundary contracts are stable. +6. **Optional cleanup (15).** Refresh documentation and developer experience after package-manager + and CI decisions settle. It may be scheduled independently when it does not conflict with an + active lane. + +## Suggested roles and validation ownership + +- **Explorer:** maps the exact implementation surface and existing tests; does not edit source. +- **Oracle:** resolves behavior, security, compatibility, and deployment decisions; records the + rationale before implementation. +- **Fixer:** makes the smallest scoped code change and adds regression tests. +- **Librarian:** updates the relevant recommendation, README, changelog-style evidence, and status. +- **Designer:** owns layout, styling, visual hierarchy, responsive behavior, and animation when a + user-facing design decision is required; do not assign those decisions to a code fixer. +- **Observer:** runs the assigned validation, watches regressions/resource behavior, and records + command output or other evidence. + +The orchestrator owns validation for this dossier and decides which checks are assigned for each +implementation. Agents must not silently broaden validation scope. The implementing agent reports +what was run and what was skipped; the observer/orchestrator records the authoritative result. + +## Lane and write-scope rules + +- One active implementation lane owns a recommendation and its directly related tests at a time. +- A lane may write only the source, tests, configuration, and documentation explicitly named by its + recommendation. Ask the orchestrator before crossing lanes. +- The librarian may update the recommendation status and evidence, but must not rewrite unrelated + recommendations. +- Do not combine security, dependency, dispatcher, or broad formatting rewrites in one change. +- Avoid broad rewrites and opportunistic refactors. Preserve unrelated behavior and existing APIs + unless the recommendation explicitly requires a contract decision. +- Before editing, check for another active lane's files and coordinate overlapping paths, especially + `src/router.ts`, `src/server.ts`, `src/cli.ts`, `README.md`, and CI/package files. + +## Per-recommendation workflow + +1. Read the relevant recommendation file, this roadmap, and the exact repository paths cited there. +2. Confirm the status is `planned`, identify dependencies, and obtain unresolved decisions from the + orchestrator/oracle. +3. Set the item to `in-progress` and record the implementation lane and scope. +4. Make a focused change; avoid broad rewrites. +5. Add regression tests for the changed behavior, including security and boundary cases where + applicable. Do not claim an item is done without tests unless the recommendation explicitly has + no runtime behavior. +6. Run the validation assigned by the orchestrator and preserve command/result evidence. +7. Have the observer/orchestrator review the diff and gates. Set `implemented`, then `verified` + only after the required review and validation are complete; otherwise record `blocked` or + `deferred` with the reason. +8. Report changed files, tests, commands, failures/skips, and evidence. Update the recommendation + status and implementation notes without altering historical evidence. + +## Security and secret-redaction rules + +- Never commit, print, paste, or include full GitHub tokens, passwords, authorization headers, or + other credentials in source, tests, logs, issue text, or dossier evidence. +- Use placeholders and last-four-or-shorter representations only; tests must assert that secrets do + not appear in errors or logs. +- Treat inbound Host and forwarded headers as untrusted until the selected trust policy says + otherwise. +- Do not weaken authentication or expose monitoring to make tests or health checks pass. +- Redact command output and audit artifacts before reporting them. If a secret is encountered, + stop, remove it from the working output, and notify the orchestrator. + +## Required validation commands + +Unless the orchestrator assigns a narrower set, the project validation baseline is: + +```text +npm run lint +npx tsc --noEmit +npm test +npm run build +``` + +Use the repository's selected package manager after item 06 settles the path; CI currently invokes +the Yarn equivalents at `.github/workflows/ci.yml:37-38`, `54-56`, and `71-72`. For dependency work, +run the supported lockfile-aware audit command and record its limitations: Yarn previously reported +68 production advisories (1 critical, 24 high), while `npm audit` is unavailable without an npm +lockfile. Run focused tests in addition to, not instead of, the assigned baseline when the change +affects a specific path. + +## Review gates + +- **Scope gate:** only the named recommendation and its dependencies changed. +- **Security gate:** secrets remain redacted; authentication, status, trust, and monitoring exposure + decisions are explicit. +- **Regression gate:** focused and required tests cover the changed contract. +- **Resource gate:** timers, listeners, sockets, queues, body memory, and cancellation have clear + ownership where relevant. +- **Operational gate:** configuration, deployment, health checks, and package-manager instructions + remain reproducible. +- **Evidence gate:** the recommendation status, validation commands, results, and known skips are + recorded before verification. + +## Definition of done + +The dossier is complete when every recommendation has a deliberate status, implementation lanes +have respected the ordered dependencies and write scopes, planned work is not misrepresented as +implemented, relevant regression tests exist for runtime changes, assigned validation has been run +by the orchestrator or reported as skipped, review gates have passed, and agents have reported +concrete file and command evidence. diff --git a/docs/enhancements/README.md b/docs/enhancements/README.md new file mode 100644 index 0000000..dc0bc56 --- /dev/null +++ b/docs/enhancements/README.md @@ -0,0 +1,46 @@ +# Enhancement dossier + +This dossier records the project-improvement recommendations from the prior analysis. It expands the +earlier grouped analysis into 15 actionable work items, ordered from lowest to highest implementation +risk. All recommendations are currently **planned** and none has been implemented by this dossier. + +## Project baseline + +The verified baseline from the prior analysis is: + +- Lint passed. +- Production type-check passed. +- Tests passed: **110/110**. +- Dependency audit caveat: Yarn reported **68 production advisories**, including **1 critical** and + **24 high**. `npm audit` is unavailable without an npm lockfile. + +These findings describe implementation and regression risk, not issue severity. A low-risk item can +still address a serious security concern; conversely, a high-risk item is high because changing it +may affect behavior broadly, not because the underlying issue is necessarily severe. + +## Recommendations + +| # | Recommendation | Risk | Urgency | +| ---: | --- | --- | --- | +| 01 | [Redact invalid tokens from errors](./01-redact-invalid-tokens.md) | Very low | Urgent | +| 02 | [Fail closed for partial authentication](./02-partial-authentication.md) | Very low | Urgent | +| 03 | [Tighten the exact status exception](./03-exact-status-exception.md) | Very low | Urgent | +| 04 | [Correct standard unsupported-method responses](./04-standard-unsupported-method-responses.md) | Low | Normal | +| 05 | [Correct forwarded metadata](./05-forwarded-metadata.md) | Low | Normal | +| 06 | [Establish one package-manager and audit path](./06-package-manager-audit-path.md) | Low/moderate | Normal | +| 07 | [Fix worker/router lifecycle leaks](./07-worker-router-lifecycle-leaks.md) | Low/moderate | Normal | +| 08 | [Validate numeric and token configuration](./08-numeric-token-configuration.md) | Moderate | Normal | +| 09 | [Harden rate-limit refresh](./09-rate-limit-refresh.md) | Moderate | High | +| 10 | [Make proxy errors and cancellation state-aware](./10-state-aware-proxy-errors-cancellation.md) | Moderate | High | +| 11 | [Replace or isolate swagger-stats monitoring](./11-swagger-stats-monitoring.md) | Moderate | Normal | +| 12 | [Correct HTTP forwarding semantics](./12-http-forwarding-semantics.md) | Moderate/high | High | +| 13 | [Bound body, queue, and request lifetime](./13-body-queue-request-lifetime.md) | High | High | +| 14 | [Use an event-driven bounded dispatcher](./14-event-driven-dispatcher.md) | Highest | Normal | +| 15 | [Documentation and developer-experience cleanup](./15-documentation-developer-experience.md) | Low | Optional | + +## Reading and execution guidance + +Read [AGENT-ROADMAP.md](./AGENT-ROADMAP.md) before implementing any item. Each recommendation +contains the evidence, decisions, implementation notes, validation plan, and definition of done +needed for a focused change. The numeric order is authoritative for filenames and index order; +dependencies may require waiting for an earlier item before starting a later one. From 83bf2eb1d557d7a7c76e4ed8e69970db88e31fac Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Fri, 28 Aug 2026 21:14:00 -0400 Subject: [PATCH 02/26] fix(security): redact invalid token errors Keep full credentials out of emitted errors and CLI logs while preserving token removal through the event argument. --- docs/enhancements/01-redact-invalid-tokens.md | 16 ++++++++++++++-- docs/enhancements/README.md | 2 +- src/router.spec.ts | 16 ++++++++++++++++ src/router.ts | 2 +- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/enhancements/01-redact-invalid-tokens.md b/docs/enhancements/01-redact-invalid-tokens.md index a21a5f8..08ca83f 100644 --- a/docs/enhancements/01-redact-invalid-tokens.md +++ b/docs/enhancements/01-redact-invalid-tokens.md @@ -1,13 +1,13 @@ --- id: 01 title: Redact invalid tokens from errors -status: planned +status: verified risk: very-low urgency: urgent scope: error reporting and token validation --- -**Status:** Planned; not yet implemented. +**Status:** Verified; implementation and validation complete. ## Problem @@ -45,3 +45,15 @@ diagnostic redaction remains useful. Run the required project checks in the road - No invalid-token error contains the full token. - Existing error forwarding and invalid-token behavior remain intact. - Regression coverage and validation evidence are reported. + +## Verification evidence + +- `src/router.ts` now emits only the token's last four characters in the diagnostic while preserving + the full token as the event argument used for token removal. +- `src/router.spec.ts` verifies that invalid-token errors omit the complete token and retain the + redacted suffix. +- `npx vitest run src/router.spec.ts`: passed (16 tests). +- `npm run lint`: passed. +- `npx tsc --noEmit`: passed. +- `npm test`: passed (111 tests). +- `npm run build`: passed. diff --git a/docs/enhancements/README.md b/docs/enhancements/README.md index dc0bc56..3bb9217 100644 --- a/docs/enhancements/README.md +++ b/docs/enhancements/README.md @@ -2,7 +2,7 @@ This dossier records the project-improvement recommendations from the prior analysis. It expands the earlier grouped analysis into 15 actionable work items, ordered from lowest to highest implementation -risk. All recommendations are currently **planned** and none has been implemented by this dossier. +risk. Recommendation 01 is **verified**; the remaining recommendations are currently **planned**. ## Project baseline diff --git a/src/router.spec.ts b/src/router.spec.ts index 122d35c..592f31f 100644 --- a/src/router.spec.ts +++ b/src/router.spec.ts @@ -266,6 +266,22 @@ describe('Middleware core', () => { await request(app).get('/').expect(200); }); + test('it should redact invalid tokens from rate-limit errors', async () => { + const errors: string[] = []; + middleware.on('error', (error) => errors.push(error.toString())); + + nock.cleanAll(); + nock('https://api.github.com', { allowUnmocked: false }) + .get('/rate_limit') + .times(4) + .reply(StatusCodes.UNAUTHORIZED); + + await middleware.refreshRateLimits(); + + expect(errors.join('\n')).not.toContain(FAKE_TOKEN); + expect(errors.join('\n')).toContain(FAKE_TOKEN.slice(-4)); + }); + test('it should not update limits when "x-ratelimit-remaining" is not on header', async () => { scope .defaultReplyHeaders({ diff --git a/src/router.ts b/src/router.ts index 1e3ee3e..6dfd304 100644 --- a/src/router.ts +++ b/src/router.ts @@ -223,7 +223,7 @@ class ProxyWorker extends EventEmitter { if (response.status === 401) { this.remaining = 0; this.reset = Number.POSITIVE_INFINITY; - this.emit('error', `Invalid token detected (${this.token}).`, this.token); + this.emit('error', `Invalid token detected (${this.token.slice(-4)}).`, this.token); } else { const res = (await response.json()) as { resources: Record; From d1405a277f4dd8880d96295b10f6ec8a5b619a7f Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Fri, 28 Aug 2026 21:21:40 -0400 Subject: [PATCH 03/26] fix(auth): reject partial credentials Prevent deployments with one credential from silently disabling authentication. --- .../enhancements/02-partial-authentication.md | 33 +++++++-- docs/enhancements/README.md | 3 +- src/cli.spec.ts | 71 +++++++++++++++++-- src/cli.ts | 33 +++++++-- 4 files changed, 123 insertions(+), 17 deletions(-) diff --git a/docs/enhancements/02-partial-authentication.md b/docs/enhancements/02-partial-authentication.md index 2942ba2..d56339e 100644 --- a/docs/enhancements/02-partial-authentication.md +++ b/docs/enhancements/02-partial-authentication.md @@ -1,13 +1,13 @@ --- id: 02 title: Fail closed for partial authentication -status: planned +status: verified risk: very-low urgency: urgent scope: CLI authentication configuration --- -**Status:** Planned; not yet implemented. +**Status:** Verified; implementation, review, and authoritative project validation are complete. ## Problem @@ -16,8 +16,16 @@ configuration can silently leave the proxy unauthenticated. ## Evidence -- The conditional construction of the authentication object is at `src/cli.ts:105-111`. +- Authentication configuration is constructed in `src/cli.ts` and previously omitted when only one + credential was supplied. - The server middleware consumes that optional object at `src/server.ts:122-125`. +- `src/cli.ts` now rejects partial authentication before creating or listening on the proxy server, + using a stable error that contains no credential values. +- `src/cli.spec.ts` covers username-only and password-only startup rejection, neither/both + configuration, and credential redaction in the configuration error. +- Existing protected-request coverage in `src/server.spec.ts` confirms valid complete credentials + continue to authenticate, while no-auth coverage confirms neither credential keeps authentication + disabled. ## Expected benefit @@ -35,11 +43,26 @@ current successful path when both credentials are supplied. Do not log credentia ## Validation plan -Add tests for username-only, password-only, neither, and both credentials. Verify the proxy does not -start for partial configuration and that valid protected requests still authenticate. +Focused tests cover username-only, password-only, neither, and both credentials. The proxy does not +start for partial configuration, and existing protected-request tests cover valid authentication. + +Focused evidence: `npx vitest run src/cli.spec.ts src/server.spec.ts` — 77 tests passed. ## Definition of done - Partial authentication configuration is rejected before serving traffic. - Complete authentication configuration behaves as intended. - Tests and command evidence are reported. + +## Verification evidence + +- `src/cli.ts` rejects username-only and password-only configuration before creating or listening on + the proxy server, without logging credential values. +- `src/cli.spec.ts` covers partial-auth startup failures, credential redaction, and neither/both + configuration paths. +- `npx vitest run src/cli.spec.ts src/server.spec.ts`: passed (77 tests). +- `npm run lint`: passed. +- `npx tsc --noEmit`: passed. +- `npm test`: passed (117 tests). +- `npm run build`: passed. +- `git diff --check`: passed. diff --git a/docs/enhancements/README.md b/docs/enhancements/README.md index 3bb9217..99f0c21 100644 --- a/docs/enhancements/README.md +++ b/docs/enhancements/README.md @@ -2,7 +2,8 @@ This dossier records the project-improvement recommendations from the prior analysis. It expands the earlier grouped analysis into 15 actionable work items, ordered from lowest to highest implementation -risk. Recommendation 01 is **verified**; the remaining recommendations are currently **planned**. +risk. Recommendations 01 and 02 are **verified**; the remaining recommendations are currently +**planned**. ## Project baseline diff --git a/src/cli.spec.ts b/src/cli.spec.ts index b5a12b5..112ac26 100644 --- a/src/cli.spec.ts +++ b/src/cli.spec.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import { Command } from 'commander'; import { afterEach, beforeEach, describe, expect, test } from 'vitest'; -import { createCli } from './cli.js'; +import { createAuthConfiguration, createCli, PARTIAL_AUTHENTICATION_ERROR } from './cli.js'; import { concatTokens, parseTokens, readTokensFile } from './server.js'; export type CliCmdResult = { @@ -16,11 +16,15 @@ export type CliCmdResult = { stderr?: string | null; }; -export async function cli(args: string[], cwd: string): Promise { +export async function cli( + args: string[], + cwd: string, + environment: NodeJS.ProcessEnv = {} +): Promise { return new Promise((resolve) => { exec( - `npm run dev-no-reload --no-status-monitor ${args.join(' ')}`, - { cwd }, + `npm run dev-no-reload -- --no-status-monitor ${args.join(' ')}`, + { cwd, env: { ...process.env, ...environment } }, (error, stdout, stderr) => resolve({ code: error?.code ?? 0, error, stdout, stderr }) ); }); @@ -36,6 +40,65 @@ describe('Test cli app', () => { const result = await cli(['-t', 'invalid'], '.'); expect(result.code).toEqual(1); }); + + test('it should reject username-only authentication before starting', async () => { + const username = 'only-user'; + const result = await cli(['-t', '1234567890123456789012345678901234567890'], '.', { + GPS_AUTH_USERNAME: username + }); + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + + expect(result.code).toEqual(1); + expect(output).toContain(PARTIAL_AUTHENTICATION_ERROR); + expect(output).not.toContain(username); + }); + + test('it should reject password-only authentication before starting', async () => { + const password = 'only-password'; + const result = await cli(['-t', '1234567890123456789012345678901234567890'], '.', { + GPS_AUTH_PASSWORD: password + }); + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + + expect(result.code).toEqual(1); + expect(output).toContain(PARTIAL_AUTHENTICATION_ERROR); + expect(output).not.toContain(password); + }); +}); + +describe('CLI authentication configuration', () => { + test('should leave authentication disabled when neither credential is supplied', () => { + expect(createAuthConfiguration(undefined, undefined)).toBeUndefined(); + }); + + test('should configure authentication when both credentials are supplied', () => { + expect(createAuthConfiguration('testuser', 'testpass')).toEqual({ + username: 'testuser', + password: 'testpass' + }); + }); + + test('should reject username-only configuration without logging the username', () => { + const username = 'username-secret'; + + expect(() => createAuthConfiguration(username, undefined)).toThrowError( + PARTIAL_AUTHENTICATION_ERROR + ); + expect(() => createAuthConfiguration(username, undefined)).toThrowError( + expect.not.objectContaining({ message: expect.stringContaining(username) }) + ); + }); + + test('should reject password-only configuration without logging the password', () => { + const password = 'password-secret'; + + expect(() => createAuthConfiguration(undefined, password)).toThrowError( + PARTIAL_AUTHENTICATION_ERROR + ); + expect(() => createAuthConfiguration(undefined, password)).toThrowError( + expect.not.objectContaining({ message: expect.stringContaining(password) }) + ); + }); }); describe('createCli command structure', () => { diff --git a/src/cli.ts b/src/cli.ts index d684691..d53c4bc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,23 @@ import omitBy from 'lodash/omitBy.js'; import packageJson from '../package.json' with { type: 'json' }; import { type CliOpts, concatTokens, createProxyServer, readTokensFile } from './server.js'; +export const PARTIAL_AUTHENTICATION_ERROR = + 'Authentication requires both username and password when configured.'; + +export function createAuthConfiguration( + username: string | undefined, + password: string | undefined +): CliOpts['auth'] { + const hasUsername = username !== undefined; + const hasPassword = password !== undefined; + + if (hasUsername !== hasPassword) { + throw new Error(PARTIAL_AUTHENTICATION_ERROR); + } + + return hasUsername && hasPassword ? { username, password } : undefined; +} + function parseTimeBudgetMultiplier(value: string): number { const num = Number(value); if (isNaN(num) || num < 1) { @@ -81,6 +98,14 @@ export function createCli(): Command { .addOption(new Option('--no-status-monitor', 'Disable requests monitoring on /status')) .version(packageJson.version || '?', '-v, --version', 'output the current version') .action(async (options) => { + let auth: CliOpts['auth']; + try { + auth = createAuthConfiguration(options.authUsername, options.authPassword); + } catch (error) { + consola.error(error instanceof Error ? error.message : PARTIAL_AUTHENTICATION_ERROR); + process.exit(1); + } + if (!options.token.length && !options.tokens?.length) { consola.info(`${program.helpInformation()}`); consola.error(`Arguments missing ("--token" or "--tokens" is mandatory).\n\n`); @@ -102,13 +127,7 @@ export function createCli(): Command { minRemaining: options.minRemaining, timeBudgetMultiplier: options.timeBudgetMultiplier, statusMonitor: options.statusMonitor, - auth: - options.authUsername && options.authPassword - ? { - username: options.authUsername, - password: options.authPassword - } - : undefined + auth }; const app = createProxyServer(appOptions); From 6633888ef75eb66fcebb2556fa7a4af09579567c Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Fri, 28 Aug 2026 21:48:49 -0400 Subject: [PATCH 04/26] feat(proxy): harden routing and packaging Protect status lookalikes and forwarded headers while aligning dependency installs across CI and Docker. --- .dockerignore | 1 - .github/workflows/ci.yml | 13 +++- Dockerfile | 10 +-- README.md | 13 +++- .../enhancements/03-exact-status-exception.md | 21 ++++- ...4-standard-unsupported-method-responses.md | 19 ++++- docs/enhancements/05-forwarded-metadata.md | 37 ++++++--- .../06-package-manager-audit-path.md | 49 +++++++++--- docs/enhancements/README.md | 2 +- package.json | 4 +- src/proxy-client.spec.ts | 76 +++++++++++++++++-- src/proxy-client.ts | 13 ++-- src/router.ts | 2 +- src/server.spec.ts | 20 +++-- src/server.ts | 2 +- yarn.lock | 29 +------ 16 files changed, 222 insertions(+), 89 deletions(-) diff --git a/.dockerignore b/.dockerignore index 5698c61..e29725d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,4 +3,3 @@ dist node_modules samples -yarn.lock \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ca917b..3ec229f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,8 @@ on: paths: - "src/**" - "package.json" + - "yarn.lock" + - "Dockerfile" - "biome.json" - "tsconfig.json" - "vitest.config.ts" @@ -15,6 +17,8 @@ on: paths: - "src/**" - "package.json" + - "yarn.lock" + - "Dockerfile" - "biome.json" - "tsconfig.json" - "vitest.config.ts" @@ -34,7 +38,8 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: "yarn" - - run: yarn + - run: corepack enable + - run: yarn install --frozen-lockfile - run: yarn lint build: @@ -50,8 +55,9 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: "yarn" + - run: corepack enable - name: Install dependencies - run: yarn + run: yarn install --frozen-lockfile - name: Build source code run: yarn build @@ -68,7 +74,8 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: "yarn" - - run: yarn + - run: corepack enable + - run: yarn install --frozen-lockfile - run: yarn test:coverage - name: Coveralls uses: coverallsapp/github-action@v2 diff --git a/Dockerfile b/Dockerfile index a3a9ad5..2a7f80c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ # ---- Base Node ---- FROM node:24 AS base WORKDIR /app -COPY package*.json ./ +COPY package.json yarn.lock ./ # ---- Dependencies ---- FROM base AS dependencies -RUN npm install --force +RUN corepack enable && yarn install --frozen-lockfile --ignore-scripts # ---- Build ---- FROM dependencies AS build @@ -21,8 +21,8 @@ WORKDIR /app RUN apk add --no-cache curl tini # Install app dependencies -COPY --from=dependencies /app/package*.json ./ -RUN npm ci --omit=dev --ignore-scripts --force +COPY --from=dependencies /app/package.json /app/yarn.lock ./ +RUN corepack enable && yarn install --frozen-lockfile --production=true --ignore-scripts # Bundle app source COPY --from=build /app/dist ./dist @@ -33,4 +33,4 @@ EXPOSE ${PORT:-3000} HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD curl -f http://localhost:${PORT:-3000}/status || exit 1 -ENTRYPOINT ["/sbin/tini", "--", "node", "dist/cli.js"] \ No newline at end of file +ENTRYPOINT ["/sbin/tini", "--", "node", "dist/cli.js"] diff --git a/README.md b/README.md index 02a81f0..4c40c68 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,18 @@ Then make authenticated requests: curl -s -u myuser:mypass http://localhost:3000/users/gittrends-app 2>&1 ``` -**Note:** The `/status` monitoring endpoint is excluded from authentication to allow health checks. +**Note:** The `/status` monitoring endpoint and its nested `/status/*` routes are excluded from +authentication to allow health checks. Similarly prefixed routes such as `/status-other` still +require authentication. + +### Deployment and TLS + +The server listens for plain HTTP and binds to all interfaces when started by the CLI. Do not expose +that listener directly to an untrusted network: Basic Authentication credentials and proxied traffic +are not encrypted by this process. Put the server behind a trusted HTTPS/TLS termination boundary +when credentials or traffic cross an untrusted network. A local or private-network Docker health +check may continue to use `http://localhost:3000/status`; external health checks should use the +trusted HTTPS endpoint. To more usage information, use the option `--help`. diff --git a/docs/enhancements/03-exact-status-exception.md b/docs/enhancements/03-exact-status-exception.md index 1db4f51..6afaab3 100644 --- a/docs/enhancements/03-exact-status-exception.md +++ b/docs/enhancements/03-exact-status-exception.md @@ -1,13 +1,13 @@ --- id: 03 title: Tighten the exact status exception -status: planned +status: verified risk: very-low urgency: urgent scope: status endpoint authentication and deployment guidance --- -**Status:** Planned; not yet implemented. +**Status:** Verified; review and the parent orchestrator's validation gate are complete. ## Problem @@ -27,8 +27,9 @@ exposure when the service is reachable on a network interface. ## Dependencies/decisions -Decide whether the exception is exact `/status` only or the intended `/status/*` namespace. Warn -and document that HTTP requires TLS termination at a trusted boundary when credentials or traffic +The selected public surface is `/status` and the nested `/status/*` namespace. This preserves the +swagger-stats redirect from `/status` to `/status/` while excluding lookalike paths such as +`/status-other`. HTTP requires TLS termination at a trusted boundary when credentials or traffic cross an untrusted network. ## Implementation notes @@ -36,6 +37,18 @@ cross an untrusted network. Implement the selected route matcher rather than a general prefix check. Keep the status behavior needed by health checks and update deployment examples to reflect the chosen boundary. +## Implementation evidence + +- `src/server.ts` now exempts only `/status` or paths beginning with the explicit `/status/` route + boundary. +- `src/server.spec.ts` covers unauthenticated `/status` and `/status/` health access, while + `/status-other` and `/status-metrics` remain protected with configured authentication. +- `README.md` documents the public status namespace, the all-interface plain-HTTP CLI listener, + and the requirement for trusted HTTPS/TLS termination across untrusted networks. +- Focused validation: `npx vitest run src/server.spec.ts` — 22 tests passed. +- Final validation: Yarn lint, TypeScript, all 120 tests, production build, Docker image build, and + the built-container health check passed. + ## Validation plan Test `/status`, intended nested status paths, and lookalike paths such as `/status-other` under diff --git a/docs/enhancements/04-standard-unsupported-method-responses.md b/docs/enhancements/04-standard-unsupported-method-responses.md index d5974f2..bc10b6b 100644 --- a/docs/enhancements/04-standard-unsupported-method-responses.md +++ b/docs/enhancements/04-standard-unsupported-method-responses.md @@ -1,13 +1,13 @@ --- id: 04 title: Correct standard unsupported-method responses -status: planned +status: verified risk: low urgency: normal scope: HTTP method routing and response status handling --- -**Status:** Planned; not yet implemented. +**Status:** Verified; review and the parent orchestrator's validation gate are complete. ## Problem @@ -26,14 +26,25 @@ rejection remains explicit. ## Dependencies/decisions -Choose the appropriate standard 4xx status and response contract. Preserve the intentional rejection -of write methods rather than turning them into proxied writes. +Use `405 Method Not Allowed` with the existing `{ message: 'Endpoint not supported' }` response +body. Preserve the intentional rejection of write methods rather than turning them into proxied +writes. ## Implementation notes Change only the unsupported-method response path and any associated response type/name. Keep GET and GraphQL POST routing unchanged unless tests demonstrate a directly related defect. +## Implementation evidence + +- `ProxyRouterResponse.PROXY_ERROR` now resolves to `StatusCodes.METHOD_NOT_ALLOWED` (`405`) without + changing the existing route declarations or response message. +- Route integration coverage asserts the `405` status and response body for unsupported POST, PATCH, + PUT, and DELETE requests while retaining the supported GET and `/graphql` POST checks. +- Focused validation: `npx vitest run src/server.spec.ts` — 22 tests passed. +- Final validation: Yarn lint, TypeScript, all 120 tests, production build, Docker image build, and + the built-container health check passed. + ## Validation plan Add route tests for DELETE, PATCH, PUT, and unsupported POST paths, asserting the selected standard diff --git a/docs/enhancements/05-forwarded-metadata.md b/docs/enhancements/05-forwarded-metadata.md index 969d79f..36d4a7d 100644 --- a/docs/enhancements/05-forwarded-metadata.md +++ b/docs/enhancements/05-forwarded-metadata.md @@ -1,18 +1,18 @@ --- id: 05 title: Correct forwarded metadata -status: planned +status: verified risk: low urgency: normal scope: proxy request headers and upstream metadata --- -**Status:** Planned; not yet implemented. +**Status:** Verified; review and the parent orchestrator's validation gate are complete. ## Problem -Forwarded metadata is derived after the host header has been deleted, and protocol detection checks -the wrong request-socket property. Upstream requests therefore receive incorrect host/protocol data. +Forwarded metadata was derived after the host header had been deleted, and protocol detection checked +the wrong request-socket property. Upstream requests therefore received incorrect host/protocol data. ## Evidence @@ -26,18 +26,35 @@ boundary. ## Dependencies/decisions -Decide the trusted source for forwarded headers, including whether an existing `x-forwarded-for` -value may be retained. Coordinate trust-proxy and external-base-url decisions with item 12. +The default policy does not trust inbound `x-forwarded-for`, `x-forwarded-host`, or +`x-forwarded-proto` values. Existing forwarded values, including proxy-chain entries, are discarded +and replaced with metadata from the immediate connection: `remoteAddress`, `socket.encrypted`, and +the inbound `Host` captured before it is removed. A missing host produces an empty +`x-forwarded-host` value. Generated values are written after `modifyHeaders`, so an inbound value +cannot be retained accidentally by the normal proxy path. + +This is intentionally a fail-safe policy for deployments where clients can reach this boundary +directly. Future item 12 must define any opt-in trusted-proxy chain policy and its external base URL +semantics; it must not infer trust from the presence of forwarded headers. Item 12 is not implemented +here. ## Implementation notes -Capture the inbound host before deleting it, use a correct protocol source, and define behavior for -proxy chains without trusting spoofable headers by default. +Capture the inbound host before deleting it, use `req.socket.encrypted` for protocol detection, and +overwrite spoofable forwarded headers with immediate-connection metadata by default. ## Validation plan -Add proxy-client tests that inspect outgoing host/protocol/forwarded headers for representative HTTP -and HTTPS requests and for absent host data. Verify existing authorization/header behavior. +Proxy-client tests inspect outgoing host/protocol/forwarded headers for representative HTTP and HTTPS +requests, absent host data, and spoofed forwarded values. Existing authorization/header behavior +remains covered by the proxy-client suite. + +## Validation evidence + +- `npx vitest run src/proxy-client.spec.ts` +- `git diff --check` +- Final validation: Yarn lint, TypeScript, all 120 tests, production build, Docker image build, and + the built-container health check passed. ## Definition of done diff --git a/docs/enhancements/06-package-manager-audit-path.md b/docs/enhancements/06-package-manager-audit-path.md index 236dd2c..3fe9847 100644 --- a/docs/enhancements/06-package-manager-audit-path.md +++ b/docs/enhancements/06-package-manager-audit-path.md @@ -1,13 +1,13 @@ --- id: 06 title: Establish one reproducible package-manager and dependency-audit path -status: planned +status: verified risk: low/moderate urgency: normal scope: dependency manifests, lockfiles, CI, and container builds --- -**Status:** Planned; not yet implemented. +**Status:** Verified; parent review and validation are complete. ## Problem @@ -22,6 +22,8 @@ triage less reproducible. - Direct dependencies are listed in `package.json:46-58`. - `dotenv-override-true` and `https-proxy-agent` are likely unused; `ip` is used only for host display (`package.json:49`, `src/cli.ts:9`, `127`). +- `swagger-stats@0.99.7` requires the runtime peer `prom-client`; `prom-client@^14.2.0` is now a + direct production dependency so the Yarn Classic production image includes it. ## Expected benefit @@ -30,20 +32,47 @@ actionable results. ## Dependencies/decisions -Choose Yarn or npm consistently, including the committed lockfile, CI cache/install commands, and -Docker installation. Triage reachable advisories rather than treating the audit count alone as a -removal plan. Confirm dependency usage before removing anything. +Yarn Classic is authoritative because the repository already tracks `yarn.lock`, CI already uses +Yarn, and the existing developer instructions use Yarn. `package.json` pins the package manager to +`yarn@1.22.22`; installs use the lockfile without rewriting it. Docker and CI enable Corepack before +running the same frozen install. + +Triage confirmed that `dotenv-override-true` and `https-proxy-agent` have no source imports, so they +were removed from the manifest and lockfile. `ip` remains because `src/cli.ts` uses `ip.address()` +to display the listening host. Yarn still reports the known `ip` advisory: it has no patched release, +and this application does not call the affected `isPublic` API. No source change was required. ## Implementation notes -Align manifests, lockfile handling, workflow commands, and Docker context/install instructions with -the selected package manager. Review the named dependencies and record why each remains or is removed. +Aligned the manifest, Yarn lockfile, Docker build context/install commands, and all CI install steps +with Yarn Classic. The Docker context now includes `yarn.lock`; dependency and release stages both +use `yarn install --frozen-lockfile`, with production dependencies selected in the release stage. +CI uses the same frozen install in each job and retains setup-node's Yarn cache. + +The dependency-only Yarn audit completed with 182 packages and reported 68 advisories (8 low, +35 moderate, 24 high, and 1 critical). The remaining findings are primarily transitive packages +used by `swagger-stats` and the existing direct `lodash`, `undici`, and `ip` dependencies. This is +an audit baseline and triage record, not a claim that all upstream advisories are fixed; Yarn Classic +reports advisories but does not provide a general automatic remediation path. `npm audit` remains +unsupported because no npm lockfile is authoritative. ## Validation plan -Run a clean install with the selected manager, CI-equivalent lint/build/test commands, container -build checks, and the supported audit command. Record the Yarn advisory caveat and do not claim npm -audit support without an npm lockfile. +Run a clean/frozen install with Yarn, CI-equivalent lint/build/test commands, container build checks, +and `yarn audit --groups dependencies`. Record the Yarn advisory caveat and do not claim npm audit +support without an npm lockfile. + +Implementation checks: + +- `yarn install --ignore-scripts`: passed and regenerated the lockfile after removing the two unused + dependencies. +- `yarn install --frozen-lockfile --ignore-scripts`: passed. +- `yarn audit --groups dependencies`: completed with exit code 30 because of the non-zero advisory + result above. +- `npm audit`: not run; npm has no authoritative lockfile in this repository. +- `git diff --check`: passed. +- Parent validation: Yarn lint, TypeScript, all 120 tests, production build, Docker image build, and + the built-container health check passed. ## Definition of done diff --git a/docs/enhancements/README.md b/docs/enhancements/README.md index 99f0c21..f82f736 100644 --- a/docs/enhancements/README.md +++ b/docs/enhancements/README.md @@ -2,7 +2,7 @@ This dossier records the project-improvement recommendations from the prior analysis. It expands the earlier grouped analysis into 15 actionable work items, ordered from lowest to highest implementation -risk. Recommendations 01 and 02 are **verified**; the remaining recommendations are currently +risk. Recommendations 01 through 06 are **verified**; recommendations 07 through 15 are currently **planned**. ## Project baseline diff --git a/package.json b/package.json index ef8708b..58762c6 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "engines": { "node": ">=24" }, + "packageManager": "yarn@1.22.22", "type": "module", "repository": "git@github.com:gittrends-app/github-proxy-server.git", "author": "Hudson Silva Borges ", @@ -42,10 +43,8 @@ "compression": "^1.8.1", "consola": "^3.4.2", "dayjs": "^1.11.19", - "dotenv-override-true": "^6.2.2", "express": "^5.2.1", "http-status-codes": "^2.3.0", - "https-proxy-agent": "^7.0.6", "ip": "^2.0.1", "lodash": "^4.17.21", "p-limit": "^7.2.0", @@ -53,6 +52,7 @@ "pino": "^10.2.0", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", + "prom-client": "^14.2.0", "swagger-stats": "^0.99.7", "table": "^6.9.0", "undici": "^7.18.2" diff --git a/src/proxy-client.spec.ts b/src/proxy-client.spec.ts index 647f3e9..6f6ab48 100644 --- a/src/proxy-client.spec.ts +++ b/src/proxy-client.spec.ts @@ -104,7 +104,69 @@ describe('ProxyClient', () => { expect(receivedHeaders['x-custom-header']).toBe('custom-value'); }); - test('should add x-forwarded headers', async () => { + test('should add forwarded metadata from the immediate request', async () => { + let receivedHeaders: Record = {}; + + scope.get('/test').reply(function () { + receivedHeaders = this.req.headers as Record; + return [StatusCodes.OK, { success: true }]; + }); + + const { req, res } = createMockRequestResponse('GET', '/test', undefined, { + host: 'incoming.example.com' + }); + + await client.proxy(req, res); + + expect(receivedHeaders['x-forwarded-for']).toBe('127.0.0.1'); + expect(receivedHeaders['x-forwarded-proto']).toBe('http'); + expect(receivedHeaders['x-forwarded-host']).toBe('incoming.example.com'); + }); + + test('should derive HTTPS protocol from the request socket', async () => { + let receivedHeaders: Record = {}; + + scope.get('/test').reply(function () { + receivedHeaders = this.req.headers as Record; + return [StatusCodes.OK, { success: true }]; + }); + + const { req, res } = createMockRequestResponse( + 'GET', + '/test', + undefined, + {}, + { encrypted: true } + ); + + await client.proxy(req, res); + + expect(receivedHeaders['x-forwarded-proto']).toBe('https'); + }); + + test('should replace spoofed forwarded headers with immediate request metadata', async () => { + let receivedHeaders: Record = {}; + + scope.get('/test').reply(function () { + receivedHeaders = this.req.headers as Record; + return [StatusCodes.OK, { success: true }]; + }); + + const { req, res } = createMockRequestResponse('GET', '/test', undefined, { + host: 'incoming.example.com', + 'x-forwarded-for': 'spoofed-client', + 'x-forwarded-host': 'spoofed.example.com', + 'x-forwarded-proto': 'https' + }); + + await client.proxy(req, res); + + expect(receivedHeaders['x-forwarded-for']).toBe('127.0.0.1'); + expect(receivedHeaders['x-forwarded-host']).toBe('incoming.example.com'); + expect(receivedHeaders['x-forwarded-proto']).toBe('http'); + }); + + test('should send an empty forwarded host when the request has no host', async () => { let receivedHeaders: Record = {}; scope.get('/test').reply(function () { @@ -113,12 +175,11 @@ describe('ProxyClient', () => { }); const { req, res } = createMockRequestResponse('GET', '/test'); + delete req.headers.host; await client.proxy(req, res); - expect(receivedHeaders['x-forwarded-for']).toBeDefined(); - expect(receivedHeaders['x-forwarded-proto']).toBeDefined(); - expect(receivedHeaders['x-forwarded-host']).toBeDefined(); + expect(receivedHeaders['x-forwarded-host']).toBe(''); }); test('should modify headers via modifyHeaders callback', async () => { @@ -407,7 +468,8 @@ function createMockRequestResponse( method: string, url: string, body?: unknown, - headers: Record = {} + headers: Record = {}, + socketOptions: { encrypted?: boolean; remoteAddress?: string } = {} ): { req: IncomingMessage; res: ServerResponse } { const req = { method, @@ -417,8 +479,8 @@ function createMockRequestResponse( host: headers.host || 'localhost:3000' }, socket: { - remoteAddress: '127.0.0.1', - encrypted: false + remoteAddress: socketOptions.remoteAddress || '127.0.0.1', + encrypted: socketOptions.encrypted || false }, on: vi.fn((event: string, callback: (chunk?: Buffer) => void) => { if (event === 'data' && body) { diff --git a/src/proxy-client.ts b/src/proxy-client.ts index 224a6d5..1609b84 100644 --- a/src/proxy-client.ts +++ b/src/proxy-client.ts @@ -53,17 +53,20 @@ export class ProxyClient { } } + const forwardedHost = headers.host || ''; + // Remove host header to avoid conflicts delete headers.host; // Apply header modifications if provided const modifiedHeaders = options?.modifyHeaders ? options.modifyHeaders(headers) : headers; - // Add forwarded headers (x-forwarded-*) - const forwarded = headers['x-forwarded-for'] || req.socket.remoteAddress || ''; - modifiedHeaders['x-forwarded-for'] = forwarded; - modifiedHeaders['x-forwarded-proto'] = 'https' in req.socket ? 'https' : 'http'; - modifiedHeaders['x-forwarded-host'] = headers.host || ''; + // Do not trust inbound forwarded headers. The immediate connection is the only trusted hop + // until a trusted proxy policy is configured at the application boundary. + modifiedHeaders['x-forwarded-for'] = req.socket.remoteAddress || ''; + const socket = req.socket as IncomingMessage['socket'] & { encrypted?: boolean }; + modifiedHeaders['x-forwarded-proto'] = socket.encrypted ? 'https' : 'http'; + modifiedHeaders['x-forwarded-host'] = forwardedHost; // Prepare request body if present let body: Buffer | undefined; diff --git a/src/router.ts b/src/router.ts index 6dfd304..90cccf0 100644 --- a/src/router.ts +++ b/src/router.ts @@ -315,7 +315,7 @@ class QueueImpl implements RequestQueue { } export enum ProxyRouterResponse { - PROXY_ERROR = 600 + PROXY_ERROR = StatusCodes.METHOD_NOT_ALLOWED } export default class ProxyRouter extends EventEmitter { diff --git a/src/server.spec.ts b/src/server.spec.ts index 8bff594..39fb98a 100644 --- a/src/server.spec.ts +++ b/src/server.spec.ts @@ -8,7 +8,6 @@ import request from 'supertest'; import { withFile } from 'tmp-promise'; import { beforeAll, beforeEach, describe, expect, test } from 'vitest'; -import { ProxyRouterResponse } from './router.js'; import { type CliOpts, createProxyServer, parseTokens, readTokensFile } from './server.js'; describe('Test tokens file parser', () => { @@ -99,13 +98,16 @@ describe('Test create proxy server', () => { expect(() => createProxyServer(params)).toThrowError(); }); - test('it should accept GET requests', async () => { + test('it should accept GET requests and reject unsupported methods', async () => { const app = createProxyServer(params); await request(app).get('/').expect(StatusCodes.OK); - await request(app).post('/').expect(ProxyRouterResponse.PROXY_ERROR); - await request(app).patch('/').expect(ProxyRouterResponse.PROXY_ERROR); - await request(app).put('/').expect(ProxyRouterResponse.PROXY_ERROR); - await request(app).delete('/').expect(ProxyRouterResponse.PROXY_ERROR); + + for (const method of ['post', 'patch', 'put', 'delete'] as const) { + await request(app) + [method]('/') + .expect(StatusCodes.METHOD_NOT_ALLOWED) + .expect({ message: 'Endpoint not supported' }); + } }); test('it should accept POSTs only to /graphql', async () => { @@ -232,9 +234,13 @@ describe('Test proxy authentication', () => { test('it should allow access to /status without authentication', async () => { const app = createProxyServer({ ...params, statusMonitor: true }); - // /status endpoint redirects to /status/ux - follow the redirect + // /status endpoint redirects to /status/ - follow the redirect const response = await request(app).get('/status').redirects(1); expect(response.status).toBe(StatusCodes.OK); + + await request(app).get('/status/').expect(StatusCodes.OK); + await request(app).get('/status-other').expect(StatusCodes.UNAUTHORIZED); + await request(app).get('/status-metrics').expect(StatusCodes.UNAUTHORIZED); }); test('it should work with POST /graphql when authenticated', async () => { diff --git a/src/server.ts b/src/server.ts index fb46b01..a665f21 100644 --- a/src/server.ts +++ b/src/server.ts @@ -121,7 +121,7 @@ export function createProxyServer(options: CliOpts): Express { if (options.auth) { app.use((req: Request, res: Response, next) => { - if (req.path.startsWith('/status')) return next(); + if (req.path === '/status' || req.path.startsWith('/status/')) return next(); const credentials = basicAuth(req); diff --git a/yarn.lock b/yarn.lock index 4e802ee..6402a17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1485,11 +1485,6 @@ add-stream@^1.0.0: resolved "https://registry.npmjs.org/add-stream/-/add-stream-1.0.0.tgz" integrity sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ== -agent-base@^7.1.2: - version "7.1.4" - resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz" - integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== - ajv@^6.11.0, ajv@^6.12.6: version "6.12.6" resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz" @@ -2505,13 +2500,6 @@ debug@2.6.9: dependencies: ms "2.0.0" -debug@4: - version "4.3.6" - resolved "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz" - integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== - dependencies: - ms "2.1.2" - debug@^4.0.0, debug@^4.3.4: version "4.3.5" resolved "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz" @@ -2653,11 +2641,6 @@ dot-prop@^9.0.0: dependencies: type-fest "^4.18.2" -dotenv-override-true@^6.2.2: - version "6.2.2" - resolved "https://registry.npmjs.org/dotenv-override-true/-/dotenv-override-true-6.2.2.tgz" - integrity sha512-a8jtuLPLBl6rCWRfGJe+9iNdh0qqU/4b7IZBvzPYlOEx9vCBSF2Mak4xz5J9aX6GzCkIlCHUlTZW4Im+MIJx1g== - dotgitignore@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/dotgitignore/-/dotgitignore-2.1.0.tgz" @@ -3593,14 +3576,6 @@ http-status-codes@^2.3.0: resolved "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz" integrity sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA== -https-proxy-agent@^7.0.6: - version "7.0.6" - resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz" - integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== - dependencies: - agent-base "^7.1.2" - debug "4" - human-signals@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz" @@ -5345,9 +5320,9 @@ process-warning@^5.0.0: resolved "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz" integrity sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA== -prom-client@>=11.5.3: +prom-client@>=11.5.3, prom-client@^14.2.0: version "14.2.0" - resolved "https://registry.npmjs.org/prom-client/-/prom-client-14.2.0.tgz" + resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-14.2.0.tgz#ca94504e64156f6506574c25fb1c34df7812cf11" integrity sha512-sF308EhTenb/pDRPakm+WgiN+VdM/T1RaHj1x+MvAuT8UiQP8JmOEbxVqtkbfR4LrvOg5n7ic01kRBDGXjYikA== dependencies: tdigest "^0.1.1" From e35ee7373e616047cdd17eb4264658d9c6ccf0d1 Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Fri, 28 Aug 2026 22:26:25 -0400 Subject: [PATCH 05/26] fix(lifecycle): close router resources --- .../07-worker-router-lifecycle-leaks.md | 21 +- docs/enhancements/README.md | 2 +- src/cli.spec.ts | 73 ++- src/cli.ts | 102 +++- src/router.spec.ts | 143 +++++- src/router.ts | 475 +++++++++++++----- src/server.spec.ts | 52 +- src/server.ts | 15 +- 8 files changed, 694 insertions(+), 189 deletions(-) diff --git a/docs/enhancements/07-worker-router-lifecycle-leaks.md b/docs/enhancements/07-worker-router-lifecycle-leaks.md index b65e986..dcb6e63 100644 --- a/docs/enhancements/07-worker-router-lifecycle-leaks.md +++ b/docs/enhancements/07-worker-router-lifecycle-leaks.md @@ -1,13 +1,13 @@ --- id: 07 title: Fix worker and router lifecycle leaks -status: planned +status: verified risk: low/moderate urgency: normal scope: worker timers, queues, agents, listeners, and shutdown --- -**Status:** Planned; not yet implemented. +**Status:** Verified; review and final validation are complete. ## Problem @@ -23,6 +23,23 @@ destruction mutates the collection being traversed. - The global listener limit is raised at `src/cli.ts:90`. - CLI shutdown currently closes only the server at `src/cli.ts:152-161`. +## Implementation evidence + +- `src/router.ts` now owns token records, resource queues, worker wiring, refresh timer handles, and + cached asynchronous destruction; workers explicitly settle scheduled tasks, pause and clear + queues, clear timers, destroy their Undici Agents, detach references, and terminate responses. +- Concurrent token removal and router destruction are composed, while refresh and error forwarding + paths remain contained for routers without error listeners and disposal failures are aggregated; + the public manual refresh method retains its reject-on-failure/readiness contract. +- `src/server.ts` exposes an idempotent asynchronous `app.destroy()` that delegates to the hidden + router. +- `src/cli.ts` removes the global listener-limit override and performs single-flight, named signal + shutdown by starting HTTP close before router cleanup and awaiting both, including listen-error + startup cleanup. +- Focused lifecycle coverage was added to `src/router.spec.ts`, `src/server.spec.ts`, and + `src/cli.spec.ts`. +- Final validation: Yarn lint, TypeScript, all 129 tests, and production build passed. + ## Expected benefit Repeated setup/teardown, token changes, tests, and process shutdown release resources predictably diff --git a/docs/enhancements/README.md b/docs/enhancements/README.md index f82f736..403c31a 100644 --- a/docs/enhancements/README.md +++ b/docs/enhancements/README.md @@ -2,7 +2,7 @@ This dossier records the project-improvement recommendations from the prior analysis. It expands the earlier grouped analysis into 15 actionable work items, ordered from lowest to highest implementation -risk. Recommendations 01 through 06 are **verified**; recommendations 07 through 15 are currently +risk. Recommendations 01 through 07 are **verified**; recommendations 08 through 15 are currently **planned**. ## Project baseline diff --git a/src/cli.spec.ts b/src/cli.spec.ts index 112ac26..b0c000a 100644 --- a/src/cli.spec.ts +++ b/src/cli.spec.ts @@ -1,5 +1,6 @@ -import { exec } from 'node:child_process'; +import { exec, spawn } from 'node:child_process'; import { unlinkSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -64,6 +65,76 @@ describe('Test cli app', () => { expect(output).toContain(PARTIAL_AUTHENTICATION_ERROR); expect(output).not.toContain(password); }); + + test('it should destroy the router when a child receives SIGTERM', async () => { + const child = spawn( + process.execPath, + [ + '--import', + 'tsx/esm', + 'src/cli.ts', + '--no-status-monitor', + '-t', + '1234567890123456789012345678901234567890', + '-p', + '0' + ], + { cwd: process.cwd(), env: { ...process.env, FORCE_COLOR: '0' } } + ); + + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + let settled = false; + const timeout = setTimeout(() => { + if (!settled) { + child.kill('SIGKILL'); + reject(new Error('CLI did not shut down in time')); + } + }, 10000); + const signalTimer = setTimeout(() => child.kill('SIGTERM'), 3000); + child.once('error', (error) => { + clearTimeout(timeout); + clearTimeout(signalTimer); + if (!settled) { + settled = true; + reject(error); + } + }); + child.once('close', (code, signal) => { + clearTimeout(timeout); + clearTimeout(signalTimer); + if (!settled) { + settled = true; + resolve({ code, signal }); + } + }); + } + ); + + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + }, 15000); + + test('it should clean up when the listen server emits an error', async () => { + const blocker = createServer(); + await new Promise((resolve, reject) => { + blocker.once('error', reject); + blocker.listen({ host: '0.0.0.0', port: 0 }, resolve); + }); + + try { + const address = blocker.address(); + if (!address || typeof address === 'string') throw new Error('Unable to determine test port'); + + const result = await cli( + ['-t', '1234567890123456789012345678901234567890', '-p', `${address.port}`], + '.' + ); + expect(result.code).toBe(1); + } finally { + await new Promise((resolve) => blocker.close(() => resolve())); + } + }, 15000); }); describe('CLI authentication configuration', () => { diff --git a/src/cli.ts b/src/cli.ts index d53c4bc..ace8c33 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,5 @@ #!/usr/bin/env node /* Author: Hudson S. Borges */ -import EventEmitter from 'node:events'; import { pathToFileURL } from 'node:url'; import chalk from 'chalk'; @@ -112,8 +111,6 @@ export function createCli(): Command { process.exit(1); } - EventEmitter.defaultMaxListeners = Number.MAX_SAFE_INTEGER; - const tokens = [...options.token, ...(options.tokens || [])].reduce( (memo: string[], token: string) => concatTokens(token, memo), [] @@ -137,12 +134,10 @@ export function createCli(): Command { .on('warn', consola.warn) .on('error', consola.error); - const server = app.listen({ host: '0.0.0.0', port: options.port }, (error?: Error) => { - if (error) { - consola.error(error); - process.exit(1); - } - + let startupReady = false; + const server = app.listen({ host: '0.0.0.0', port: options.port }, () => { + if (!server.listening) return; + startupReady = true; const host = `http://${ip.address()}:${options.port}`; consola.success( `Proxy server running on ${host} (tokens: ${chalk.greenBright(tokens.length)})` @@ -168,24 +163,83 @@ export function createCli(): Command { ); }); - const shutdown = async () => { - server.close((err?: Error) => { - if (err) { - consola.error(err); - process.exit(1); - } - - consola.success('Server closed'); - process.exit(0); + const closeServer = (): Promise => { + return new Promise((resolve, reject) => { + server.close((error?: Error) => { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (error && code !== 'ERR_SERVER_NOT_RUNNING') { + reject(error); + } else { + resolve(); + } + }); }); }; - ['SIGTERM', 'SIGINT'].forEach((signal) => { - process.on(signal, async () => { - consola.info(`${signal} signal received: closing HTTP server`); - await shutdown(); - }); - }); + let shutdownPromise: Promise | undefined; + let handleSigterm: () => void; + let handleSigint: () => void; + + const removeSignalHandlers = (): void => { + process.off('SIGTERM', handleSigterm); + process.off('SIGINT', handleSigint); + }; + + const dispose = async (exitCode: number, announce: boolean): Promise => { + // Start both operations before awaiting either so active requests can drain or abort. + const serverClose = closeServer(); + const routerDestroy = app.destroy(); + const results = await Promise.allSettled([serverClose, routerDestroy]); + const errors = results + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map((result) => result.reason); + + removeSignalHandlers(); + if (errors.length) { + consola.error(new AggregateError(errors, 'Proxy server shutdown failed')); + process.exit(1); + return; + } + + if (announce) consola.success('Server closed'); + process.exit(exitCode); + }; + + const shutdown = (): Promise => { + if (shutdownPromise) return shutdownPromise; + + shutdownPromise = dispose(0, true); + + return shutdownPromise; + }; + + const cleanupStartupFailure = async (startupError: Error): Promise => { + consola.error(startupError); + if (!shutdownPromise) shutdownPromise = dispose(1, false); + await shutdownPromise; + }; + + handleSigterm = (): void => { + consola.info('SIGTERM signal received: closing HTTP server'); + void shutdown(); + }; + + handleSigint = (): void => { + consola.info('SIGINT signal received: closing HTTP server'); + void shutdown(); + }; + + const handleServerError = (error: Error): void => { + if (startupReady && server.listening) { + consola.error(error); + return; + } + void cleanupStartupFailure(error); + }; + + server.on('error', handleServerError); + process.once('SIGTERM', handleSigterm); + process.once('SIGINT', handleSigint); }); } diff --git a/src/router.spec.ts b/src/router.spec.ts index 592f31f..0bdf8d8 100644 --- a/src/router.spec.ts +++ b/src/router.spec.ts @@ -1,10 +1,10 @@ -import express, { type Express } from 'express'; +import express, { type Express, type Request, type Response } from 'express'; import { StatusCodes } from 'http-status-codes'; import repeat from 'lodash/repeat.js'; import times from 'lodash/times.js'; import nock from 'nock'; import request from 'supertest'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import Middleware from './router'; @@ -36,20 +36,143 @@ describe('Middleware constructor and methods', () => { expect(() => new Middleware([])).toThrowError(); }); - test('it should remove/add tokens', () => { + test('it should remove/add tokens', async () => { const middleware = new Middleware([FAKE_TOKEN]); expect(middleware.tokens).toHaveLength(1); - middleware.removeToken(FAKE_TOKEN); + await middleware.removeToken(FAKE_TOKEN); expect(middleware.tokens).toHaveLength(0); middleware.addToken(FAKE_TOKEN); expect(middleware.tokens).toHaveLength(1); + return middleware.destroy(); }); - test('it should create only one client per token', () => { + test('it should create only one client per token', async () => { const middleware = new Middleware(times(2, () => FAKE_TOKEN)); expect(middleware.tokens).toHaveLength(1); + await middleware.destroy(); + }); + + test('it should destroy every token and its lifecycle timers exactly once', async () => { + vi.useFakeTimers(); + try { + const middleware = new Middleware(times(3, (index) => `${repeat('t', 39)}${index}`)); + + const timerCount = vi.getTimerCount(); + expect(timerCount).toBeGreaterThan(0); + + const destruction = middleware.destroy(); + expect(middleware.destroy()).toBe(destruction); + await destruction; + + expect(middleware.tokens).toEqual([]); + expect(vi.getTimerCount()).toBeLessThan(timerCount); + + middleware.addToken(FAKE_TOKEN); + expect(middleware.tokens).toEqual([]); + await middleware.removeToken('missing-token'); + } finally { + vi.useRealTimers(); + } + }); + + test('it should terminate queued responses and ignore work after destroy', async () => { + const middleware = new Middleware([FAKE_TOKEN], { minRemaining: 5000 }); + const response = { + destroyed: false, + destroy: vi.fn(), + writableEnded: false + } as unknown as Response; + + await middleware.schedule({ method: 'GET', path: '/' } as Request, response); + await middleware.destroy(); + + expect(response.destroy).toHaveBeenCalledTimes(1); + + await middleware.schedule({ method: 'GET', path: '/' } as Request, response); + expect(response.destroy).toHaveBeenCalledTimes(2); + }); + + test('it should settle worker schedule promises cleared during destruction', async () => { + const middleware = new Middleware([FAKE_TOKEN], { minRemaining: 0 }); + const worker = ( + middleware as unknown as { + workersByResource: { + core: Array<{ + remaining: number; + reset: number; + proxy: { proxy: (...args: never[]) => Promise }; + schedule: (req: Request, res: Response) => Promise; + }>; + }; + } + ).workersByResource.core[0]; + worker.remaining = 1000; + worker.reset = 0; + + vi.spyOn(worker.proxy, 'proxy').mockImplementation(() => new Promise(() => undefined)); + + const requests = times(11, () => { + const request = { + headers: {}, + method: 'GET', + path: '/', + socket: { destroyed: false, writableFinished: false } + } as unknown as Request; + const response = { + destroy: vi.fn(), + destroyed: false, + writableEnded: false + } as unknown as Response; + return { request, response }; + }); + + const schedules = requests.map(({ request, response }) => worker.schedule(request, response)); + const destruction = middleware.destroy(); + + await expect(Promise.all(schedules)).resolves.toHaveLength(11); + await destruction; + requests.forEach(({ response }) => expect(response.destroy).toHaveBeenCalled()); + }); + + test('it should await concurrent token removal during router destruction', async () => { + const firstToken = `${repeat('a', 39)}0`; + const secondToken = `${repeat('a', 39)}1`; + const middleware = new Middleware([firstToken, secondToken]); + const removal = middleware.removeToken(firstToken); + const destruction = middleware.destroy(); + + await Promise.all([removal, destruction]); + expect(middleware.tokens).toEqual([]); + }); + + test('it should aggregate Agent destruction failures after attempting every worker', async () => { + const middleware = new Middleware([FAKE_TOKEN]); + const worker = ( + middleware as unknown as { + workersByResource: { core: Array<{ agent: { destroy: () => Promise } }> }; + } + ).workersByResource.core[0]; + vi.spyOn(worker.agent, 'destroy').mockRejectedValue(new Error('agent cleanup failed')); + + await expect(middleware.destroy()).rejects.toThrow('agent cleanup failed'); + await expect(middleware.destroy()).rejects.toThrow('agent cleanup failed'); + }); + + test('manual refresh should reject and not emit ready when a worker refresh fails', async () => { + const fetch = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('refresh failed')); + const middleware = new Middleware([FAKE_TOKEN]); + const ready = vi.fn(); + middleware.on('ready', ready); + + try { + await expect(middleware.refreshRateLimits()).rejects.toThrow('refresh failed'); + expect(ready).not.toHaveBeenCalled(); + } finally { + await middleware.destroy(); + fetch.mockRestore(); + } }); }); @@ -91,7 +214,7 @@ describe('Middleware core', () => { nock.cleanAll(); nock.restore(); - middleware.destroy(); + await middleware.destroy(); }); afterAll(() => { @@ -220,7 +343,7 @@ describe('Middleware core', () => { Object.values(tokens).forEach((calls) => expect(calls).toBeGreaterThan(0)); - Object.keys(tokens).forEach((token) => middleware.removeToken(token)); + await Promise.all(Object.keys(tokens).map((token) => middleware.removeToken(token))); }); test('it should not forward ratelimit and scope information', async () => { @@ -254,12 +377,12 @@ describe('Middleware core', () => { await request(app).get('/').expect(200); await request(app).get('/user').expect(200); - middleware.removeToken(FAKE_TOKEN); + await middleware.removeToken(FAKE_TOKEN); middleware.addToken(repeat('i', 40)); await request(app).get('/user').expect(401); - middleware.removeToken(repeat('i', 40)); + await middleware.removeToken(repeat('i', 40)); middleware.addToken(repeat('j', 40)); await request(app).get('/user').expect(401); @@ -302,7 +425,7 @@ describe('Middleware core', () => { await request(app).get('/').set('Authorization', tokenStr).expect(401); await request(app).get('/').expect(200); - middleware.destroy(); + await middleware.destroy(); middleware = new Middleware([FAKE_TOKEN], { requestTimeout, minRemaining: 0, diff --git a/src/router.ts b/src/router.ts index 90cccf0..355cf12 100644 --- a/src/router.ts +++ b/src/router.ts @@ -22,6 +22,22 @@ type ExtendedRequest = Request & { type APIResources = 'core' | 'search' | 'code_search' | 'graphql'; +type ScheduledRequest = QueuedRequest & { + settle: () => void; +}; + +function disposalError(errors: unknown[], message: string): Error | undefined { + if (!errors.length) return undefined; + if (errors.length === 1) { + return errors[0] instanceof Error ? errors[0] : new Error(String(errors[0])); + } + return new AggregateError(errors, message); +} + +function terminateResponse(res: Response): void { + if (!res.writableEnded && !res.destroyed) res.destroy(); +} + export interface WorkerLogger { resource: APIResources; token: string; @@ -53,11 +69,24 @@ class ProxyWorker extends EventEmitter { readonly schedule; private readonly opts: ProxyRouterOpts; + private readonly agent: Agent; + private readonly scheduledRequests = new Set(); private router?: ProxyRouter; private resourceQueue?: RequestQueue; private pullInterval?: NodeJS.Timeout; private _budgetResetInterval?: NodeJS.Timeout; private checkForWork?: () => Promise; + private destroyed = false; + private destroyPromise?: Promise; + + private emitError(error: unknown, token?: string): void { + if (!this.listenerCount('error')) return; + try { + this.emit('error', error, token); + } catch { + // Error listeners must not turn refresh failures into unhandled rejections. + } + } readonly defaults: { resource: APIResources; @@ -98,17 +127,19 @@ class ProxyWorker extends EventEmitter { (this.defaults.resource === 'graphql' ? 60000 : 90000) * (opts.timeBudgetMultiplier || 1); }, 60000).unref(); + this.agent = new Agent({ + connections: 20, + pipelining: 1, + keepAliveTimeout: 60000, + keepAliveMaxTimeout: 600000, + headersTimeout: opts.requestTimeout, + bodyTimeout: opts.requestTimeout + }); + this.proxy = new ProxyClient({ target: 'https://api.github.com', timeout: opts.requestTimeout, - dispatcher: new Agent({ - connections: 20, - pipelining: 1, - keepAliveTimeout: 60000, - keepAliveMaxTimeout: 600000, - headersTimeout: opts.requestTimeout, - bodyTimeout: opts.requestTimeout - }) + dispatcher: this.agent }); let maxConcurrent = 1; @@ -117,117 +148,153 @@ class ProxyWorker extends EventEmitter { this.queue = new PQueue({ concurrency: maxConcurrent }); this.schedule = async (req: ExtendedRequest, res: Response): Promise => { - return this.queue.add(async () => { - try { - if (req.socket.destroyed) { - this.log(); - return; - } - - const noTimeBudget = this.timeBudget < this.queue.pending * 1000; - const noRequests = - this.remaining <= opts.minRemaining && this.reset >= Math.floor(Date.now() / 1000); - - if (noTimeBudget || noRequests) { - this.emit('retry', req, res); - return; - } + if (this.destroyed) { + terminateResponse(res); + return; + } - req.startedAt = new Date(); - this.remaining -= 1; - - const hasAuthorization = opts.overrideAuthorization ? false : !!req.headers.authorization; - - await this.proxy.proxy(req, res, { - modifyHeaders: (headers) => { - if (!hasAuthorization) headers.authorization = `token ${token}`; - return headers; - }, - onResponse: async (data) => { - const linkHeader = data.headers.link; - if (linkHeader && req.headers.host) { - data.headers.link = linkHeader.replaceAll( - 'https://api.github.com', - `http://${req.headers.host}` - ); + let settleTask!: () => void; + const completion = new Promise((resolve) => { + settleTask = resolve; + }); + const scheduledRequest = { req, res, settle: settleTask }; + this.scheduledRequests.add(scheduledRequest); + + try { + void this.queue + .add(async () => { + try { + if (this.destroyed || req.socket.destroyed) { + this.log(); + return; } - // Only update rate limits if we injected the token - if (!hasAuthorization) { - const status = data.status.toString(); - const rateLimitRemaining = data.headers['x-ratelimit-remaining']; - const rateLimitReset = data.headers['x-ratelimit-reset']; - const rateLimitLimit = data.headers['x-ratelimit-limit']; - - if (rateLimitRemaining) { - this.updateLimits({ - status, - 'x-ratelimit-remaining': rateLimitRemaining, - 'x-ratelimit-reset': rateLimitReset || '', - 'x-ratelimit-limit': rateLimitLimit || '' - }); - } + const noTimeBudget = this.timeBudget < this.queue.pending * 1000; + const noRequests = + this.remaining <= opts.minRemaining && this.reset >= Math.floor(Date.now() / 1000); - this.timeBudget -= Date.now() - (req.startedAt?.getTime() || 1000); - - this.log(data.status, req.startedAt); + if (noTimeBudget || noRequests) { + this.emit('retry', req, res); + return; + } - // Remove rate limit and scope headers - for (const key of Object.keys(data.headers)) { - if (/(ratelimit|scope)/i.test(key)) { - delete data.headers[key]; + req.startedAt = new Date(); + this.remaining -= 1; + + const hasAuthorization = opts.overrideAuthorization + ? false + : !!req.headers.authorization; + + await this.proxy.proxy(req, res, { + modifyHeaders: (headers) => { + if (!hasAuthorization) headers.authorization = `token ${token}`; + return headers; + }, + onResponse: async (data) => { + if (this.destroyed) return; + + const linkHeader = data.headers.link; + if (linkHeader && req.headers.host) { + data.headers.link = linkHeader.replaceAll( + 'https://api.github.com', + `http://${req.headers.host}` + ); } - } - const exposeHeaders = data.headers['access-control-expose-headers']; - if (exposeHeaders) { - const filtered = exposeHeaders - .split(', ') - .filter((header) => !/(ratelimit|scope)/i.test(header)) - .join(', '); - if (filtered) { - data.headers['access-control-expose-headers'] = filtered; - } else { - delete data.headers['access-control-expose-headers']; + // Only update rate limits if we injected the token + if (!hasAuthorization) { + const status = data.status.toString(); + const rateLimitRemaining = data.headers['x-ratelimit-remaining']; + const rateLimitReset = data.headers['x-ratelimit-reset']; + const rateLimitLimit = data.headers['x-ratelimit-limit']; + + if (rateLimitRemaining) { + this.updateLimits({ + status, + 'x-ratelimit-remaining': rateLimitRemaining, + 'x-ratelimit-reset': rateLimitReset || '', + 'x-ratelimit-limit': rateLimitLimit || '' + }); + } + + this.timeBudget -= Date.now() - (req.startedAt?.getTime() || 1000); + + this.log(data.status, req.startedAt); + + // Remove rate limit and scope headers + for (const key of Object.keys(data.headers)) { + if (/(ratelimit|scope)/i.test(key)) { + delete data.headers[key]; + } + } + + const exposeHeaders = data.headers['access-control-expose-headers']; + if (exposeHeaders) { + const filtered = exposeHeaders + .split(', ') + .filter((header) => !/(ratelimit|scope)/i.test(header)) + .join(', '); + if (filtered) { + data.headers['access-control-expose-headers'] = filtered; + } else { + delete data.headers['access-control-expose-headers']; + } + } } } + }); + } catch (error) { + const err = error as Error & { code?: string }; + const errorCode = err.code || err.message; + this.log( + errorCode === 'ETIMEDOUT' ? 'ETIMEDOUT' : ProxyRouterResponse.PROXY_ERROR, + req.startedAt + ); + + if (!req.socket.destroyed && !req.socket.writableFinished && !res.destroyed) { + res.status(StatusCodes.BAD_GATEWAY).send(); } + + req.abortController?.abort(); + terminateResponse(res); + } finally { + this.scheduledRequests.delete(scheduledRequest); + settleTask(); } + }) + .catch(() => { + this.scheduledRequests.delete(scheduledRequest); + settleTask(); }); - } catch (error) { - const err = error as Error & { code?: string }; - const errorCode = err.code || err.message; - this.log( - errorCode === 'ETIMEDOUT' ? 'ETIMEDOUT' : ProxyRouterResponse.PROXY_ERROR, - req.startedAt - ); - - if (!req.socket.destroyed && !req.socket.writableFinished) { - res.status(StatusCodes.BAD_GATEWAY).send(); - } - - req.abortController?.abort(); - res.destroy(); - } - }); + await completion; + } catch { + this.scheduledRequests.delete(scheduledRequest); + settleTask(); + } }; } public async refreshRateLimits(): Promise { + if (this.destroyed) return; + await fetch('https://api.github.com/rate_limit', { headers: { authorization: `token ${this.token}`, 'user-agent': 'GitHub API Proxy Server (@hsborges/github-proxy-server)' } }).then(async (response) => { + if (this.destroyed) return; + if (response.status === 401) { this.remaining = 0; this.reset = Number.POSITIVE_INFINITY; - this.emit('error', `Invalid token detected (${this.token.slice(-4)}).`, this.token); + this.emitError(`Invalid token detected (${this.token.slice(-4)}).`, this.token); } else { const res = (await response.json()) as { resources: Record; }; + if (this.destroyed) return; + this.remaining = res.resources[this.defaults.resource].remaining; this.reset = res.resources[this.defaults.resource].reset; this.log(undefined, new Date()); @@ -247,6 +314,8 @@ class ProxyWorker extends EventEmitter { } private log(status?: number | string, startedAt?: Date): void { + if (this.destroyed) return; + this.emit('log', { resource: this.defaults.resource, token: this.token.slice(-4), @@ -260,6 +329,8 @@ class ProxyWorker extends EventEmitter { } canAcceptWork(): boolean { + if (this.destroyed) return false; + return ( this.queue.pending < (this.queue.concurrency ?? 1) && this.timeBudget >= this.queue.pending * 1000 && @@ -268,16 +339,18 @@ class ProxyWorker extends EventEmitter { } setRouter(router: ProxyRouter): void { + if (this.destroyed) return; + this.router = router; this.resourceQueue = router.getQueue(this.defaults.resource); this.startPullLoop(); } private startPullLoop(): void { - if (!this.router || !this.resourceQueue) return; + if (this.destroyed || !this.router || !this.resourceQueue) return; this.checkForWork = async () => { - if (!this.canAcceptWork() || !this.resourceQueue) return; + if (this.destroyed || !this.canAcceptWork() || !this.resourceQueue) return; const work = this.resourceQueue.dequeue(); if (work) await this.schedule(work.req, work.res); }; @@ -286,15 +359,51 @@ class ProxyWorker extends EventEmitter { this.pullInterval = setInterval(this.checkForWork, 100).unref(); } - destroy(): this { + get isDestroyed(): boolean { + return this.destroyed; + } + + destroy(): Promise { + if (this.destroyPromise) return this.destroyPromise; + + this.destroyed = true; + this.queue.pause(); this.queue.clear(); - if (this.pullInterval) { - clearInterval(this.pullInterval); - } - if (this._budgetResetInterval) { - clearInterval(this._budgetResetInterval); - } - return this; + + if (this.pullInterval) clearInterval(this.pullInterval); + if (this._budgetResetInterval) clearInterval(this._budgetResetInterval); + this.pullInterval = undefined; + this._budgetResetInterval = undefined; + + this.router = undefined; + this.resourceQueue = undefined; + this.checkForWork = undefined; + this.removeAllListeners(); + + this.destroyPromise = (async () => { + const errors: unknown[] = []; + + for (const { res, settle } of this.scheduledRequests) { + try { + terminateResponse(res); + } catch (error) { + errors.push(error); + } finally { + settle(); + } + } + this.scheduledRequests.clear(); + + try { + await this.agent.destroy(); + } catch (error) { + errors.push(error); + } + + const error = disposalError(errors, 'Proxy worker destruction failed'); + if (error) throw error; + })(); + return this.destroyPromise; } } @@ -334,8 +443,22 @@ export default class ProxyRouter extends EventEmitter { search: ProxyWorker; code_search: ProxyWorker; graphql: ProxyWorker; + refreshTimers: NodeJS.Timeout[]; }>; + private destroyed = false; + private destroyPromise?: Promise; + private readonly removals = new Set>(); + + private emitError(error: unknown): void { + if (!this.listenerCount('error')) return; + try { + this.emit('error', error); + } catch { + // Error listeners must not turn cleanup failures into unhandled exceptions. + } + } + // Cache worker arrays to avoid repeated map() calls private readonly workersByResource: { core: ProxyWorker[]; @@ -369,6 +492,11 @@ export default class ProxyRouter extends EventEmitter { } async schedule(req: Request, res: Response): Promise { + if (this.destroyed) { + terminateResponse(res); + return; + } + const isGraphQL = req.path.startsWith('/graphql') && req.method === 'POST'; const isCodeSearch = req.path.startsWith('/search/code'); const isSearch = req.path.startsWith('/search'); @@ -389,6 +517,7 @@ export default class ProxyRouter extends EventEmitter { } addToken(token: string): void { + if (this.destroyed) return; if (this.clients.map((client) => client.token).includes(token)) return; const core = new ProxyWorker(token, { ...this.options, resource: 'core' }); @@ -396,19 +525,46 @@ export default class ProxyRouter extends EventEmitter { const codeSearch = new ProxyWorker(token, { ...this.options, resource: 'code_search' }); const graphql = new ProxyWorker(token, { ...this.options, resource: 'graphql' }); - for (const worker of [core, search, codeSearch, graphql]) { - worker.on('error', (error: unknown) => this.emit('error', error)); + const workers = [core, search, codeSearch, graphql]; + const refreshTimers: NodeJS.Timeout[] = []; + + for (const worker of workers) { + worker.on('error', (error: unknown) => this.emitError(error)); worker.on('retry', (req: ExtendedRequest, res: Response) => this.schedule(req, res)); worker.on('log', (log: WorkerLogger) => this.emit('log', log)); worker.on('warn', (message: string) => this.emit('warn', message)); - worker.refreshRateLimits().then(() => this.emit('ready')); + void worker + .refreshRateLimits() + .then(() => { + if (!this.destroyed && !worker.isDestroyed) this.emit('ready'); + }) + .catch((error: unknown) => { + if (!this.destroyed && !worker.isDestroyed) this.emitError(error); + }); // Auto-refresh rate limits every 15 minutes - setInterval(() => worker.refreshRateLimits(), 15 * 60 * 1000).unref(); + refreshTimers.push( + setInterval( + () => { + if (this.destroyed || worker.isDestroyed) return; + void worker.refreshRateLimits().catch((error: unknown) => { + if (!this.destroyed && !worker.isDestroyed) this.emitError(error); + }); + }, + 15 * 60 * 1000 + ).unref() + ); // Phase 3: Set router reference to enable pull mechanism worker.setRouter(this); } - this.clients.push({ token, core, search, code_search: codeSearch, graphql }); + this.clients.push({ + token, + core, + search, + code_search: codeSearch, + graphql, + refreshTimers + }); // Update worker caches this.workersByResource.core.push(core); @@ -417,49 +573,104 @@ export default class ProxyRouter extends EventEmitter { this.workersByResource.graphql.push(graphql); } - removeToken(token: string): void { - const index = this.clients.map((c) => c.token).indexOf(token); - if (index === -1) return; - - const removed = this.clients.splice(index, 1); - removed.forEach((client) => { - for (const worker of [client.core, client.search, client.code_search, client.graphql]) { - worker.destroy(); - } - - // Update worker caches - const coreIndex = this.workersByResource.core.indexOf(client.core); - if (coreIndex !== -1) this.workersByResource.core.splice(coreIndex, 1); + private async destroyClient(client: (typeof this.clients)[number]): Promise { + const workers = [client.core, client.search, client.code_search, client.graphql]; + for (const timer of client.refreshTimers) clearInterval(timer); + + const results = await Promise.allSettled(workers.map((worker) => worker.destroy())); + const errors = results + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map((result) => result.reason); + + const cacheEntries: Array<[ProxyWorker[], ProxyWorker]> = [ + [this.workersByResource.core, client.core], + [this.workersByResource.search, client.search], + [this.workersByResource.code_search, client.code_search], + [this.workersByResource.graphql, client.graphql] + ]; + for (const [workersByResource, worker] of cacheEntries) { + const workerIndex = workersByResource.indexOf(worker); + if (workerIndex !== -1) workersByResource.splice(workerIndex, 1); + worker.removeAllListeners(); + } - const searchIndex = this.workersByResource.search.indexOf(client.search); - if (searchIndex !== -1) this.workersByResource.search.splice(searchIndex, 1); + const error = disposalError(errors, 'Proxy token destruction failed'); + if (error) throw error; + } - const codeSearchIndex = this.workersByResource.code_search.indexOf(client.code_search); - if (codeSearchIndex !== -1) this.workersByResource.code_search.splice(codeSearchIndex, 1); + removeToken(token: string): Promise { + if (this.destroyed) return this.destroyPromise ?? Promise.resolve(); - const graphqlIndex = this.workersByResource.graphql.indexOf(client.graphql); - if (graphqlIndex !== -1) this.workersByResource.graphql.splice(graphqlIndex, 1); - }); + const index = this.clients.map((c) => c.token).indexOf(token); + if (index === -1) return Promise.resolve(); + + const client = this.clients.splice(index, 1)[0]; + const removal = this.destroyClient(client); + this.removals.add(removal); + void removal.then( + () => this.removals.delete(removal), + () => this.removals.delete(removal) + ); + return removal; } async refreshRateLimits(): Promise { + if (this.destroyed) return; + + const clients = [...this.clients]; await Promise.all( - this.clients.map((client) => + clients.map((client) => Promise.all( - [client.core, client.search, client.code_search, client.graphql].map((w) => - w.refreshRateLimits() + [client.core, client.search, client.code_search, client.graphql].map((worker) => + worker.refreshRateLimits() ) ) ) - ).then(() => this.emit('ready')); + ); + if (!this.destroyed) this.emit('ready'); } get tokens(): string[] { return this.clients.map((client) => client.token); } - destroy(): this { - this.clients.forEach((client) => this.removeToken(client.token)); - return this; + destroy(): Promise { + if (this.destroyPromise) return this.destroyPromise; + + this.destroyed = true; + + const clients = this.clients.splice(0); + const removals = [...this.removals]; + + this.destroyPromise = (async () => { + const errors: unknown[] = []; + for (const queue of Object.values(this.queues)) { + let work = queue.dequeue(); + while (work) { + try { + terminateResponse(work.res); + } catch (error) { + errors.push(error); + } + work = queue.dequeue(); + } + } + + const results = await Promise.allSettled([ + ...clients.map((client) => this.destroyClient(client)), + ...removals + ]); + for (const result of results) { + if (result.status === 'rejected') errors.push(result.reason); + } + + for (const workers of Object.values(this.workersByResource)) workers.length = 0; + this.removeAllListeners(); + + const error = disposalError(errors, 'Proxy router destruction failed'); + if (error) throw error; + })(); + + return this.destroyPromise; } } diff --git a/src/server.spec.ts b/src/server.spec.ts index 39fb98a..4257317 100644 --- a/src/server.spec.ts +++ b/src/server.spec.ts @@ -6,10 +6,22 @@ import times from 'lodash/times.js'; import nock from 'nock'; import request from 'supertest'; import { withFile } from 'tmp-promise'; -import { beforeAll, beforeEach, describe, expect, test } from 'vitest'; +import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest'; import { type CliOpts, createProxyServer, parseTokens, readTokensFile } from './server.js'; +const createdApps: Array> = []; + +function createTestApp(options: CliOpts): ReturnType { + const app = createProxyServer(options); + createdApps.push(app); + return app; +} + +afterEach(async () => { + await Promise.all(createdApps.splice(0).map((app) => app.destroy())); +}); + describe('Test tokens file parser', () => { test('it should check tokens length', () => { expect(() => parseTokens(times(15, () => 'a').join(''))).toThrowError(); @@ -98,8 +110,16 @@ describe('Test create proxy server', () => { expect(() => createProxyServer(params)).toThrowError(); }); + test('it should expose an idempotent asynchronous app destroy method', async () => { + const app = createTestApp(params); + const destruction = app.destroy(); + + expect(app.destroy()).toBe(destruction); + await destruction; + }); + test('it should accept GET requests and reject unsupported methods', async () => { - const app = createProxyServer(params); + const app = createTestApp(params); await request(app).get('/').expect(StatusCodes.OK); for (const method of ['post', 'patch', 'put', 'delete'] as const) { @@ -111,12 +131,12 @@ describe('Test create proxy server', () => { }); test('it should accept POSTs only to /graphql', async () => { - const app = createProxyServer(params); + const app = createTestApp(params); await request(app).post('/graphql').expect(StatusCodes.OK); }); test('it should emit logs when enabled', async () => { - const app = createProxyServer({ ...params, silent: false }); + const app = createTestApp({ ...params, silent: false }); const logs: string[] = []; app.on('log', (data) => logs.push(data.toString())); @@ -130,7 +150,7 @@ describe('Test create proxy server', () => { }); test('it should not emit logs when disabled', async () => { - const app = createProxyServer({ ...params, silent: true }); + const app = createTestApp({ ...params, silent: true }); const logs: string[] = []; app.on('log', (data) => logs.push(data.toString())); @@ -140,7 +160,7 @@ describe('Test create proxy server', () => { }); test('it should emit an error when invalid token are detected', async () => { - const app = createProxyServer({ ...params, tokens: [repeat('i', 40)] }); + const app = createTestApp({ ...params, tokens: [repeat('i', 40)] }); const errors: string[] = []; app.on('error', (data) => errors.push(data.toString())); @@ -150,7 +170,7 @@ describe('Test create proxy server', () => { }); test('it should not pass authorization tokens by default', async () => { - const app = createProxyServer(params); + const app = createTestApp(params); await request(app) .get('/user') @@ -160,7 +180,7 @@ describe('Test create proxy server', () => { }); test('it should allow users to user own authorization tokens', async () => { - const app = createProxyServer({ ...params, overrideAuthorization: false }); + const app = createTestApp({ ...params, overrideAuthorization: false }); await request(app) .get('/user') @@ -206,34 +226,34 @@ describe('Test proxy authentication', () => { }); test('it should require authentication when auth is configured', async () => { - const app = createProxyServer(params); + const app = createTestApp(params); await request(app).get('/').expect(StatusCodes.UNAUTHORIZED); await request(app).post('/graphql').expect(StatusCodes.UNAUTHORIZED); }); test('it should accept valid credentials', async () => { - const app = createProxyServer(params); + const app = createTestApp(params); await request(app).get('/').auth('testuser', 'testpass').expect(StatusCodes.OK); }); test('it should reject invalid username', async () => { - const app = createProxyServer(params); + const app = createTestApp(params); await request(app).get('/').auth('wronguser', 'testpass').expect(StatusCodes.UNAUTHORIZED); }); test('it should reject invalid password', async () => { - const app = createProxyServer(params); + const app = createTestApp(params); await request(app).get('/').auth('testuser', 'wrongpass').expect(StatusCodes.UNAUTHORIZED); }); test('it should return WWW-Authenticate header on unauthorized', async () => { - const app = createProxyServer(params); + const app = createTestApp(params); const response = await request(app).get('/').expect(StatusCodes.UNAUTHORIZED); expect(response.headers['www-authenticate']).toBe('Basic realm="GitHub Proxy Server"'); }); test('it should allow access to /status without authentication', async () => { - const app = createProxyServer({ ...params, statusMonitor: true }); + const app = createTestApp({ ...params, statusMonitor: true }); // /status endpoint redirects to /status/ - follow the redirect const response = await request(app).get('/status').redirects(1); expect(response.status).toBe(StatusCodes.OK); @@ -244,12 +264,12 @@ describe('Test proxy authentication', () => { }); test('it should work with POST /graphql when authenticated', async () => { - const app = createProxyServer(params); + const app = createTestApp(params); await request(app).post('/graphql').auth('testuser', 'testpass').expect(StatusCodes.OK); }); test('it should not require auth when auth option is not provided', async () => { - const app = createProxyServer({ ...params, auth: undefined }); + const app = createTestApp({ ...params, auth: undefined }); await request(app).get('/').expect(StatusCodes.OK); }); }); diff --git a/src/server.ts b/src/server.ts index a665f21..554551a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -101,7 +101,11 @@ export type CliOpts = ProxyRouterOpts & { }; }; -export function createProxyServer(options: CliOpts): Express { +export type ProxyServer = Express & { + destroy(): Promise; +}; + +export function createProxyServer(options: CliOpts): ProxyServer { const tokens = compact(options.tokens).reduce( (memo: string[], token: string) => concatTokens(token, memo), [] @@ -166,7 +170,9 @@ export function createProxyServer(options: CliOpts): Express { ...options }); - proxy.on('error', (message) => app.emit('error', message)); + proxy.on('error', (message) => { + if (app.listenerCount('error')) app.emit('error', message); + }); proxy.on('warn', (message) => app.emit('warn', message)); if (!options.silent) { @@ -186,5 +192,8 @@ export function createProxyServer(options: CliOpts): Express { app.put('{/*path}', notSupported); app.post('{/*path}', notSupported); - return app; + const proxyApp = app as ProxyServer; + proxyApp.destroy = (): Promise => proxy.destroy(); + + return proxyApp; } From 6d7815fbd02fbb10f5f000926fdfd04b0dda91b9 Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Fri, 28 Aug 2026 22:41:13 -0400 Subject: [PATCH 06/26] fix(config): validate runtime options --- AGENTS.md | 4 +- .../08-numeric-token-configuration.md | 21 ++++- docs/enhancements/README.md | 2 +- src/cli.spec.ts | 53 ++++++++++++ src/cli.ts | 34 ++++---- src/router.spec.ts | 23 +++++- src/router.ts | 80 ++++++++++++++++++- src/server.spec.ts | 8 ++ src/server.ts | 7 +- 9 files changed, 208 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f5cbf6c..79993a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -183,7 +183,9 @@ try { ### Configuration - Support CLI flags and environment variables - Use `commander` for CLI parsing with `.env()` for env var binding -- Validate required options (e.g., tokens must be 40 characters) +- Validate supported GitHub credential formats: legacy 40-character credentials, `ghp_`, `gho_`, + `ghu_`, `ghs_`, and `ghr_` credentials with 36-character suffixes, or `github_pat_` credentials + with an 82-character suffix ## Common Gotchas diff --git a/docs/enhancements/08-numeric-token-configuration.md b/docs/enhancements/08-numeric-token-configuration.md index 18ad830..772398e 100644 --- a/docs/enhancements/08-numeric-token-configuration.md +++ b/docs/enhancements/08-numeric-token-configuration.md @@ -1,13 +1,13 @@ --- id: 08 title: Validate numeric and token configuration at startup -status: planned +status: verified risk: moderate urgency: normal scope: CLI option parsing and credential validation --- -**Status:** Planned; not yet implemented. +**Status:** Verified; review and final validation are complete. ## Problem @@ -37,6 +37,13 @@ ranges and defaults for port, timeout, minimum remaining requests, and multiplie Introduce explicit parsers/validators with clear option-specific errors. Keep secrets out of error messages and preserve the selected credential-format policy in operator documentation. +The supported numeric ranges are port `0..65535`, request timeout `1..120000` milliseconds, minimum +remaining `0..5000`, and time-budget multiplier `1..10`. Integer settings must be safe integers; +the multiplier also accepts finite decimal values. Supported credentials are legacy 40-character +alphanumeric credentials, `ghp_`, `gho_`, `ghu_`, `ghs_`, and `ghr_` credentials with 36-character +alphanumeric suffixes, and `github_pat_` credentials with an 82-character alphanumeric/underscore +suffix. + ## Validation plan Test invalid and boundary values for every numeric option, supported token formats, duplicates, and @@ -47,3 +54,13 @@ startup failure behavior. Run normal startup tests with default values. - All numeric configuration has finite, bounded validation. - Supported token formats are explicitly defined and validated. - Startup errors are safe, clear, tested, and reported. + +## Implementation evidence + +- `src/router.ts` provides shared numeric and credential validators for direct router configuration. +- `src/cli.ts` applies option-specific parsers to flags and environment-backed values. +- `src/server.ts` validates direct server options and delegates credential validation consistently. +- `src/cli.spec.ts` and `src/server.spec.ts` cover numeric boundaries, malformed values, credential + formats, duplicates, and startup validation failures. +- Final validation: focused CLI/server/router tests (119 passed), full test suite (146 passed), Yarn + lint, TypeScript, and production build passed. diff --git a/docs/enhancements/README.md b/docs/enhancements/README.md index 403c31a..d5f3752 100644 --- a/docs/enhancements/README.md +++ b/docs/enhancements/README.md @@ -2,7 +2,7 @@ This dossier records the project-improvement recommendations from the prior analysis. It expands the earlier grouped analysis into 15 actionable work items, ordered from lowest to highest implementation -risk. Recommendations 01 through 07 are **verified**; recommendations 08 through 15 are currently +risk. Recommendations 01 through 08 are **verified**; recommendations 09 through 15 are currently **planned**. ## Project baseline diff --git a/src/cli.spec.ts b/src/cli.spec.ts index b0c000a..c024d48 100644 --- a/src/cli.spec.ts +++ b/src/cli.spec.ts @@ -5,9 +5,16 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Command } from 'commander'; +import repeat from 'lodash/repeat.js'; import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { createAuthConfiguration, createCli, PARTIAL_AUTHENTICATION_ERROR } from './cli.js'; +import { + parseMinRemaining, + parsePort, + parseRequestTimeout, + parseTimeBudgetMultiplier +} from './router.js'; import { concatTokens, parseTokens, readTokensFile } from './server.js'; export type CliCmdResult = { @@ -296,6 +303,29 @@ describe('CLI option parsing', () => { }); }); +describe('Numeric configuration validation', () => { + test.each([ + ['port', parsePort, 0, 65535], + ['requestTimeout', parseRequestTimeout, 1, 120000], + ['minRemaining', parseMinRemaining, 0, 5000], + ['timeBudgetMultiplier', parseTimeBudgetMultiplier, 1, 10] + ])('should accept %s boundaries', (_name, parse, minimum, maximum) => { + expect(parse(minimum)).toBe(minimum); + expect(parse(maximum)).toBe(maximum); + }); + + test.each([ + ['port', parsePort, [-1, 65536, 1.5, '1e3', '']], + ['requestTimeout', parseRequestTimeout, [0, 120001, 1.5, 'Infinity', '']], + ['minRemaining', parseMinRemaining, [-1, 5001, 1.5, 'NaN', '']], + ['timeBudgetMultiplier', parseTimeBudgetMultiplier, [0, 10.1, 'Infinity', '1e2', '']] + ])('should reject invalid %s values', (name, parse, values) => { + for (const value of values) { + expect(() => parse(value)).toThrow(`Invalid ${name}`); + } + }); +}); + describe('CLI environment variables', () => { test('should support PORT environment variable', () => { const program = createCli(); @@ -342,6 +372,21 @@ describe('Helper Functions - concatTokens', () => { expect(result).toHaveLength(1); }); + test('should accept supported prefixed GitHub credential formats', () => { + const credentials = [ + `ghp_${repeat('a', 36)}`, + `gho_${repeat('b', 36)}`, + `ghu_${repeat('c', 36)}`, + `ghs_${repeat('d', 36)}`, + `ghr_${repeat('e', 36)}`, + `github_pat_${repeat('f', 82)}` + ]; + + expect( + credentials.reduce((list, credential) => concatTokens(credential, list), []) + ).toEqual(credentials); + }); + test('should add valid token to existing list', () => { const token1 = '1234567890123456789012345678901234567890'; const token2 = '0987654321098765432109876543210987654321'; @@ -370,6 +415,14 @@ describe('Helper Functions - concatTokens', () => { test('should throw error for empty token', () => { expect(() => concatTokens('', [])).toThrow('Invalid access token detected'); }); + + test('should reject unsupported credential formats without exposing them', () => { + const invalidToken = `github_pat_${repeat('secret', 20)}`; + + expect(() => concatTokens(invalidToken, [])).toThrow( + expect.not.objectContaining({ message: expect.stringContaining(invalidToken) }) + ); + }); }); describe('Helper Functions - parseTokens', () => { diff --git a/src/cli.ts b/src/cli.ts index ace8c33..b158d88 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,12 @@ import omit from 'lodash/omit.js'; import omitBy from 'lodash/omitBy.js'; import packageJson from '../package.json' with { type: 'json' }; +import { + parseMinRemaining, + parsePort, + parseRequestTimeout, + parseTimeBudgetMultiplier +} from './router.js'; import { type CliOpts, concatTokens, createProxyServer, readTokensFile } from './server.js'; export const PARTIAL_AUTHENTICATION_ERROR = @@ -31,21 +37,13 @@ export function createAuthConfiguration( return hasUsername && hasPassword ? { username, password } : undefined; } -function parseTimeBudgetMultiplier(value: string): number { - const num = Number(value); - if (isNaN(num) || num < 1) { - throw new Error('Time budget multiplier must be >= 1.0 (use 1.0 for 100%, 1.5 for 150%, etc.)'); - } - return num; -} - export function createCli(): Command { const program = new Command(); return program .addOption( new Option('-p, --port [port]', 'Port to start the proxy server') - .argParser(Number) + .argParser(parsePort) .default(3000) .env('PORT') ) @@ -61,13 +59,13 @@ export function createCli(): Command { ) .addOption( new Option('--request-timeout [timeout]', 'Request timeout (ms)') - .argParser(Number) + .argParser(parseRequestTimeout) .default(30000) .env('GPS_REQUEST_TIMEOUT') ) .addOption( new Option('--min-remaining ', 'Stop using token on a minimum of') - .argParser(Number) + .argParser(parseMinRemaining) .default(100) .env('GPS_MIN_REMAINING') ) @@ -115,14 +113,18 @@ export function createCli(): Command { (memo: string[], token: string) => concatTokens(token, memo), [] ); + const port = parsePort(options.port); + const requestTimeout = parseRequestTimeout(options.requestTimeout); + const minRemaining = parseMinRemaining(options.minRemaining); + const timeBudgetMultiplier = parseTimeBudgetMultiplier(options.timeBudgetMultiplier); const appOptions: CliOpts = { - requestTimeout: options.requestTimeout, + requestTimeout, silent: options.silent, overrideAuthorization: options.overrideAuthorization, tokens: tokens, - minRemaining: options.minRemaining, - timeBudgetMultiplier: options.timeBudgetMultiplier, + minRemaining, + timeBudgetMultiplier, statusMonitor: options.statusMonitor, auth }; @@ -135,10 +137,10 @@ export function createCli(): Command { .on('error', consola.error); let startupReady = false; - const server = app.listen({ host: '0.0.0.0', port: options.port }, () => { + const server = app.listen({ host: '0.0.0.0', port }, () => { if (!server.listening) return; startupReady = true; - const host = `http://${ip.address()}:${options.port}`; + const host = `http://${ip.address()}:${port}`; consola.success( `Proxy server running on ${host} (tokens: ${chalk.greenBright(tokens.length)})` ); diff --git a/src/router.spec.ts b/src/router.spec.ts index 0bdf8d8..e0bd6dc 100644 --- a/src/router.spec.ts +++ b/src/router.spec.ts @@ -36,6 +36,27 @@ describe('Middleware constructor and methods', () => { expect(() => new Middleware([])).toThrowError(); }); + test.each([ + ['requestTimeout', { requestTimeout: 0 }], + ['minRemaining', { minRemaining: -1 }], + ['timeBudgetMultiplier', { timeBudgetMultiplier: 11 }] + ])('it should reject invalid direct %s options', (_name, invalidOptions) => { + expect(() => new Middleware([FAKE_TOKEN], invalidOptions)).toThrow(`Invalid ${_name}`); + }); + + test('it should validate every token before allocating worker resources', () => { + const setInterval = vi.spyOn(globalThis, 'setInterval'); + + try { + expect(() => new Middleware([FAKE_TOKEN, 'invalid-token'])).toThrow( + 'unsupported GitHub credential format' + ); + expect(setInterval).not.toHaveBeenCalled(); + } finally { + setInterval.mockRestore(); + } + }); + test('it should remove/add tokens', async () => { const middleware = new Middleware([FAKE_TOKEN]); expect(middleware.tokens).toHaveLength(1); @@ -325,7 +346,7 @@ describe('Middleware core', () => { test('it should balance the use of the tokens', async () => { scope.get('/').delay(250).reply(200); - const tokens = times(5, (n) => `${FAKE_TOKEN}**${n}`) + const tokens = times(5, (n) => `${repeat('t', 39)}${n}`) .concat(FAKE_TOKEN) .reduce((memo: Record, token) => ({ ...memo, [token]: 0 }), {}); diff --git a/src/router.ts b/src/router.ts index 355cf12..83eb0e7 100644 --- a/src/router.ts +++ b/src/router.ts @@ -22,6 +22,80 @@ type ExtendedRequest = Request & { type APIResources = 'core' | 'search' | 'code_search' | 'graphql'; +export const NUMERIC_CONFIGURATION_LIMITS = { + port: { min: 0, max: 65535 }, + requestTimeout: { min: 1, max: 120000 }, + minRemaining: { min: 0, max: 5000 }, + timeBudgetMultiplier: { min: 1, max: 10 } +} as const; + +function parseNumericConfiguration( + value: unknown, + name: keyof typeof NUMERIC_CONFIGURATION_LIMITS, + integer: boolean +): number { + const limits = NUMERIC_CONFIGURATION_LIMITS[name]; + const pattern = integer ? /^\+?\d+$/ : /^\+?(?:\d+(?:\.\d+)?|\.\d+)$/; + const number = + typeof value === 'number' + ? value + : typeof value === 'string' && pattern.test(value) + ? Number(value) + : Number.NaN; + const isValid = + Number.isFinite(number) && + Math.abs(number) <= Number.MAX_SAFE_INTEGER && + (!integer || Number.isInteger(number)) && + number >= limits.min && + number <= limits.max; + + if (!isValid) { + const kind = integer ? 'a safe integer' : 'a finite safe number'; + throw new Error(`Invalid ${name}: expected ${kind} between ${limits.min} and ${limits.max}.`); + } + + return number; +} + +export function parsePort(value: unknown): number { + return parseNumericConfiguration(value, 'port', true); +} + +export function parseRequestTimeout(value: unknown): number { + return parseNumericConfiguration(value, 'requestTimeout', true); +} + +export function parseMinRemaining(value: unknown): number { + return parseNumericConfiguration(value, 'minRemaining', true); +} + +export function parseTimeBudgetMultiplier(value: unknown): number { + return parseNumericConfiguration(value, 'timeBudgetMultiplier', false); +} + +export function validateProxyRouterOptions(options: ProxyRouterOpts): ProxyRouterOpts { + const requestTimeout = parseRequestTimeout(options.requestTimeout); + const minRemaining = parseMinRemaining(options.minRemaining); + const timeBudgetMultiplier = + options.timeBudgetMultiplier === undefined + ? undefined + : parseTimeBudgetMultiplier(options.timeBudgetMultiplier); + + return { ...options, requestTimeout, minRemaining, timeBudgetMultiplier }; +} + +const GITHUB_TOKEN_PATTERNS = [ + /^[A-Za-z0-9]{40}$/, + /^gh[opusr]_[A-Za-z0-9]{36}$/, + /^github_pat_[A-Za-z0-9_]{82}$/ +]; + +export function validateGitHubToken(token: unknown): asserts token is string { + if (typeof token !== 'string' || !GITHUB_TOKEN_PATTERNS.some((pattern) => pattern.test(token))) { + throw new Error('Invalid access token detected (unsupported GitHub credential format).'); + } +} + type ScheduledRequest = QueuedRequest & { settle: () => void; }; @@ -476,9 +550,12 @@ export default class ProxyRouter extends EventEmitter { super({}); if (!tokens.length) throw new Error('At least one token is required!'); + tokens.forEach((token) => validateGitHubToken(token)); this.clients = []; - this.options = Object.assign({ requestTimeout: 20000, minRemaining: 100 }, opts); + this.options = validateProxyRouterOptions( + Object.assign({ requestTimeout: 20000, minRemaining: 100 }, opts) + ); // Initialize per-resource queues this.queues = { @@ -518,6 +595,7 @@ export default class ProxyRouter extends EventEmitter { addToken(token: string): void { if (this.destroyed) return; + validateGitHubToken(token); if (this.clients.map((client) => client.token).includes(token)) return; const core = new ProxyWorker(token, { ...this.options, resource: 'core' }); diff --git a/src/server.spec.ts b/src/server.spec.ts index 4257317..87e4bc0 100644 --- a/src/server.spec.ts +++ b/src/server.spec.ts @@ -110,6 +110,14 @@ describe('Test create proxy server', () => { expect(() => createProxyServer(params)).toThrowError(); }); + test.each([ + ['requestTimeout', { requestTimeout: 0 }], + ['minRemaining', { minRemaining: -1 }], + ['timeBudgetMultiplier', { timeBudgetMultiplier: Infinity }] + ])('it should validate direct %s configuration', (_name, invalidOptions) => { + expect(() => createProxyServer({ ...params, ...invalidOptions })).toThrow(`Invalid ${_name}`); + }); + test('it should expose an idempotent asynchronous app destroy method', async () => { const app = createTestApp(params); const destruction = app.destroy(); diff --git a/src/server.ts b/src/server.ts index 554551a..c3bdd00 100644 --- a/src/server.ts +++ b/src/server.ts @@ -20,6 +20,8 @@ import { getBorderCharacters, table } from 'table'; import ProxyRouter, { type ProxyRouterOpts, ProxyRouterResponse, + validateGitHubToken, + validateProxyRouterOptions, type WorkerLogger } from './router.js'; @@ -79,8 +81,7 @@ export function parseTokens(text: string): string[] { // concat tokens in commander export function concatTokens(token: string, list: string[]): string[] { - if (token.length !== 40) - throw new Error('Invalid access token detected (they have 40 characters)'); + validateGitHubToken(token); return uniq([...list, token]); } @@ -106,6 +107,8 @@ export type ProxyServer = Express & { }; export function createProxyServer(options: CliOpts): ProxyServer { + validateProxyRouterOptions(options); + const tokens = compact(options.tokens).reduce( (memo: string[], token: string) => concatTokens(token, memo), [] From b3a3f700fd0cb61d462e503fcf3993b6b97a2d52 Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Fri, 28 Aug 2026 23:05:30 -0400 Subject: [PATCH 07/26] fix(rate-limit): harden refresh lifecycle --- docs/enhancements/09-rate-limit-refresh.md | 25 +- docs/enhancements/README.md | 2 +- src/router.spec.ts | 242 +++++++++++++++++- src/router.ts | 282 ++++++++++++++++----- 4 files changed, 489 insertions(+), 62 deletions(-) diff --git a/docs/enhancements/09-rate-limit-refresh.md b/docs/enhancements/09-rate-limit-refresh.md index cb16bdc..56af617 100644 --- a/docs/enhancements/09-rate-limit-refresh.md +++ b/docs/enhancements/09-rate-limit-refresh.md @@ -1,13 +1,13 @@ --- id: 09 title: Harden rate-limit refresh against outages and malformed responses -status: planned +status: verified risk: moderate urgency: high scope: rate-limit fetching, parsing, refresh scheduling, and token workers --- -**Status:** Planned; not yet implemented. +**Status:** Verified; review and final validation are complete. ## Problem @@ -35,6 +35,16 @@ Define stale-state behavior, retry/backoff limits, malformed-response handling, Handle fetch, HTTP, JSON, and resource-shape failures explicitly. Centralize or coordinate refresh per token, preserve safe stale values, and emit actionable redacted diagnostics. +The implementation makes up to three attempts per token refresh, with 250ms then 500ms bounded +backoff (capped at 2000ms). A successful response must contain validated `core`, `search`, +`code_search`, and `graphql` resources before any worker state changes. Failed refreshes retain +previous values, report only the token suffix, and detached initial/interval refreshes are contained. +Manual refreshes reject on failure and emit `ready` only after all tokens refresh successfully; a +single in-flight refresh is coalesced per token and its response is fanned out to all four workers. +Each attempt uses an item-09-owned `AbortController` bounded by the configured request timeout. +Each client owns its active controller, attempt timeout, and retry timer; token removal and router +destruction cancel and await that work, preventing later fetches or diagnostics for removed tokens. + ## Validation plan Test network failure, non-success responses, malformed JSON/resource data, backoff, stale state, and @@ -45,3 +55,14 @@ successful refresh. Verify refresh fan-out and that no promise rejection is unha - Refresh failures are contained, observable, and bounded by the selected retry policy. - Valid responses update all required resource state from the intended refresh path. - Tests cover outages and malformed responses with reported evidence. + +## Implementation evidence + +- `src/router.ts` centralizes one validated `/rate_limit` fetch per token, bounded retry/backoff, + stale-state preservation, coalescing, worker fan-out, abortable attempt timeouts, and per-client + retry-timer cleanup within the item 07 ownership model. +- `src/router.spec.ts` covers fan-out, coalescing, retry bounds, stale values, malformed responses, + manual failure behavior, destruction during refresh, removed-token cancellation, retry recovery, + and one-fetch-per-token behavior across multiple tokens. +- Final validation: focused router tests (35 passed), full test suite (155 passed), Yarn lint, + TypeScript, and production build passed. diff --git a/docs/enhancements/README.md b/docs/enhancements/README.md index d5f3752..727cfa9 100644 --- a/docs/enhancements/README.md +++ b/docs/enhancements/README.md @@ -2,7 +2,7 @@ This dossier records the project-improvement recommendations from the prior analysis. It expands the earlier grouped analysis into 15 actionable work items, ordered from lowest to highest implementation -risk. Recommendations 01 through 08 are **verified**; recommendations 09 through 15 are currently +risk. Recommendations 01 through 09 are **verified**; recommendations 10 through 15 are currently **planned**. ## Project baseline diff --git a/src/router.spec.ts b/src/router.spec.ts index e0bd6dc..1346564 100644 --- a/src/router.spec.ts +++ b/src/router.spec.ts @@ -12,6 +12,23 @@ let app: Express; const FAKE_TOKEN = repeat('t', 40); +const RATE_LIMIT_RESOURCES = { + core: { limit: 5000, remaining: 4000, reset: 2000000000 }, + search: { limit: 30, remaining: 25, reset: 2000000000 }, + code_search: { limit: 10, remaining: 8, reset: 2000000000 }, + graphql: { limit: 5000, remaining: 4500, reset: 2000000000 } +}; + +function rateLimitResponse( + resources: unknown = RATE_LIMIT_RESOURCES, + status = StatusCodes.OK +): globalThis.Response { + return { + status, + json: async () => ({ resources }) + } as unknown as globalThis.Response; +} + describe('Middleware constructor and methods', () => { beforeAll(() => { nock('https://api.github.com', { allowUnmocked: false }) @@ -197,6 +214,228 @@ describe('Middleware constructor and methods', () => { }); }); +describe('Rate-limit refresh policy', () => { + test('should fetch once per token and fan out validated resources', async () => { + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(rateLimitResponse()); + const middleware = new Middleware([FAKE_TOKEN]); + + try { + await new Promise((resolve) => middleware.once('ready', resolve)); + expect(fetch).toHaveBeenCalledTimes(1); + + const workers = ( + middleware as unknown as { + workersByResource: Record>; + } + ).workersByResource; + expect(workers.core[0]).toMatchObject({ remaining: 4000, reset: 2000000000 }); + expect(workers.search[0]).toMatchObject({ remaining: 25, reset: 2000000000 }); + expect(workers.code_search[0]).toMatchObject({ remaining: 8, reset: 2000000000 }); + expect(workers.graphql[0]).toMatchObject({ remaining: 4500, reset: 2000000000 }); + } finally { + await middleware.destroy(); + fetch.mockRestore(); + } + }); + + test('should coalesce concurrent refreshes for a token', async () => { + let resolveResponse!: (response: globalThis.Response) => void; + const pendingResponse = new Promise((resolve) => { + resolveResponse = resolve; + }); + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(rateLimitResponse()) + .mockImplementation(() => pendingResponse); + const middleware = new Middleware([FAKE_TOKEN]); + + try { + await new Promise((resolve) => middleware.once('ready', resolve)); + const first = middleware.refreshRateLimits(); + const second = middleware.refreshRateLimits(); + expect(fetch).toHaveBeenCalledTimes(2); + + resolveResponse(rateLimitResponse()); + await Promise.all([first, second]); + expect(fetch).toHaveBeenCalledTimes(2); + } finally { + await middleware.destroy(); + fetch.mockRestore(); + } + }); + + test('should retry bounded refresh failures and preserve stale values', async () => { + vi.useFakeTimers(); + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(rateLimitResponse()); + const middleware = new Middleware([FAKE_TOKEN]); + + try { + await new Promise((resolve) => middleware.once('ready', resolve)); + const worker = ( + middleware as unknown as { + workersByResource: { core: Array<{ remaining: number; reset: number }> }; + } + ).workersByResource.core[0]; + const previous = { remaining: worker.remaining, reset: worker.reset }; + fetch.mockRejectedValue(new Error('network unavailable')); + + const refresh = middleware.refreshRateLimits(); + const rejection = expect(refresh).rejects.toThrow('after 3 attempts'); + await vi.advanceTimersByTimeAsync(250); + await vi.advanceTimersByTimeAsync(500); + await rejection; + expect(fetch).toHaveBeenCalledTimes(4); + expect(worker).toMatchObject(previous); + } finally { + await middleware.destroy(); + fetch.mockRestore(); + vi.useRealTimers(); + } + }); + + test('should reject malformed HTTP, JSON, and resource responses', async () => { + const invalidResponses = [ + rateLimitResponse(undefined, StatusCodes.BAD_GATEWAY), + { + status: StatusCodes.OK, + json: async () => { + throw new Error('invalid json'); + } + } as unknown as globalThis.Response, + rateLimitResponse({ core: { remaining: 1, reset: 2000000000 } }) + ]; + + for (const invalidResponse of invalidResponses) { + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(rateLimitResponse()); + const middleware = new Middleware([FAKE_TOKEN]); + try { + await new Promise((resolve) => middleware.once('ready', resolve)); + fetch.mockResolvedValue(invalidResponse); + const refresh = middleware.refreshRateLimits(); + await expect(refresh).rejects.toThrow('Rate-limit refresh failed'); + } finally { + await middleware.destroy(); + fetch.mockRestore(); + } + } + }); + + test('should contain refresh failures when destroyed during a retry', async () => { + vi.useFakeTimers(); + const fetch = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network unavailable')); + const middleware = new Middleware([FAKE_TOKEN]); + + try { + const refresh = middleware.refreshRateLimits(); + await vi.advanceTimersByTimeAsync(250); + await middleware.destroy(); + await expect(refresh).resolves.toBeUndefined(); + } finally { + fetch.mockRestore(); + vi.useRealTimers(); + } + }); + + test('should abort a never-settling refresh when destroyed', async () => { + let signal: AbortSignal | undefined; + const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { + signal = init?.signal ?? undefined; + return new Promise(() => undefined); + }); + const middleware = new Middleware([FAKE_TOKEN]); + + try { + await middleware.destroy(); + expect(signal?.aborted).toBe(true); + } finally { + fetch.mockRestore(); + } + }); + + test('should stop removed-token retries without later fetches or diagnostics', async () => { + vi.useFakeTimers(); + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(rateLimitResponse()) + .mockRejectedValue(new Error('network unavailable')); + const middleware = new Middleware([FAKE_TOKEN]); + const errors = vi.fn(); + middleware.on('error', errors); + + try { + await new Promise((resolve) => middleware.once('ready', resolve)); + const refresh = middleware.refreshRateLimits(); + await Promise.resolve(); + await Promise.resolve(); + await middleware.removeToken(FAKE_TOKEN); + await expect(refresh).resolves.toBeUndefined(); + await vi.advanceTimersByTimeAsync(5000); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(errors).not.toHaveBeenCalled(); + } finally { + await middleware.destroy(); + fetch.mockRestore(); + vi.useRealTimers(); + } + }); + + test('should recover after a retry succeeds', async () => { + vi.useFakeTimers(); + const recovered = rateLimitResponse({ + core: { limit: 5000, remaining: 1234, reset: 2000000100 }, + search: { limit: 30, remaining: 20, reset: 2000000100 }, + code_search: { limit: 10, remaining: 7, reset: 2000000100 }, + graphql: { limit: 5000, remaining: 4321, reset: 2000000100 } + }); + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(rateLimitResponse()) + .mockRejectedValueOnce(new Error('temporary outage')) + .mockResolvedValueOnce(recovered); + const middleware = new Middleware([FAKE_TOKEN]); + + try { + await new Promise((resolve) => middleware.once('ready', resolve)); + const refresh = middleware.refreshRateLimits(); + const success = expect(refresh).resolves.toBeUndefined(); + await vi.advanceTimersByTimeAsync(250); + await success; + + const worker = ( + middleware as unknown as { + workersByResource: { core: Array<{ remaining: number; reset: number }> }; + } + ).workersByResource.core[0]; + expect(fetch).toHaveBeenCalledTimes(3); + expect(worker).toMatchObject({ remaining: 1234, reset: 2000000100 }); + } finally { + await middleware.destroy(); + fetch.mockRestore(); + vi.useRealTimers(); + } + }); + + test('should perform one refresh fetch per token across multiple tokens', async () => { + const firstToken = `${repeat('a', 39)}0`; + const secondToken = `${repeat('b', 39)}1`; + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(rateLimitResponse()); + const middleware = new Middleware([firstToken, secondToken]); + + try { + await middleware.refreshRateLimits(); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls.map(([, init]) => init?.headers)).toEqual([ + expect.objectContaining({ authorization: `token ${firstToken}` }), + expect.objectContaining({ authorization: `token ${secondToken}` }) + ]); + } finally { + await middleware.destroy(); + fetch.mockRestore(); + } + }); +}); + describe('Middleware core', () => { let scope: nock.Scope; let middleware: Middleware; @@ -351,6 +590,7 @@ describe('Middleware core', () => { .reduce((memo: Record, token) => ({ ...memo, [token]: 0 }), {}); for (const token of Object.keys(tokens)) { + if (middleware.tokens.includes(token)) continue; middleware.addToken(token); await new Promise((resolve) => middleware.once('ready', resolve)); } @@ -420,7 +660,7 @@ describe('Middleware core', () => { .times(4) .reply(StatusCodes.UNAUTHORIZED); - await middleware.refreshRateLimits(); + await expect(middleware.refreshRateLimits()).rejects.toThrow('Rate-limit refresh failed'); expect(errors.join('\n')).not.toContain(FAKE_TOKEN); expect(errors.join('\n')).toContain(FAKE_TOKEN.slice(-4)); diff --git a/src/router.ts b/src/router.ts index 83eb0e7..1faa1bb 100644 --- a/src/router.ts +++ b/src/router.ts @@ -22,6 +22,46 @@ type ExtendedRequest = Request & { type APIResources = 'core' | 'search' | 'code_search' | 'graphql'; +type RateLimit = { + remaining: number; + reset: number; +}; + +type RateLimitResources = Record; + +const REFRESH_MAX_ATTEMPTS = 3; +const REFRESH_BACKOFF_BASE_MS = 250; +const REFRESH_BACKOFF_MAX_MS = 2000; + +class RefreshFailure extends Error {} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function parseRateLimitResources(value: unknown): RateLimitResources { + if (!isRecord(value)) throw new RefreshFailure('invalid resource shape'); + + const resources = {} as RateLimitResources; + for (const resource of ['core', 'search', 'code_search', 'graphql'] as APIResources[]) { + const data = value[resource]; + if ( + !isRecord(data) || + typeof data.remaining !== 'number' || + !Number.isSafeInteger(data.remaining) || + data.remaining < 0 || + typeof data.reset !== 'number' || + !Number.isSafeInteger(data.reset) || + data.reset < 0 + ) { + throw new RefreshFailure('invalid resource shape'); + } + resources[resource] = { remaining: data.remaining, reset: data.reset }; + } + + return resources; +} + export const NUMERIC_CONFIGURATION_LIMITS = { port: { min: 0, max: 65535 }, requestTimeout: { min: 1, max: 120000 }, @@ -348,32 +388,12 @@ class ProxyWorker extends EventEmitter { }; } - public async refreshRateLimits(): Promise { + applyRateLimit(limit: RateLimit): void { if (this.destroyed) return; - await fetch('https://api.github.com/rate_limit', { - headers: { - authorization: `token ${this.token}`, - 'user-agent': 'GitHub API Proxy Server (@hsborges/github-proxy-server)' - } - }).then(async (response) => { - if (this.destroyed) return; - - if (response.status === 401) { - this.remaining = 0; - this.reset = Number.POSITIVE_INFINITY; - this.emitError(`Invalid token detected (${this.token.slice(-4)}).`, this.token); - } else { - const res = (await response.json()) as { - resources: Record; - }; - if (this.destroyed) return; - - this.remaining = res.resources[this.defaults.resource].remaining; - this.reset = res.resources[this.defaults.resource].reset; - this.log(undefined, new Date()); - } - }); + this.remaining = limit.remaining; + this.reset = limit.reset; + this.log(undefined, new Date()); } private updateLimits(headers: Record): void { @@ -518,6 +538,12 @@ export default class ProxyRouter extends EventEmitter { code_search: ProxyWorker; graphql: ProxyWorker; refreshTimers: NodeJS.Timeout[]; + refreshPromise?: Promise; + refreshController?: AbortController; + refreshTimeout?: NodeJS.Timeout; + retryTimer?: NodeJS.Timeout; + retryWaitResolve?: () => void; + removed?: boolean; }>; private destroyed = false; @@ -604,58 +630,200 @@ export default class ProxyRouter extends EventEmitter { const graphql = new ProxyWorker(token, { ...this.options, resource: 'graphql' }); const workers = [core, search, codeSearch, graphql]; - const refreshTimers: NodeJS.Timeout[] = []; for (const worker of workers) { worker.on('error', (error: unknown) => this.emitError(error)); worker.on('retry', (req: ExtendedRequest, res: Response) => this.schedule(req, res)); worker.on('log', (log: WorkerLogger) => this.emit('log', log)); worker.on('warn', (message: string) => this.emit('warn', message)); - void worker - .refreshRateLimits() - .then(() => { - if (!this.destroyed && !worker.isDestroyed) this.emit('ready'); - }) - .catch((error: unknown) => { - if (!this.destroyed && !worker.isDestroyed) this.emitError(error); - }); - // Auto-refresh rate limits every 15 minutes - refreshTimers.push( - setInterval( - () => { - if (this.destroyed || worker.isDestroyed) return; - void worker.refreshRateLimits().catch((error: unknown) => { - if (!this.destroyed && !worker.isDestroyed) this.emitError(error); - }); - }, - 15 * 60 * 1000 - ).unref() - ); // Phase 3: Set router reference to enable pull mechanism worker.setRouter(this); } - this.clients.push({ + const client = { token, core, search, code_search: codeSearch, graphql, - refreshTimers - }); + refreshTimers: [] as NodeJS.Timeout[] + }; + this.clients.push(client); // Update worker caches this.workersByResource.core.push(core); this.workersByResource.search.push(search); this.workersByResource.code_search.push(codeSearch); this.workersByResource.graphql.push(graphql); + + this.startDetachedRefresh(client, true); + // Auto-refresh rate limits every 15 minutes, once per token. + client.refreshTimers.push( + setInterval(() => this.startDetachedRefresh(client, false), 15 * 60 * 1000).unref() + ); + } + + private startDetachedRefresh(client: (typeof this.clients)[number], emitReady: boolean): void { + void this.refreshClient(client) + .then(() => { + if (emitReady && !this.destroyed && !client.removed) this.emit('ready'); + }) + .catch((error: unknown) => { + if (!this.destroyed && !client.removed) this.emitError(error); + }); + } + + private cancelRefresh(client: (typeof this.clients)[number]): void { + client.refreshController?.abort(); + if (client.refreshTimeout) clearTimeout(client.refreshTimeout); + client.refreshTimeout = undefined; + + if (client.retryTimer) clearTimeout(client.retryTimer); + client.retryTimer = undefined; + const resolveRetry = client.retryWaitResolve; + client.retryWaitResolve = undefined; + resolveRetry?.(); + } + + private async waitForRefreshRetry( + client: (typeof this.clients)[number], + delay: number + ): Promise { + if (this.destroyed || client.removed) return; + + await new Promise((resolve) => { + let timer: NodeJS.Timeout; + const complete = (): void => { + if (client.retryTimer === timer) client.retryTimer = undefined; + if (client.retryWaitResolve === complete) client.retryWaitResolve = undefined; + resolve(); + }; + timer = setTimeout(complete, delay).unref(); + client.retryTimer = timer; + client.retryWaitResolve = complete; + }); + } + + private async awaitRefreshAbort(operation: Promise, signal: AbortSignal): Promise { + if (signal.aborted) throw new RefreshFailure('refresh aborted'); + + let abort: (() => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + abort = () => reject(new RefreshFailure('refresh aborted')); + signal.addEventListener('abort', abort, { once: true }); + }); + + try { + return await Promise.race([operation, aborted]); + } finally { + if (abort) signal.removeEventListener('abort', abort); + } + } + + private async fetchRateLimits( + client: (typeof this.clients)[number] + ): Promise { + let reason = 'network failure'; + + for (let attempt = 0; attempt < REFRESH_MAX_ATTEMPTS; attempt += 1) { + if (this.destroyed || client.removed) return undefined; + + const controller = new AbortController(); + let timedOut = false; + client.refreshController = controller; + client.refreshTimeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, this.options.requestTimeout).unref(); + + try { + const response = await this.awaitRefreshAbort( + fetch('https://api.github.com/rate_limit', { + headers: { + authorization: `token ${client.token}`, + 'user-agent': 'GitHub API Proxy Server (@hsborges/github-proxy-server)' + }, + signal: controller.signal + }), + controller.signal + ); + + if (response.status < 200 || response.status >= 300) { + throw new RefreshFailure(`HTTP response ${response.status}`); + } + + let body: unknown; + try { + body = await this.awaitRefreshAbort(response.json(), controller.signal); + } catch { + throw new RefreshFailure('malformed JSON'); + } + + if (!isRecord(body) || !('resources' in body)) { + throw new RefreshFailure('invalid resource shape'); + } + return parseRateLimitResources(body.resources); + } catch (error) { + if (this.destroyed || client.removed) return undefined; + reason = timedOut + ? `attempt timed out after ${this.options.requestTimeout}ms` + : error instanceof RefreshFailure + ? error.message + : 'network failure'; + if (attempt + 1 < REFRESH_MAX_ATTEMPTS) { + const delay = Math.min(REFRESH_BACKOFF_BASE_MS * 2 ** attempt, REFRESH_BACKOFF_MAX_MS); + await this.waitForRefreshRetry(client, delay); + } + } finally { + if (client.refreshController === controller) client.refreshController = undefined; + if (client.refreshTimeout) clearTimeout(client.refreshTimeout); + if (client.refreshController === undefined) client.refreshTimeout = undefined; + } + } + + throw new Error( + `Rate-limit refresh failed for token ending ${client.token.slice(-4)} after ${REFRESH_MAX_ATTEMPTS} attempts: ${reason}` + ); + } + + private refreshClient(client: (typeof this.clients)[number]): Promise { + if (this.destroyed || client.removed) return Promise.resolve(); + if (client.refreshPromise) return client.refreshPromise; + + const refreshPromise = (async () => { + try { + const resources = await this.fetchRateLimits(client); + if (!resources || this.destroyed || client.removed) return; + + for (const worker of [client.core, client.search, client.code_search, client.graphql]) { + worker.applyRateLimit(resources[worker.defaults.resource]); + } + } catch (error) { + if (!this.destroyed && !client.removed) throw error; + } + })(); + client.refreshPromise = refreshPromise; + void refreshPromise.then( + () => { + if (client.refreshPromise === refreshPromise) client.refreshPromise = undefined; + }, + () => { + if (client.refreshPromise === refreshPromise) client.refreshPromise = undefined; + } + ); + return refreshPromise; } private async destroyClient(client: (typeof this.clients)[number]): Promise { const workers = [client.core, client.search, client.code_search, client.graphql]; + client.removed = true; for (const timer of client.refreshTimers) clearInterval(timer); + this.cancelRefresh(client); - const results = await Promise.allSettled(workers.map((worker) => worker.destroy())); + const results = await Promise.allSettled([ + client.refreshPromise ?? Promise.resolve(), + ...workers.map((worker) => worker.destroy()) + ]); const errors = results .filter((result): result is PromiseRejectedResult => result.status === 'rejected') .map((result) => result.reason); @@ -683,6 +851,7 @@ export default class ProxyRouter extends EventEmitter { if (index === -1) return Promise.resolve(); const client = this.clients.splice(index, 1)[0]; + client.removed = true; const removal = this.destroyClient(client); this.removals.add(removal); void removal.then( @@ -696,15 +865,12 @@ export default class ProxyRouter extends EventEmitter { if (this.destroyed) return; const clients = [...this.clients]; - await Promise.all( - clients.map((client) => - Promise.all( - [client.core, client.search, client.code_search, client.graphql].map((worker) => - worker.refreshRateLimits() - ) - ) - ) - ); + try { + await Promise.all(clients.map((client) => this.refreshClient(client))); + } catch (error) { + if (!this.destroyed) this.emitError(error); + throw error; + } if (!this.destroyed) this.emit('ready'); } From 8cc06238f696ed45175dd7d9397c90f6a814ec3b Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Fri, 28 Aug 2026 23:27:41 -0400 Subject: [PATCH 08/26] fix(proxy): make cancellation state-aware --- ...0-state-aware-proxy-errors-cancellation.md | 27 ++- docs/enhancements/README.md | 2 +- src/proxy-client.spec.ts | 114 +++++++++ src/proxy-client.ts | 143 +++++++++++- src/router.spec.ts | 219 ++++++++++++++++++ src/router.ts | 167 ++++++++----- 6 files changed, 597 insertions(+), 75 deletions(-) diff --git a/docs/enhancements/10-state-aware-proxy-errors-cancellation.md b/docs/enhancements/10-state-aware-proxy-errors-cancellation.md index 888dca9..fa919be 100644 --- a/docs/enhancements/10-state-aware-proxy-errors-cancellation.md +++ b/docs/enhancements/10-state-aware-proxy-errors-cancellation.md @@ -1,13 +1,13 @@ --- id: 10 title: Make proxy errors and cancellation state-aware -status: planned +status: verified risk: moderate urgency: high scope: proxy error responses, sockets, abort signals, and cancellation --- -**Status:** Planned; not yet implemented. +**Status:** Verified; review and final validation are complete. ## Problem @@ -36,6 +36,17 @@ controllers and which response/socket states permit an error response. Make cancellation state explicit, attach the active controller to the request context, guard writes with the correct response/request state, and avoid destroying a response after a completed send. +The router owns one controller for each active request and passes it to `ProxyClient`; request, +socket, and response-close events abort that controller. Proxy operations race request-body reads, +upstream reads, and backpressure waits against the signal. Error handling sends `502` only when the +request is connected and no response has started, destroys only a connected partial response, and +leaves completed or disconnected responses untouched. Existing request timeout behavior remains the +only timeout boundary coordinated here; no item 13 body, queue, overload, or lifetime limits are +introduced. +Worker destruction aborts request-owned controllers before response teardown and task settlement. +Response streaming rechecks cancellation and downstream state before status/header mutation, each +chunk, drain wait, and terminal `end`; readers are best-effort cancelled and released on abort. + ## Validation plan Extend tests for timeout, upstream connection failure, client disconnect, completed response, and @@ -46,3 +57,15 @@ partial response cases. Confirm no duplicate response writes and that in-flight - Error handling is conditional on accurate request/response state. - Cancellation reaches the active upstream operation. - Existing and new timeout/disconnect regression tests pass. + +## Implementation evidence + +- `src/router.ts` attaches active request controllers, aborts on disconnect, and uses + `headersSent`, `writableEnded`, `destroyed`, and request/socket state before writing or destroying; + worker teardown aborts active requests before settling them. +- `src/proxy-client.ts` accepts the router-owned controller and makes body, upstream, and stream + operations cancellation-aware, with response-state guards and drain-listener cleanup. +- `src/router.spec.ts` covers timeout, upstream failure, client disconnect, completed responses, + partial responses, and cancellation propagation. +- Final validation: focused proxy/router tests (70 passed), full test suite (163 passed), Yarn lint, + TypeScript, and production build passed. diff --git a/docs/enhancements/README.md b/docs/enhancements/README.md index 727cfa9..b0b86a4 100644 --- a/docs/enhancements/README.md +++ b/docs/enhancements/README.md @@ -2,7 +2,7 @@ This dossier records the project-improvement recommendations from the prior analysis. It expands the earlier grouped analysis into 15 actionable work items, ordered from lowest to highest implementation -risk. Recommendations 01 through 09 are **verified**; recommendations 10 through 15 are currently +risk. Recommendations 01 through 10 are **verified**; recommendations 11 through 15 are currently **planned**. ## Project baseline diff --git a/src/proxy-client.spec.ts b/src/proxy-client.spec.ts index 6f6ab48..d8c9d16 100644 --- a/src/proxy-client.spec.ts +++ b/src/proxy-client.spec.ts @@ -297,6 +297,120 @@ describe('ProxyClient', () => { }); }); + test('should cancel an active upstream request with the caller controller', async () => { + const controller = new AbortController(); + scope + .get('/cancel') + .delay(TIMEOUT * 2) + .reply(StatusCodes.OK); + + const { req, res } = createMockRequestResponse('GET', '/cancel'); + const proxy = client.proxy(req, res, { abortController: controller }); + controller.abort(); + + await expect(proxy).rejects.toMatchObject({ code: 'ETIMEDOUT' }); + }); + + test('should not mutate the response after cancellation during onResponse', async () => { + const controller = new AbortController(); + let releaseResponse!: () => void; + const responseHandling = new Promise((resolve) => { + releaseResponse = resolve; + }); + let onResponseStarted!: () => void; + const responseStarted = new Promise((resolve) => { + onResponseStarted = resolve; + }); + scope.get('/cancel-on-response').reply(StatusCodes.OK); + + const { req, res } = createMockRequestResponse('GET', '/cancel-on-response'); + const proxy = client.proxy(req, res, { + abortController: controller, + onResponse: async () => { + onResponseStarted(); + await responseHandling; + } + }); + + await responseStarted; + controller.abort(); + releaseResponse(); + + await expect(proxy).rejects.toMatchObject({ code: 'ETIMEDOUT' }); + expect(res.statusCode).toBe(0); + expect(res.writableFinished).toBe(false); + }); + + test('should stop streamed writes after downstream cancellation', async () => { + const controller = new AbortController(); + const read = vi.fn(() => new Promise>(() => undefined)); + const cancel = vi.fn().mockResolvedValue(undefined); + const releaseLock = vi.fn(); + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + status: StatusCodes.OK, + statusText: 'OK', + headers: new Headers(), + body: { getReader: () => ({ read, cancel, releaseLock }) } + } as unknown as globalThis.Response); + const { req, res } = createMockRequestResponse('GET', '/stream-cancel'); + + try { + const proxy = client.proxy(req, res, { abortController: controller }); + await vi.waitFor(() => expect(read).toHaveBeenCalled()); + controller.abort(); + + await expect(proxy).rejects.toMatchObject({ code: 'ETIMEDOUT' }); + expect(res.writableFinished).toBe(false); + expect(res.write).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalled(); + expect(releaseLock).toHaveBeenCalled(); + } finally { + fetch.mockRestore(); + } + }); + + test('should remove drain listeners when backpressure is cancelled', async () => { + const controller = new AbortController(); + let drain!: () => void; + const read = vi + .fn() + .mockResolvedValueOnce({ done: false, value: new Uint8Array([1]) }) + .mockImplementation( + () => new Promise>(() => undefined) + ); + const cancel = vi.fn().mockResolvedValue(undefined); + const releaseLock = vi.fn(); + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + status: StatusCodes.OK, + statusText: 'OK', + headers: new Headers(), + body: { getReader: () => ({ read, cancel, releaseLock }) } + } as unknown as globalThis.Response); + const { req, res } = createMockRequestResponse('GET', '/backpressure-cancel'); + const removeListener = vi.fn(); + res.write = vi.fn(() => false); + res.once = vi.fn((event: string, callback: () => void) => { + if (event === 'drain') drain = callback; + return res; + }); + res.removeListener = removeListener; + + try { + const proxy = client.proxy(req, res, { abortController: controller }); + await vi.waitFor(() => expect(drain).toBeTypeOf('function')); + controller.abort(); + + await expect(proxy).rejects.toMatchObject({ code: 'ETIMEDOUT' }); + expect(res.writableFinished).toBe(false); + expect(removeListener).toHaveBeenCalledWith('drain', drain); + expect(removeListener).toHaveBeenCalledWith('close', expect.any(Function)); + expect(cancel).toHaveBeenCalled(); + expect(releaseLock).toHaveBeenCalled(); + } finally { + fetch.mockRestore(); + } + }); + test('should handle network errors', async () => { scope.get('/error').replyWithError(new Error('Network error')); diff --git a/src/proxy-client.ts b/src/proxy-client.ts index 1609b84..809c833 100644 --- a/src/proxy-client.ts +++ b/src/proxy-client.ts @@ -31,6 +31,7 @@ export class ProxyClient { res: ServerResponse, options?: { modifyHeaders?: (headers: Record) => Record; + abortController?: AbortController; onResponse?: (data: { status: number; statusText: string; @@ -38,7 +39,7 @@ export class ProxyClient { }) => void | Promise; } ): Promise { - const controller = new AbortController(); + const controller = options?.abortController ?? new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { @@ -71,7 +72,7 @@ export class ProxyClient { // Prepare request body if present let body: Buffer | undefined; if (req.method !== 'GET' && req.method !== 'HEAD') { - body = await this.readRequestBody(req); + body = await this.readRequestBody(req, controller.signal); } // Make the fetch request @@ -94,6 +95,7 @@ export class ProxyClient { // Call onResponse callback if provided (with mutable headers) if (options?.onResponse) { + if (!this.canMutateResponse(res, controller.signal)) return; await options.onResponse({ status: response.status, statusText: response.statusText, @@ -106,33 +108,63 @@ export class ProxyClient { delete responseHeaders['content-encoding']; delete responseHeaders['content-length']; // Also remove as length changes after decompression + if (!this.canMutateResponse(res, controller.signal)) return; + // Copy response status res.statusCode = response.status; + if (!this.canMutateResponse(res, controller.signal)) return; res.statusMessage = response.statusText; // Copy modified response headers for (const [key, value] of Object.entries(responseHeaders)) { + if (!this.canMutateResponse(res, controller.signal)) return; res.setHeader(key, value); } // Stream response body if (response.body) { + if (!this.canWriteResponse(res, controller.signal)) return; const reader = response.body.getReader(); try { while (true) { - const { done, value } = await reader.read(); - if (done) break; + if (!this.canWriteResponse(res, controller.signal)) { + void reader.cancel().catch(() => undefined); + return; + } + const { done, value } = await this.awaitAbort(reader.read(), controller.signal); + if (done) { + if (this.canWriteResponse(res, controller.signal)) res.end(); + return; + } + if (!this.canWriteResponse(res, controller.signal)) { + void reader.cancel().catch(() => undefined); + return; + } if (!res.write(value)) { // Backpressure: wait for drain event - await new Promise((resolve) => res.once('drain', resolve)); + if (!this.canWriteResponse(res, controller.signal)) { + void reader.cancel().catch(() => undefined); + return; + } + const drained = await this.waitForDrain(res, controller.signal); + if (!drained) { + void reader.cancel().catch(() => undefined); + return; + } } } - res.end(); } catch (error) { - reader.releaseLock(); + void reader.cancel().catch(() => undefined); throw error; + } finally { + try { + reader.releaseLock(); + } catch { + // The reader may already be released by the underlying stream. + } } } else { + if (!this.canWriteResponse(res, controller.signal)) return; res.end(); } } catch (error) { @@ -152,12 +184,101 @@ export class ProxyClient { /** * Read the full request body into a buffer */ - private readRequestBody(req: IncomingMessage): Promise { + private readRequestBody(req: IncomingMessage, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; - req.on('data', (chunk) => chunks.push(chunk)); - req.on('end', () => resolve(Buffer.concat(chunks))); - req.on('error', reject); + const cleanup = (): void => { + req.removeListener?.('data', onData); + req.removeListener?.('end', onEnd); + req.removeListener?.('error', onError); + signal.removeEventListener('abort', onAbort); + }; + const onData = (chunk: Buffer): void => { + chunks.push(chunk); + }; + const onEnd = (): void => { + cleanup(); + resolve(Buffer.concat(chunks)); + }; + const onError = (error: Error): void => { + cleanup(); + reject(error); + }; + const onAbort = (): void => { + cleanup(); + reject(new DOMException('The operation was aborted', 'AbortError')); + }; + + if (signal.aborted) { + onAbort(); + return; + } + + req.on('data', onData); + req.on('end', onEnd); + req.on('error', onError); + signal.addEventListener('abort', onAbort, { once: true }); + }); + } + + private async awaitAbort(operation: Promise, signal: AbortSignal): Promise { + if (signal.aborted) throw new DOMException('The operation was aborted', 'AbortError'); + + let abort: (() => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + abort = () => reject(new DOMException('The operation was aborted', 'AbortError')); + signal.addEventListener('abort', abort, { once: true }); + }); + + try { + return await Promise.race([operation, aborted]); + } finally { + if (abort) signal.removeEventListener('abort', abort); + } + } + + private canWriteResponse(res: ServerResponse, signal: AbortSignal): boolean { + if (signal.aborted) throw new DOMException('The operation was aborted', 'AbortError'); + return !res.destroyed && !res.writableEnded; + } + + private canMutateResponse(res: ServerResponse, signal: AbortSignal): boolean { + return this.canWriteResponse(res, signal) && !res.headersSent; + } + + private waitForDrain(res: ServerResponse, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const cleanup = (): void => { + res.removeListener?.('drain', onDrain); + res.removeListener?.('close', onClose); + signal.removeEventListener('abort', onAbort); + }; + const onDrain = (): void => { + cleanup(); + resolve(true); + }; + const onClose = (): void => { + cleanup(); + resolve(false); + }; + const onAbort = (): void => { + cleanup(); + reject(new DOMException('The operation was aborted', 'AbortError')); + }; + + if (signal.aborted) { + onAbort(); + return; + } + + if (res.destroyed || res.writableEnded) { + resolve(false); + return; + } + + res.once('drain', onDrain); + res.once('close', onClose); + signal.addEventListener('abort', onAbort, { once: true }); }); } } diff --git a/src/router.spec.ts b/src/router.spec.ts index 1346564..a19fdd2 100644 --- a/src/router.spec.ts +++ b/src/router.spec.ts @@ -1,3 +1,5 @@ +import EventEmitter from 'node:events'; + import express, { type Express, type Request, type Response } from 'express'; import { StatusCodes } from 'http-status-codes'; import repeat from 'lodash/repeat.js'; @@ -212,6 +214,191 @@ describe('Middleware constructor and methods', () => { fetch.mockRestore(); } }); + + test('should not write or destroy an already-completed response after a proxy error', async () => { + const middleware = new Middleware([FAKE_TOKEN]); + const worker = ( + middleware as unknown as { + workersByResource: { + core: Array<{ + remaining: number; + reset: number; + proxy: { proxy: (...args: never[]) => Promise }; + schedule: (req: Request, res: Response) => Promise; + }>; + }; + } + ).workersByResource.core[0]; + worker.remaining = 5000; + worker.reset = 0; + const { req, res, send, status, destroy } = createStateAwareRequestResponse({ + headersSent: true, + writableEnded: true, + destroyed: false + }); + const proxy = vi.spyOn(worker.proxy, 'proxy').mockRejectedValue(new Error('upstream failed')); + + try { + await worker.schedule(req, res); + expect(status).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + expect(destroy).not.toHaveBeenCalled(); + } finally { + proxy.mockRestore(); + await middleware.destroy(); + } + }); + + test('should destroy a connected partial response without sending a duplicate error', async () => { + const middleware = new Middleware([FAKE_TOKEN]); + const worker = ( + middleware as unknown as { + workersByResource: { + core: Array<{ + remaining: number; + reset: number; + proxy: { proxy: (...args: never[]) => Promise }; + schedule: (req: Request, res: Response) => Promise; + }>; + }; + } + ).workersByResource.core[0]; + worker.remaining = 5000; + worker.reset = 0; + const { req, res, send, status, destroy } = createStateAwareRequestResponse({ + headersSent: true, + writableEnded: false, + destroyed: false + }); + const proxy = vi.spyOn(worker.proxy, 'proxy').mockRejectedValue(new Error('upstream failed')); + + try { + await worker.schedule(req, res); + expect(status).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + expect(destroy).toHaveBeenCalledTimes(1); + } finally { + proxy.mockRestore(); + await middleware.destroy(); + } + }); + + test('should propagate request cancellation to the active upstream operation', async () => { + const middleware = new Middleware([FAKE_TOKEN]); + const worker = ( + middleware as unknown as { + workersByResource: { + core: Array<{ + remaining: number; + reset: number; + proxy: { + proxy: ( + req: Request, + res: Response, + options?: { abortController?: AbortController } + ) => Promise; + }; + schedule: (req: Request, res: Response) => Promise; + }>; + }; + } + ).workersByResource.core[0]; + worker.remaining = 5000; + worker.reset = 0; + const { req, res, status, destroy } = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + let signal: AbortSignal | undefined; + let started!: () => void; + const operationStarted = new Promise((resolve) => { + started = resolve; + }); + const proxy = vi.spyOn(worker.proxy, 'proxy').mockImplementation((_req, _res, options) => { + signal = options?.abortController?.signal; + started(); + return new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => reject(new DOMException('The operation was aborted', 'AbortError')), + { once: true } + ); + }); + }); + + try { + const scheduled = worker.schedule(req, res); + await operationStarted; + req.aborted = true; + req.emit('aborted'); + await scheduled; + expect(signal?.aborted).toBe(true); + expect(status).not.toHaveBeenCalled(); + expect(destroy).not.toHaveBeenCalled(); + } finally { + proxy.mockRestore(); + await middleware.destroy(); + } + }); + + test('should abort the active proxy before settling a destroyed worker task', async () => { + const middleware = new Middleware([FAKE_TOKEN]); + const worker = ( + middleware as unknown as { + workersByResource: { + core: Array<{ + remaining: number; + reset: number; + proxy: { + proxy: ( + req: Request, + res: Response, + options?: { abortController?: AbortController } + ) => Promise; + }; + schedule: (req: Request, res: Response) => Promise; + destroy: () => Promise; + }>; + }; + } + ).workersByResource.core[0]; + worker.remaining = 5000; + worker.reset = 0; + const { req, res, status, send } = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + let signal: AbortSignal | undefined; + let started!: () => void; + const operationStarted = new Promise((resolve) => { + started = resolve; + }); + const proxy = vi.spyOn(worker.proxy, 'proxy').mockImplementation((_req, _res, options) => { + signal = options?.abortController?.signal; + started(); + return new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => reject(new DOMException('The operation was aborted', 'AbortError')), + { once: true } + ); + }); + }); + + try { + const scheduled = worker.schedule(req, res); + await operationStarted; + await Promise.all([scheduled, worker.destroy()]); + expect(signal?.aborted).toBe(true); + expect(status).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + } finally { + proxy.mockRestore(); + await middleware.destroy(); + } + }); }); describe('Rate-limit refresh policy', () => { @@ -436,6 +623,38 @@ describe('Rate-limit refresh policy', () => { }); }); +function createStateAwareRequestResponse(state: { + headersSent: boolean; + writableEnded: boolean; + destroyed: boolean; +}): { + req: Request & { aborted: boolean }; + res: Response; + send: ReturnType; + status: ReturnType; + destroy: ReturnType; +} { + const socket = Object.assign(new EventEmitter(), { destroyed: false }); + const req = Object.assign(new EventEmitter(), { + method: 'GET', + url: '/', + headers: { host: 'localhost:3000' }, + socket, + aborted: false, + destroyed: false + }) as unknown as Request & { aborted: boolean }; + const send = vi.fn(); + const status = vi.fn(() => ({ send })); + const destroy = vi.fn(); + const res = Object.assign(new EventEmitter(), { + ...state, + status, + destroy + }) as unknown as Response; + + return { req, res, send, status, destroy }; +} + describe('Middleware core', () => { let scope: nock.Scope; let middleware: Middleware; diff --git a/src/router.ts b/src/router.ts index 1faa1bb..b449ce7 100644 --- a/src/router.ts +++ b/src/router.ts @@ -136,7 +136,8 @@ export function validateGitHubToken(token: unknown): asserts token is string { } } -type ScheduledRequest = QueuedRequest & { +type ScheduledRequest = Omit & { + req: ExtendedRequest; settle: () => void; }; @@ -152,6 +153,19 @@ function terminateResponse(res: Response): void { if (!res.writableEnded && !res.destroyed) res.destroy(); } +function requestDisconnected(req: ExtendedRequest): boolean { + return Boolean(req.destroyed || req.aborted || req.socket?.destroyed); +} + +function responseUnavailable(res: Response): boolean { + return Boolean(res.headersSent || res.writableEnded || res.destroyed); +} + +function terminatePartialResponse(req: ExtendedRequest, res: Response): void { + if (requestDisconnected(req) || res.writableEnded || res.destroyed || !res.headersSent) return; + res.destroy(); +} + export interface WorkerLogger { resource: APIResources; token: string; @@ -273,12 +287,18 @@ class ProxyWorker extends EventEmitter { }); const scheduledRequest = { req, res, settle: settleTask }; this.scheduledRequests.add(scheduledRequest); + let activeAbortController: AbortController | undefined; try { void this.queue .add(async () => { try { - if (this.destroyed || req.socket.destroyed) { + if ( + this.destroyed || + requestDisconnected(req) || + res.writableEnded || + res.destroyed + ) { this.log(); return; } @@ -295,69 +315,91 @@ class ProxyWorker extends EventEmitter { req.startedAt = new Date(); this.remaining -= 1; - const hasAuthorization = opts.overrideAuthorization - ? false - : !!req.headers.authorization; - - await this.proxy.proxy(req, res, { - modifyHeaders: (headers) => { - if (!hasAuthorization) headers.authorization = `token ${token}`; - return headers; - }, - onResponse: async (data) => { - if (this.destroyed) return; - - const linkHeader = data.headers.link; - if (linkHeader && req.headers.host) { - data.headers.link = linkHeader.replaceAll( - 'https://api.github.com', - `http://${req.headers.host}` - ); - } - - // Only update rate limits if we injected the token - if (!hasAuthorization) { - const status = data.status.toString(); - const rateLimitRemaining = data.headers['x-ratelimit-remaining']; - const rateLimitReset = data.headers['x-ratelimit-reset']; - const rateLimitLimit = data.headers['x-ratelimit-limit']; - - if (rateLimitRemaining) { - this.updateLimits({ - status, - 'x-ratelimit-remaining': rateLimitRemaining, - 'x-ratelimit-reset': rateLimitReset || '', - 'x-ratelimit-limit': rateLimitLimit || '' - }); + const abortController = new AbortController(); + activeAbortController = abortController; + req.abortController = abortController; + const abortRequest = (): void => abortController.abort(); + const abortResponse = (): void => { + if (!res.writableEnded) abortController.abort(); + }; + req.once?.('aborted', abortRequest); + req.once?.('error', abortRequest); + req.socket?.once?.('close', abortRequest); + res.once?.('close', abortResponse); + + try { + const hasAuthorization = opts.overrideAuthorization + ? false + : !!req.headers.authorization; + + await this.proxy.proxy(req, res, { + abortController, + modifyHeaders: (headers) => { + if (!hasAuthorization) headers.authorization = `token ${token}`; + return headers; + }, + onResponse: async (data) => { + if (this.destroyed) return; + + const linkHeader = data.headers.link; + if (linkHeader && req.headers.host) { + data.headers.link = linkHeader.replaceAll( + 'https://api.github.com', + `http://${req.headers.host}` + ); } - this.timeBudget -= Date.now() - (req.startedAt?.getTime() || 1000); + // Only update rate limits if we injected the token + if (!hasAuthorization) { + const status = data.status.toString(); + const rateLimitRemaining = data.headers['x-ratelimit-remaining']; + const rateLimitReset = data.headers['x-ratelimit-reset']; + const rateLimitLimit = data.headers['x-ratelimit-limit']; + + if (rateLimitRemaining) { + this.updateLimits({ + status, + 'x-ratelimit-remaining': rateLimitRemaining, + 'x-ratelimit-reset': rateLimitReset || '', + 'x-ratelimit-limit': rateLimitLimit || '' + }); + } + + this.timeBudget -= Date.now() - (req.startedAt?.getTime() || 1000); - this.log(data.status, req.startedAt); + this.log(data.status, req.startedAt); - // Remove rate limit and scope headers - for (const key of Object.keys(data.headers)) { - if (/(ratelimit|scope)/i.test(key)) { - delete data.headers[key]; + // Remove rate limit and scope headers + for (const key of Object.keys(data.headers)) { + if (/(ratelimit|scope)/i.test(key)) { + delete data.headers[key]; + } } - } - const exposeHeaders = data.headers['access-control-expose-headers']; - if (exposeHeaders) { - const filtered = exposeHeaders - .split(', ') - .filter((header) => !/(ratelimit|scope)/i.test(header)) - .join(', '); - if (filtered) { - data.headers['access-control-expose-headers'] = filtered; - } else { - delete data.headers['access-control-expose-headers']; + const exposeHeaders = data.headers['access-control-expose-headers']; + if (exposeHeaders) { + const filtered = exposeHeaders + .split(', ') + .filter((header) => !/(ratelimit|scope)/i.test(header)) + .join(', '); + if (filtered) { + data.headers['access-control-expose-headers'] = filtered; + } else { + delete data.headers['access-control-expose-headers']; + } } } } - } - }); + }); + } finally { + req.removeListener?.('aborted', abortRequest); + req.removeListener?.('error', abortRequest); + req.socket?.removeListener?.('close', abortRequest); + res.removeListener?.('close', abortResponse); + if (req.abortController === abortController) delete req.abortController; + } } catch (error) { + activeAbortController?.abort(); const err = error as Error & { code?: string }; const errorCode = err.code || err.message; this.log( @@ -365,13 +407,15 @@ class ProxyWorker extends EventEmitter { req.startedAt ); - if (!req.socket.destroyed && !req.socket.writableFinished && !res.destroyed) { - res.status(StatusCodes.BAD_GATEWAY).send(); + if (!this.destroyed) { + if (!requestDisconnected(req) && !responseUnavailable(res)) { + res.status(StatusCodes.BAD_GATEWAY).send(); + } else { + terminatePartialResponse(req, res); + } } - - req.abortController?.abort(); - terminateResponse(res); } finally { + activeAbortController = undefined; this.scheduledRequests.delete(scheduledRequest); settleTask(); } @@ -477,8 +521,9 @@ class ProxyWorker extends EventEmitter { this.destroyPromise = (async () => { const errors: unknown[] = []; - for (const { res, settle } of this.scheduledRequests) { + for (const { req, res, settle } of this.scheduledRequests) { try { + req.abortController?.abort(); terminateResponse(res); } catch (error) { errors.push(error); From 7646ea560bf9f1ccbf9b117f0869d8e17d80e2b8 Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Fri, 28 Aug 2026 23:38:32 -0400 Subject: [PATCH 09/26] fix(monitoring): isolate public health endpoint --- README.md | 11 +++--- .../11-swagger-stats-monitoring.md | 21 +++++++++-- docs/enhancements/README.md | 2 +- src/cli.ts | 2 +- src/server.spec.ts | 32 ++++++++++++++--- src/server.ts | 35 ++++++++++++++----- 6 files changed, 83 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 4c40c68..7f48c7b 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,12 @@ Then make authenticated requests: curl -s -u myuser:mypass http://localhost:3000/users/gittrends-app 2>&1 ``` -**Note:** The `/status` monitoring endpoint and its nested `/status/*` routes are excluded from -authentication to allow health checks. Similarly prefixed routes such as `/status-other` still -require authentication. +**Note:** `/status` and `/status/` are small public health endpoints and return `{"status":"ok"}`. +Unknown `/status/*` paths return `404` and never fall through to the proxy. Detailed swagger-stats +monitoring, when enabled, is isolated under `/metrics` (`/metrics/stats` and `/metrics/metrics`) +and is protected by Basic Authentication whenever proxy authentication is configured. Monitoring +paths return `404` when disabled; similarly prefixed routes such as `/status-other` still require +authentication. ### Deployment and TLS @@ -130,7 +133,7 @@ Options: --no-override-authorization By default, the authorization header is overrided with a configured token --auth-username [username] Proxy authentication username (env: GPS_AUTH_USERNAME) --auth-password [password] Proxy authentication password (env: GPS_AUTH_PASSWORD) - --no-status-monitor Disable requests monitoring on /status + --no-status-monitor Disable requests monitoring on /metrics -v, --version output the current version -h, --help display help for command ``` diff --git a/docs/enhancements/11-swagger-stats-monitoring.md b/docs/enhancements/11-swagger-stats-monitoring.md index c37076b..b2e3539 100644 --- a/docs/enhancements/11-swagger-stats-monitoring.md +++ b/docs/enhancements/11-swagger-stats-monitoring.md @@ -1,13 +1,13 @@ --- id: 11 title: Replace or isolate public swagger-stats monitoring -status: planned +status: verified risk: moderate urgency: normal scope: monitoring middleware, public status surface, and health checks --- -**Status:** Planned; not yet implemented. +**Status:** Verified; review and final validation are complete. ## Problem @@ -37,6 +37,13 @@ health behavior and determine whether swagger-stats remains an approved dependen Separate liveness/readiness behavior from detailed monitoring if needed, restrict monitoring access to the intended trust boundary, and update dependency and README guidance consistently. +The selected policy keeps only `GET /status` and `GET /status/` public, with a small JSON health +response. Unknown `/status/*` paths return `404` before proxy routing. swagger-stats remains an +intentional optional runtime dependency and, when enabled, uses the isolated `/metrics` namespace +for its UI, stats, metrics, and logout paths. The existing Basic Auth middleware protects all +metrics paths when credentials are configured. When monitoring is disabled, `/metrics` is reserved +and returns `404`; the Docker health check remains on `/status`. + ## Validation plan Test enabled and disabled monitoring, authenticated and unauthenticated status/metrics access, and @@ -47,3 +54,13 @@ the Docker health check. Verify the selected monitoring contract without exposin - Monitoring exposure and authentication policy are explicit. - Health checks continue to work. - Dependency, route, documentation, and regression evidence support the selected design. + +## Implementation evidence + +- `src/server.ts` provides the public health routes, blocks unknown `/status/*` paths, and configures + swagger-stats under `/metrics` only when monitoring is enabled. +- `src/server.spec.ts` covers enabled/disabled monitoring, both public health forms, protected and + authenticated metrics, status lookalikes, and the `/status` Docker health contract. +- `README.md` documents the exposure and authentication policy. +- Final validation: focused server tests (28 passed), full test suite (165 passed), Yarn lint, + TypeScript, production build, Docker image build, and the built-container health check passed. diff --git a/docs/enhancements/README.md b/docs/enhancements/README.md index b0b86a4..5ae1a12 100644 --- a/docs/enhancements/README.md +++ b/docs/enhancements/README.md @@ -2,7 +2,7 @@ This dossier records the project-improvement recommendations from the prior analysis. It expands the earlier grouped analysis into 15 actionable work items, ordered from lowest to highest implementation -risk. Recommendations 01 through 10 are **verified**; recommendations 11 through 15 are currently +risk. Recommendations 01 through 11 are **verified**; recommendations 12 through 15 are currently **planned**. ## Project baseline diff --git a/src/cli.ts b/src/cli.ts index b158d88..db99615 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -92,7 +92,7 @@ export function createCli(): Command { 'GPS_AUTH_PASSWORD' ) ) - .addOption(new Option('--no-status-monitor', 'Disable requests monitoring on /status')) + .addOption(new Option('--no-status-monitor', 'Disable requests monitoring on /metrics')) .version(packageJson.version || '?', '-v, --version', 'output the current version') .action(async (options) => { let auth: CliOpts['auth']; diff --git a/src/server.spec.ts b/src/server.spec.ts index 87e4bc0..2041051 100644 --- a/src/server.spec.ts +++ b/src/server.spec.ts @@ -143,6 +143,15 @@ describe('Test create proxy server', () => { await request(app).post('/graphql').expect(StatusCodes.OK); }); + test('it should expose only the public health contract when monitoring is disabled', async () => { + const app = createTestApp({ ...params, statusMonitor: false }); + + await request(app).get('/status').expect(StatusCodes.OK).expect({ status: 'ok' }); + await request(app).get('/status/').expect(StatusCodes.OK).expect({ status: 'ok' }); + await request(app).get('/status/unknown').expect(StatusCodes.NOT_FOUND); + await request(app).get('/metrics').expect(StatusCodes.NOT_FOUND); + }); + test('it should emit logs when enabled', async () => { const app = createTestApp({ ...params, silent: false }); @@ -262,15 +271,30 @@ describe('Test proxy authentication', () => { test('it should allow access to /status without authentication', async () => { const app = createTestApp({ ...params, statusMonitor: true }); - // /status endpoint redirects to /status/ - follow the redirect - const response = await request(app).get('/status').redirects(1); + const response = await request(app).get('/status'); expect(response.status).toBe(StatusCodes.OK); - - await request(app).get('/status/').expect(StatusCodes.OK); + expect(response.body).toEqual({ status: 'ok' }); + + await request(app).get('/status/').expect(StatusCodes.OK).expect({ status: 'ok' }); + await request(app).get('/status/unknown').expect(StatusCodes.NOT_FOUND); + await request(app).get('/metrics').expect(StatusCodes.UNAUTHORIZED); + await request(app).get('/metrics/stats').expect(StatusCodes.UNAUTHORIZED); + await request(app).get('/metrics/metrics').expect(StatusCodes.UNAUTHORIZED); + await request(app).get('/metrics/stats').auth('testuser', 'testpass').expect(StatusCodes.OK); + await request(app).get('/metrics/metrics').auth('testuser', 'testpass').expect(StatusCodes.OK); await request(app).get('/status-other').expect(StatusCodes.UNAUTHORIZED); await request(app).get('/status-metrics').expect(StatusCodes.UNAUTHORIZED); }); + test('it should keep disabled metrics unavailable while protecting the health namespace', async () => { + const app = createTestApp({ ...params, statusMonitor: false }); + + await request(app).get('/status').expect(StatusCodes.OK); + await request(app).get('/status/unknown').expect(StatusCodes.NOT_FOUND); + await request(app).get('/metrics').expect(StatusCodes.UNAUTHORIZED); + await request(app).get('/metrics').auth('testuser', 'testpass').expect(StatusCodes.NOT_FOUND); + }); + test('it should work with POST /graphql when authenticated', async () => { const app = createTestApp(params); await request(app).post('/graphql').auth('testuser', 'testpass').expect(StatusCodes.OK); diff --git a/src/server.ts b/src/server.ts index c3bdd00..9cf3307 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,7 @@ import compression from 'compression'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime.js'; import express, { type Express, type Request, type Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; import compact from 'lodash/compact.js'; import uniq from 'lodash/uniq.js'; import { pino } from 'pino'; @@ -126,9 +127,18 @@ export function createProxyServer(options: CliOpts): ProxyServer { }) ); + app.get(['/status', '/status/'], (_req: Request, res: Response) => { + res.status(StatusCodes.OK).json({ status: 'ok' }); + }); + + // Keep the public health namespace separate from proxy and monitoring routes. + app.use('/status', (_req: Request, res: Response) => { + res.status(StatusCodes.NOT_FOUND).send({ message: 'Endpoint not found' }); + }); + if (options.auth) { app.use((req: Request, res: Response, next) => { - if (req.path === '/status' || req.path.startsWith('/status/')) return next(); + if (req.path === '/status' || req.path === '/status/') return next(); const credentials = basicAuth(req); @@ -159,13 +169,22 @@ export function createProxyServer(options: CliOpts): ProxyServer { } if (options.statusMonitor) { - app.use( - swaggerStats.getMiddleware({ - name: 'GitHub Proxy Server', - version: process.env.npm_package_version, - uriPath: '/status' - }) - ); + const monitoringOptions = { + name: 'GitHub Proxy Server', + version: process.env.npm_package_version, + uriPath: '/metrics', + pathUI: '/metrics/ui', + pathDist: '/metrics/dist', + pathUX: '/metrics/ux', + pathStats: '/metrics/stats', + pathMetrics: '/metrics/metrics', + pathLogout: '/metrics/logout' + }; + app.use(swaggerStats.getMiddleware(monitoringOptions)); + } else { + app.use('/metrics', (_req: Request, res: Response) => { + res.status(StatusCodes.NOT_FOUND).send({ message: 'Monitoring disabled' }); + }); } const proxy = new ProxyRouter(tokens, { From 767cbdf05c1843fa96d69f0f3582c776815f1cf9 Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Sat, 29 Aug 2026 00:01:40 -0400 Subject: [PATCH 10/26] fix(proxy): enforce HTTP forwarding semantics --- README.md | 6 ++ .../12-http-forwarding-semantics.md | 24 ++++- docs/enhancements/README.md | 2 +- src/cli.spec.ts | 31 ++++++ src/cli.ts | 8 ++ src/proxy-client.spec.ts | 96 +++++++++++++++++ src/proxy-client.ts | 85 ++++++++++++--- src/router.spec.ts | 87 ++++++++++++++- src/router.ts | 101 +++++++++++++++--- src/server.ts | 4 +- 10 files changed, 412 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 7f48c7b..e2f28f7 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,11 @@ and is protected by Basic Authentication whenever proxy authentication is config paths return `404` when disabled; similarly prefixed routes such as `/status-other` still require authentication. +When the proxy is deployed behind a trusted public URL, set `--external-base-url` or +`GPS_EXTERNAL_BASE_URL` to an absolute `http://` or `https://` URL. Redirect and `Link` headers are +rewritten to that base; if it is omitted, upstream links are preserved unchanged and the untrusted +inbound `Host` header is never used for external URLs. + ### Deployment and TLS The server listens for plain HTTP and binds to all interfaces when started by the CLI. Do not expose @@ -129,6 +134,7 @@ Options: --request-timeout [timeout] Request timeout (ms) (default: 30000, env: GPS_REQUEST_TIMEOUT) --min-remaining Stop using token on a minimum of (default: 100, env: GPS_MIN_REMAINING) --time-budget-multiplier [multiplier] Time budget multiplier (>= 1.0) (default: 1, env: GPS_TIME_BUDGET_MULTIPLIER) + --external-base-url Trusted external HTTP(S) base URL (env: GPS_EXTERNAL_BASE_URL) --silent Dont show requests outputs (env: GPS_SILENT) --no-override-authorization By default, the authorization header is overrided with a configured token --auth-username [username] Proxy authentication username (env: GPS_AUTH_USERNAME) diff --git a/docs/enhancements/12-http-forwarding-semantics.md b/docs/enhancements/12-http-forwarding-semantics.md index 0cf56cb..6bdabee 100644 --- a/docs/enhancements/12-http-forwarding-semantics.md +++ b/docs/enhancements/12-http-forwarding-semantics.md @@ -1,13 +1,13 @@ --- id: 12 title: Correct HTTP forwarding semantics at the proxy boundary -status: planned +status: verified risk: moderate/high urgency: high scope: request/response headers, hop-by-hop semantics, links, and proxy trust --- -**Status:** Planned; not yet implemented. +**Status:** Verified; review and final validation are complete. ## Problem @@ -37,6 +37,14 @@ Filter connection-specific headers on both directions, preserve valid multi-valu derive link rewriting from an explicit trusted external scheme/host rather than an untrusted inbound value. +The proxy removes the standard hop-by-hop header set plus every header named by the inbound +`Connection` field on requests and responses, while preserving end-to-end metadata and repeated +`Set-Cookie` values. The optional `externalBaseUrl`/`--external-base-url`/ +`GPS_EXTERNAL_BASE_URL` setting accepts only absolute HTTP(S) URLs, rewrites GitHub `Location` and +`Link` values when configured, and leaves upstream links unchanged when omitted. Inbound `Host` is +never used for externally visible links; the item 05 forwarded-header overwrite policy remains in +place. + ## Validation plan Add integration tests for hop-by-hop headers, multi-value response headers, forwarded requests, and @@ -48,3 +56,15 @@ configuration. - Header forwarding follows the documented HTTP semantics. - Link rewriting uses the selected trusted external URL policy. - Integration/regression tests cover the proxy boundary and evidence is reported. + +## Implementation evidence + +- `src/proxy-client.ts` filters request/response hop-by-hop headers and preserves response header + arrays, including repeated cookies. +- `src/router.ts` validates the trusted external base URL and rewrites redirects and `Link` headers + only when explicitly configured. +- `src/server.ts`, `src/cli.ts`, and `README.md` expose and document the option consistently. +- Focused proxy, router, server, and CLI tests cover filtering, forwarded headers, redirects, link + rewriting, HTTP/HTTPS combinations, missing values, and invalid configuration. +- Final validation: focused forwarding tests (184 passed), full test suite (184 passed), Yarn lint, + TypeScript, and production build passed. diff --git a/docs/enhancements/README.md b/docs/enhancements/README.md index 5ae1a12..e39f8b8 100644 --- a/docs/enhancements/README.md +++ b/docs/enhancements/README.md @@ -2,7 +2,7 @@ This dossier records the project-improvement recommendations from the prior analysis. It expands the earlier grouped analysis into 15 actionable work items, ordered from lowest to highest implementation -risk. Recommendations 01 through 11 are **verified**; recommendations 12 through 15 are currently +risk. Recommendations 01 through 12 are **verified**; recommendations 13 through 15 are currently **planned**. ## Project baseline diff --git a/src/cli.spec.ts b/src/cli.spec.ts index c024d48..2d0f403 100644 --- a/src/cli.spec.ts +++ b/src/cli.spec.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { createAuthConfiguration, createCli, PARTIAL_AUTHENTICATION_ERROR } from './cli.js'; import { + parseExternalBaseUrl, parseMinRemaining, parsePort, parseRequestTimeout, @@ -245,6 +246,13 @@ describe('createCli command structure', () => { expect(statusMonitorOption).toBeDefined(); }); + test('should have trusted external base URL option', () => { + const externalBaseUrlOption = program.options.find((opt) => opt.long === '--external-base-url'); + expect(externalBaseUrlOption).toBeDefined(); + expect(externalBaseUrlOption?.parseArg).toBeDefined(); + expect(externalBaseUrlOption?.required).toBe(true); + }); + test('should have version option', () => { const versionOption = program.options.find((opt) => opt.long === '--version'); expect(versionOption).toBeDefined(); @@ -362,6 +370,29 @@ describe('CLI environment variables', () => { const authPasswordOption = program.options.find((opt) => opt.long === '--auth-password'); expect(authPasswordOption?.envVar).toBe('GPS_AUTH_PASSWORD'); }); + + test('should support GPS_EXTERNAL_BASE_URL environment variable', () => { + const program = createCli(); + const externalBaseUrlOption = program.options.find((opt) => opt.long === '--external-base-url'); + expect(externalBaseUrlOption?.envVar).toBe('GPS_EXTERNAL_BASE_URL'); + }); +}); + +describe('External base URL validation', () => { + test('should accept and normalize absolute HTTP(S) URLs', () => { + expect(parseExternalBaseUrl('http://proxy.example/base/')).toBe('http://proxy.example/base'); + expect(parseExternalBaseUrl('https://proxy.example')).toBe('https://proxy.example'); + expect(parseExternalBaseUrl(undefined)).toBeUndefined(); + }); + + test.each([ + 'ftp://proxy.example', + '//proxy.example', + 'not-a-url', + 'https://proxy.example/?x=1' + ])('should reject invalid external base URL %s', (value) => { + expect(() => parseExternalBaseUrl(value)).toThrow('Invalid externalBaseUrl'); + }); }); describe('Helper Functions - concatTokens', () => { diff --git a/src/cli.ts b/src/cli.ts index db99615..c43d475 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ import omitBy from 'lodash/omitBy.js'; import packageJson from '../package.json' with { type: 'json' }; import { + parseExternalBaseUrl, parseMinRemaining, parsePort, parseRequestTimeout, @@ -75,6 +76,11 @@ export function createCli(): Command { .default(1) .env('GPS_TIME_BUDGET_MULTIPLIER') ) + .addOption( + new Option('--external-base-url ', 'Trusted external HTTP(S) base URL') + .argParser(parseExternalBaseUrl) + .env('GPS_EXTERNAL_BASE_URL') + ) .addOption(new Option('--silent', 'Dont show requests outputs').env('GPS_SILENT')) .addOption( new Option( @@ -117,6 +123,7 @@ export function createCli(): Command { const requestTimeout = parseRequestTimeout(options.requestTimeout); const minRemaining = parseMinRemaining(options.minRemaining); const timeBudgetMultiplier = parseTimeBudgetMultiplier(options.timeBudgetMultiplier); + const externalBaseUrl = parseExternalBaseUrl(options.externalBaseUrl); const appOptions: CliOpts = { requestTimeout, @@ -125,6 +132,7 @@ export function createCli(): Command { tokens: tokens, minRemaining, timeBudgetMultiplier, + externalBaseUrl, statusMonitor: options.statusMonitor, auth }; diff --git a/src/proxy-client.spec.ts b/src/proxy-client.spec.ts index d8c9d16..ec68322 100644 --- a/src/proxy-client.spec.ts +++ b/src/proxy-client.spec.ts @@ -104,6 +104,72 @@ describe('ProxyClient', () => { expect(receivedHeaders['x-custom-header']).toBe('custom-value'); }); + test('should filter hop-by-hop request headers and connection tokens', async () => { + let receivedHeaders: Record = {}; + + scope.get('/hop-by-hop').reply(function () { + receivedHeaders = this.req.headers as Record; + return [StatusCodes.OK, 'ok']; + }); + + const { req, res } = createMockRequestResponse('GET', '/hop-by-hop', undefined, { + connection: 'keep-alive, x-request-hop', + 'keep-alive': 'timeout=5', + 'x-request-hop': 'remove-me', + te: 'trailers', + 'x-end-to-end': 'preserve-me' + }); + + await client.proxy(req, res); + + expect(receivedHeaders.connection).toBeUndefined(); + expect(receivedHeaders['keep-alive']).toBeUndefined(); + expect(receivedHeaders['x-request-hop']).toBeUndefined(); + expect(receivedHeaders.te).toBeUndefined(); + expect(receivedHeaders['x-end-to-end']).toBe('preserve-me'); + }); + + test('should preserve trusted forwarded headers named by inbound Connection', async () => { + let receivedHeaders: Record = {}; + + scope.get('/forwarded-connection').reply(function () { + receivedHeaders = this.req.headers as Record; + return [StatusCodes.OK, 'ok']; + }); + + const { req, res } = createMockRequestResponse('GET', '/forwarded-connection', undefined, { + connection: 'x-forwarded-for, x-forwarded-host, x-forwarded-proto', + 'x-forwarded-for': 'spoofed-for', + 'x-forwarded-host': 'spoofed-host', + 'x-forwarded-proto': 'spoofed-proto' + }); + + await client.proxy(req, res); + + expect(receivedHeaders['x-forwarded-for']).toBe('127.0.0.1'); + expect(receivedHeaders['x-forwarded-host']).toBe('localhost:3000'); + expect(receivedHeaders['x-forwarded-proto']).toBe('http'); + }); + + test('should preserve modified authorization despite an inbound Connection token', async () => { + let receivedHeaders: Record = {}; + + scope.get('/authorization').reply(function () { + receivedHeaders = this.req.headers as Record; + return [StatusCodes.OK, 'ok']; + }); + + const { req, res } = createMockRequestResponse('GET', '/authorization', undefined, { + connection: 'authorization' + }); + + await client.proxy(req, res, { + modifyHeaders: (headers) => ({ ...headers, authorization: 'token injected' }) + }); + + expect(receivedHeaders.authorization).toBe('token injected'); + }); + test('should add forwarded metadata from the immediate request', async () => { let receivedHeaders: Record = {}; @@ -255,6 +321,36 @@ describe('ProxyClient', () => { expect(res.getHeader('x-custom-header')).toBe('custom-value'); }); + test('should filter hop-by-hop response headers and preserve repeated cookies', async () => { + const headers = new Headers({ + connection: 'x-response-hop', + 'x-response-hop': 'remove-me', + 'x-end-to-end': 'preserve-me' + }); + Object.defineProperty(headers, 'getSetCookie', { + value: () => ['first=1; Path=/', 'second=2; Path=/'] + }); + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + status: StatusCodes.OK, + statusText: 'OK', + headers, + body: null + } as unknown as globalThis.Response); + + const { req, res } = createMockRequestResponse('GET', '/response-headers'); + + try { + await client.proxy(req, res); + } finally { + fetch.mockRestore(); + } + + expect(res.getHeader('connection')).toBeUndefined(); + expect(res.getHeader('x-response-hop')).toBeUndefined(); + expect(res.getHeader('x-end-to-end')).toBe('preserve-me'); + expect(res.getHeader('set-cookie')).toEqual(['first=1; Path=/', 'second=2; Path=/']); + }); + test('should handle empty response body', async () => { scope.get('/test').reply(StatusCodes.NO_CONTENT); diff --git a/src/proxy-client.ts b/src/proxy-client.ts index 809c833..693fb7a 100644 --- a/src/proxy-client.ts +++ b/src/proxy-client.ts @@ -3,6 +3,21 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import type { Dispatcher } from 'undici'; +export type ProxyHeaderValue = string | string[]; +export type ProxyResponseHeaders = Record; + +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'proxy-connection' +]); + export interface ProxyClientOptions { target: string; timeout: number; @@ -35,7 +50,7 @@ export class ProxyClient { onResponse?: (data: { status: number; statusText: string; - headers: Record; + headers: ProxyResponseHeaders; }) => void | Promise; } ): Promise { @@ -54,20 +69,32 @@ export class ProxyClient { } } + const connectionTokens = this.connectionTokens(headers); const forwardedHost = headers.host || ''; // Remove host header to avoid conflicts delete headers.host; // Apply header modifications if provided - const modifiedHeaders = options?.modifyHeaders ? options.modifyHeaders(headers) : headers; + const filteredHeaders = this.filterHopByHopHeaders(headers, connectionTokens) as Record< + string, + string + >; + const modifiedHeaders = options?.modifyHeaders + ? options.modifyHeaders(filteredHeaders) + : filteredHeaders; // Do not trust inbound forwarded headers. The immediate connection is the only trusted hop // until a trusted proxy policy is configured at the application boundary. - modifiedHeaders['x-forwarded-for'] = req.socket.remoteAddress || ''; const socket = req.socket as IncomingMessage['socket'] & { encrypted?: boolean }; - modifiedHeaders['x-forwarded-proto'] = socket.encrypted ? 'https' : 'http'; - modifiedHeaders['x-forwarded-host'] = forwardedHost; + const requestHeaders = this.filterHopByHopHeaders( + modifiedHeaders, + this.connectionTokens(modifiedHeaders) + ) as Record; + delete requestHeaders.host; + requestHeaders['x-forwarded-for'] = req.socket.remoteAddress || ''; + requestHeaders['x-forwarded-proto'] = socket.encrypted ? 'https' : 'http'; + requestHeaders['x-forwarded-host'] = forwardedHost; // Prepare request body if present let body: Buffer | undefined; @@ -78,7 +105,7 @@ export class ProxyClient { // Make the fetch request const response = await fetch(targetUrl.toString(), { method: req.method, - headers: modifiedHeaders, + headers: requestHeaders, body: body, signal: controller.signal, redirect: 'manual', @@ -88,10 +115,23 @@ export class ProxyClient { clearTimeout(timeoutId); // Convert immutable response headers to mutable object - const responseHeaders: Record = {}; + const responseHeaders: ProxyResponseHeaders = {}; response.headers.forEach((value, key) => { - responseHeaders[key] = value; + const current = responseHeaders[key]; + responseHeaders[key] = current + ? Array.isArray(current) + ? [...current, value] + : [current, value] + : value; }); + const setCookies = ( + response.headers as Headers & { getSetCookie?: () => string[] } + ).getSetCookie?.(); + if (setCookies?.length) responseHeaders['set-cookie'] = setCookies; + const filteredResponseHeaders = this.filterHopByHopHeaders( + responseHeaders, + this.connectionTokens(responseHeaders) + ); // Call onResponse callback if provided (with mutable headers) if (options?.onResponse) { @@ -99,14 +139,14 @@ export class ProxyClient { await options.onResponse({ status: response.status, statusText: response.statusText, - headers: responseHeaders + headers: filteredResponseHeaders }); } // Remove content-encoding headers since fetch automatically decompresses // Keeping them would cause ERR_CONTENT_DECODING_FAILED in browsers - delete responseHeaders['content-encoding']; - delete responseHeaders['content-length']; // Also remove as length changes after decompression + delete filteredResponseHeaders['content-encoding']; + delete filteredResponseHeaders['content-length']; // Also remove as length changes after decompression if (!this.canMutateResponse(res, controller.signal)) return; @@ -116,7 +156,7 @@ export class ProxyClient { res.statusMessage = response.statusText; // Copy modified response headers - for (const [key, value] of Object.entries(responseHeaders)) { + for (const [key, value] of Object.entries(filteredResponseHeaders)) { if (!this.canMutateResponse(res, controller.signal)) return; res.setHeader(key, value); } @@ -237,6 +277,27 @@ export class ProxyClient { } } + private connectionTokens(headers: Record): Set { + const connection = headers.connection; + const values = + connection === undefined ? [] : Array.isArray(connection) ? connection : [connection]; + return new Set( + values.flatMap((value) => value.split(',')).map((value) => value.trim().toLowerCase()) + ); + } + + private filterHopByHopHeaders( + headers: Record, + connectionTokens: Set + ): Record { + return Object.fromEntries( + Object.entries(headers).filter(([key]) => { + const normalizedKey = key.toLowerCase(); + return !HOP_BY_HOP_HEADERS.has(normalizedKey) && !connectionTokens.has(normalizedKey); + }) + ); + } + private canWriteResponse(res: ServerResponse, signal: AbortSignal): boolean { if (signal.aborted) throw new DOMException('The operation was aborted', 'AbortError'); return !res.destroyed && !res.writableEnded; diff --git a/src/router.spec.ts b/src/router.spec.ts index a19fdd2..6635c4a 100644 --- a/src/router.spec.ts +++ b/src/router.spec.ts @@ -714,6 +714,17 @@ describe('Middleware core', () => { }); }); + test.each([ + 'ftp://proxy.example', + '//proxy.example', + 'not-a-url', + 'https://proxy.example/?x=1' + ])('it should reject invalid external base URL %s', (externalBaseUrl) => { + expect(() => new Middleware([FAKE_TOKEN], { externalBaseUrl })).toThrow( + 'Invalid externalBaseUrl' + ); + }); + describe('GitHub API is online', () => { let scope: nock.Scope; @@ -915,7 +926,7 @@ describe('Middleware core', () => { await request(app).get('/').set('Authorization', tokenStr).expect(200); }); - test('it should replace base url on response header', async () => { + test('it should preserve upstream links when no external base URL is configured', async () => { const linkStr = '; rel="next", ; rel="last"'; @@ -923,8 +934,78 @@ describe('Middleware core', () => { await request(app) .get('/') - .expect(({ headers, request }) => { - expect(headers.link).toEqual(linkStr.replace(/https:\/\/api.github.com\//g, request.url)); + .expect(({ headers }) => { + expect(headers.link).toEqual(linkStr); + }); + }); + + test.each([ + 'http://proxy.example/base', + 'https://proxy.example', + 'https://proxy.example/$edge' + ])('it should rewrite redirects and links using trusted external base %s', async (baseUrl) => { + await middleware.destroy(); + middleware = new Middleware([FAKE_TOKEN], { + requestTimeout, + minRemaining: 0, + externalBaseUrl: baseUrl + }); + await new Promise((resolve) => middleware.once('ready', resolve)); + + const linkStr = + '; rel="next", ; rel="other"'; + scope.get('/redirect').reply(StatusCodes.MOVED_TEMPORARILY, '', { + location: 'https://api.github.com/repos/example', + link: linkStr + }); + scope.get('/unrelated-location').reply(StatusCodes.MOVED_TEMPORARILY, '', { + location: 'https://other.example/redirect?next=https://api.github.com/repos/example', + link: linkStr + }); + + await request(app) + .get('/redirect') + .expect(StatusCodes.MOVED_TEMPORARILY) + .expect(({ headers }) => { + expect(headers.location).toBe(`${baseUrl}/repos/example`); + expect(headers.link).toBe( + `<${baseUrl}/repos/example?page=2>; rel="next", ; rel="other"` + ); + }); + + await request(app) + .get('/unrelated-location') + .expect(StatusCodes.MOVED_TEMPORARILY) + .expect(({ headers }) => { + expect(headers.location).toBe( + 'https://other.example/redirect?next=https://api.github.com/repos/example' + ); + expect(headers.link).toBe( + `<${baseUrl}/repos/example?page=2>; rel="next", ; rel="other"` + ); + }); + }); + + test('should preserve origins when API and external paths begin with double slashes', async () => { + await middleware.destroy(); + middleware = new Middleware([FAKE_TOKEN], { + requestTimeout, + minRemaining: 0, + externalBaseUrl: 'https://proxy.example//edge' + }); + await new Promise((resolve) => middleware.once('ready', resolve)); + + scope.get('/double-slash').reply(StatusCodes.MOVED_TEMPORARILY, '', { + location: 'https://api.github.com//repos/example', + link: '; rel="next"' + }); + + await request(app) + .get('/double-slash') + .expect(StatusCodes.MOVED_TEMPORARILY) + .expect(({ headers }) => { + expect(headers.location).toBe('https://proxy.example//edge//repos/example'); + expect(headers.link).toBe('; rel="next"'); }); }); }); diff --git a/src/router.ts b/src/router.ts index b449ce7..c8173dd 100644 --- a/src/router.ts +++ b/src/router.ts @@ -6,13 +6,14 @@ import { StatusCodes } from 'http-status-codes'; import PQueue from 'p-queue'; import { Agent } from 'undici'; -import { ProxyClient } from './proxy-client.js'; +import { ProxyClient, type ProxyHeaderValue } from './proxy-client.js'; export type ProxyRouterOpts = { requestTimeout: number; minRemaining: number; overrideAuthorization?: boolean; timeBudgetMultiplier?: number; + externalBaseUrl?: string; }; type ExtendedRequest = Request & { @@ -32,6 +33,49 @@ type RateLimitResources = Record; const REFRESH_MAX_ATTEMPTS = 3; const REFRESH_BACKOFF_BASE_MS = 250; const REFRESH_BACKOFF_MAX_MS = 2000; +const GITHUB_API_ORIGIN = 'https://api.github.com'; + +function headerString(value: ProxyHeaderValue | undefined): string | undefined { + if (value === undefined) return undefined; + return Array.isArray(value) ? value.join(', ') : value; +} + +function rebaseGitHubUrl(url: URL, externalBaseUrl: string): string { + const external = new URL(externalBaseUrl); + const basePath = external.pathname === '/' ? '' : external.pathname.replace(/\/$/, ''); + const rebased = new URL(external.origin); + rebased.pathname = `${basePath}${url.pathname}`; + rebased.search = url.search; + rebased.hash = url.hash; + return rebased.toString(); +} + +function rewriteLocation(value: ProxyHeaderValue, externalBaseUrl: string): ProxyHeaderValue { + const rewrite = (header: string): string => { + try { + const url = new URL(header); + return url.origin === GITHUB_API_ORIGIN ? rebaseGitHubUrl(url, externalBaseUrl) : header; + } catch { + return header; + } + }; + return Array.isArray(value) ? value.map(rewrite) : rewrite(value); +} + +function rewriteLink(value: ProxyHeaderValue, externalBaseUrl: string): ProxyHeaderValue { + const rewrite = (header: string): string => + header.replace(/<([^>]*)>/g, (reference, target: string) => { + try { + const url = new URL(target); + return url.origin === GITHUB_API_ORIGIN + ? `<${rebaseGitHubUrl(url, externalBaseUrl)}>` + : reference; + } catch { + return reference; + } + }); + return Array.isArray(value) ? value.map(rewrite) : rewrite(value); +} class RefreshFailure extends Error {} @@ -113,6 +157,32 @@ export function parseTimeBudgetMultiplier(value: unknown): number { return parseNumericConfiguration(value, 'timeBudgetMultiplier', false); } +export function parseExternalBaseUrl(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string') + throw new Error('Invalid externalBaseUrl: expected an absolute HTTP(S) URL.'); + + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error('Invalid externalBaseUrl: expected an absolute HTTP(S) URL.'); + } + + if ( + !['http:', 'https:'].includes(url.protocol) || + !url.hostname || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error('Invalid externalBaseUrl: expected an absolute HTTP(S) URL.'); + } + + return url.toString().replace(/\/$/, ''); +} + export function validateProxyRouterOptions(options: ProxyRouterOpts): ProxyRouterOpts { const requestTimeout = parseRequestTimeout(options.requestTimeout); const minRemaining = parseMinRemaining(options.minRemaining); @@ -120,8 +190,9 @@ export function validateProxyRouterOptions(options: ProxyRouterOpts): ProxyRoute options.timeBudgetMultiplier === undefined ? undefined : parseTimeBudgetMultiplier(options.timeBudgetMultiplier); + const externalBaseUrl = parseExternalBaseUrl(options.externalBaseUrl); - return { ...options, requestTimeout, minRemaining, timeBudgetMultiplier }; + return { ...options, requestTimeout, minRemaining, timeBudgetMultiplier, externalBaseUrl }; } const GITHUB_TOKEN_PATTERNS = [ @@ -341,20 +412,24 @@ class ProxyWorker extends EventEmitter { onResponse: async (data) => { if (this.destroyed) return; - const linkHeader = data.headers.link; - if (linkHeader && req.headers.host) { - data.headers.link = linkHeader.replaceAll( - 'https://api.github.com', - `http://${req.headers.host}` - ); + if (opts.externalBaseUrl) { + const link = data.headers.link; + if (link !== undefined) + data.headers.link = rewriteLink(link, opts.externalBaseUrl); + const location = data.headers.location; + if (location !== undefined) { + data.headers.location = rewriteLocation(location, opts.externalBaseUrl); + } } // Only update rate limits if we injected the token if (!hasAuthorization) { const status = data.status.toString(); - const rateLimitRemaining = data.headers['x-ratelimit-remaining']; - const rateLimitReset = data.headers['x-ratelimit-reset']; - const rateLimitLimit = data.headers['x-ratelimit-limit']; + const rateLimitRemaining = headerString( + data.headers['x-ratelimit-remaining'] + ); + const rateLimitReset = headerString(data.headers['x-ratelimit-reset']); + const rateLimitLimit = headerString(data.headers['x-ratelimit-limit']); if (rateLimitRemaining) { this.updateLimits({ @@ -376,7 +451,9 @@ class ProxyWorker extends EventEmitter { } } - const exposeHeaders = data.headers['access-control-expose-headers']; + const exposeHeaders = headerString( + data.headers['access-control-expose-headers'] + ); if (exposeHeaders) { const filtered = exposeHeaders .split(', ') diff --git a/src/server.ts b/src/server.ts index 9cf3307..a507764 100644 --- a/src/server.ts +++ b/src/server.ts @@ -108,7 +108,7 @@ export type ProxyServer = Express & { }; export function createProxyServer(options: CliOpts): ProxyServer { - validateProxyRouterOptions(options); + const validatedOptions = validateProxyRouterOptions(options); const tokens = compact(options.tokens).reduce( (memo: string[], token: string) => concatTokens(token, memo), @@ -189,7 +189,7 @@ export function createProxyServer(options: CliOpts): ProxyServer { const proxy = new ProxyRouter(tokens, { overrideAuthorization: options.overrideAuthorization ?? true, - ...options + ...validatedOptions }); proxy.on('error', (message) => { From 68820499ff873f0420c5876c699c1f66b9e0367b Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Sat, 29 Aug 2026 00:54:18 -0400 Subject: [PATCH 11/26] fix(proxy): bound request lifetime and queueing --- README.md | 12 + .../13-body-queue-request-lifetime.md | 38 +- docs/enhancements/README.md | 2 +- src/cli.spec.ts | 41 ++ src/cli.ts | 36 + src/proxy-client.spec.ts | 46 ++ src/proxy-client.ts | 32 + src/router.spec.ts | 689 +++++++++++++++++- src/router.ts | 411 ++++++++++- 9 files changed, 1270 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index e2f28f7..f26f6cd 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,14 @@ When the proxy is deployed behind a trusted public URL, set `--external-base-url rewritten to that base; if it is omitted, upstream links are preserved unchanged and the untrusted inbound `Host` header is never used for external URLs. +Request bodies are limited to 1 MiB by default and can be configured with +`--max-request-body-bytes` or `GPS_MAX_REQUEST_BODY_BYTES` (1–16 MiB). Each live worker contributes +50 queue slots by default; configure this with `--max-queue-depth` or `GPS_MAX_QUEUE_DEPTH`. +Queued requests expire after 30 seconds by default (`--queue-wait-timeout` or +`GPS_QUEUE_WAIT_TIMEOUT`), and the total request lifetime is limited to 120 seconds by default +(`--request-lifetime-timeout` or `GPS_REQUEST_LIFETIME_TIMEOUT`). Body overflow returns `413`, a +full queue returns `503` with `Retry-After: 1`, and queue/lifetime expiry returns `504`. + ### Deployment and TLS The server listens for plain HTTP and binds to all interfaces when started by the CLI. Do not expose @@ -133,6 +141,10 @@ Options: --tokens [file] File containing a list of tokens (env: GPS_TOKENS_FILE) --request-timeout [timeout] Request timeout (ms) (default: 30000, env: GPS_REQUEST_TIMEOUT) --min-remaining Stop using token on a minimum of (default: 100, env: GPS_MIN_REMAINING) + --max-request-body-bytes [bytes] Maximum request body size (bytes) (default: 1048576, env: GPS_MAX_REQUEST_BODY_BYTES) + --max-queue-depth [depth] Maximum queued requests per worker (default: 50, env: GPS_MAX_QUEUE_DEPTH) + --queue-wait-timeout [timeout] Maximum queue wait (ms) (default: 30000, env: GPS_QUEUE_WAIT_TIMEOUT) + --request-lifetime-timeout [timeout] Maximum request lifetime (ms) (default: 120000, env: GPS_REQUEST_LIFETIME_TIMEOUT) --time-budget-multiplier [multiplier] Time budget multiplier (>= 1.0) (default: 1, env: GPS_TIME_BUDGET_MULTIPLIER) --external-base-url Trusted external HTTP(S) base URL (env: GPS_EXTERNAL_BASE_URL) --silent Dont show requests outputs (env: GPS_SILENT) diff --git a/docs/enhancements/13-body-queue-request-lifetime.md b/docs/enhancements/13-body-queue-request-lifetime.md index 8ea0035..f23d5b1 100644 --- a/docs/enhancements/13-body-queue-request-lifetime.md +++ b/docs/enhancements/13-body-queue-request-lifetime.md @@ -1,13 +1,13 @@ --- id: 13 title: Bound request bodies, queue residency, and end-to-end request lifetime -status: planned +status: verified risk: high urgency: high scope: request bodies, queues, overload handling, and timeout budgets --- -**Status:** Planned; not yet implemented. +**Status:** Verified; focused and full validation are complete. ## Problem @@ -16,10 +16,10 @@ and queues have no depth or end-to-end request-lifetime bound. ## Evidence -- Body buffering is implemented at `src/proxy-client.ts:68-72` and `src/proxy-client.ts:149-158`. -- The timeout setup and fetch boundary are at `src/proxy-client.ts:41-42` and `src/proxy-client.ts:74-85`. -- Queues are unbounded at `src/router.ts:301-315`. -- Requests are enqueued at `src/router.ts:371-385`. +- Request-body buffering and active timeout handling are implemented in `src/proxy-client.ts`. +- Bounded queue contexts, deadline timers, cancellation, and rejection responses are implemented in + `src/router.ts`. +- New limits are validated and propagated through `src/cli.ts` and `src/server.ts`. ## Expected benefit @@ -34,9 +34,14 @@ queue boundaries are explicit. ## Implementation notes -Enforce limits before unbounded buffering, account for body-read and queue-wait time in the request -budget, remove abandoned work, and select an explicit overload response. Preserve supported request -semantics while avoiding broad streaming rewrites without tests. +The proxy enforces a 1 MiB default request-body limit before buffering (configurable from 1–16 MiB), +with a typed `PAYLOAD_TOO_LARGE` error and a deterministic `413` response. Queue capacity is shared +per resource at `live workers × maxQueueDepthPerWorker`; full queues return `503` with +`Retry-After: 1`. Queue contexts track absolute queue and lifetime deadlines, remove expired work, +share one abort controller through retries and active proxy work, and clean up disconnect and +destruction listeners. Queue expiry returns `504` with `Request expired in proxy queue`, while total +lifetime expiry returns `504` with `Request lifetime exceeded`. The existing upstream request timeout +continues to use its existing `502` behavior. ## Validation plan @@ -49,3 +54,18 @@ bound. - Body, queue, wait, and total-lifetime limits are configured and documented. - Overload and timeout responses are deterministic. - Regression tests cover memory-sensitive and cancellation-sensitive paths. + +## Configuration + +| Option | Environment | Default | Range | +| --- | --- | ---: | ---: | +| `maxRequestBodyBytes` | `GPS_MAX_REQUEST_BODY_BYTES` | 1 MiB | 1–16 MiB | +| `maxQueueDepthPerWorker` | `GPS_MAX_QUEUE_DEPTH` | 50 | 1–1000 | +| `queueWaitTimeout` | `GPS_QUEUE_WAIT_TIMEOUT` | 30,000 ms | 1–120,000 ms | +| `requestLifetimeTimeout` | `GPS_REQUEST_LIFETIME_TIMEOUT` | 120,000 ms | 1–600,000 ms | + +## Validation evidence + +- Focused proxy, router, server, and CLI suites: 224 tests passed. +- Full test suite: 224 tests passed. +- Biome lint, TypeScript checking, production build, and `git diff --check` passed. diff --git a/docs/enhancements/README.md b/docs/enhancements/README.md index e39f8b8..d2076a0 100644 --- a/docs/enhancements/README.md +++ b/docs/enhancements/README.md @@ -2,7 +2,7 @@ This dossier records the project-improvement recommendations from the prior analysis. It expands the earlier grouped analysis into 15 actionable work items, ordered from lowest to highest implementation -risk. Recommendations 01 through 12 are **verified**; recommendations 13 through 15 are currently +risk. Recommendations 01 through 13 are **verified**; recommendations 14 through 15 are currently **planned**. ## Project baseline diff --git a/src/cli.spec.ts b/src/cli.spec.ts index 2d0f403..524cbea 100644 --- a/src/cli.spec.ts +++ b/src/cli.spec.ts @@ -11,8 +11,12 @@ import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { createAuthConfiguration, createCli, PARTIAL_AUTHENTICATION_ERROR } from './cli.js'; import { parseExternalBaseUrl, + parseMaxQueueDepthPerWorker, + parseMaxRequestBodyBytes, parseMinRemaining, parsePort, + parseQueueWaitTimeout, + parseRequestLifetimeTimeout, parseRequestTimeout, parseTimeBudgetMultiplier } from './router.js'; @@ -40,6 +44,25 @@ export async function cli( } describe('Test cli app', () => { + test.each([ + ['max request body bytes', parseMaxRequestBodyBytes, 1024 * 1024, 16 * 1024 * 1024], + ['max queue depth', parseMaxQueueDepthPerWorker, 1, 1000], + ['queue wait timeout', parseQueueWaitTimeout, 1, 120000], + ['request lifetime timeout', parseRequestLifetimeTimeout, 1, 600000] + ])('should parse %s within its configured range', (_name, parser, min, max) => { + expect(parser(min)).toBe(min); + expect(parser(max)).toBe(max); + }); + + test.each([ + [parseMaxRequestBodyBytes, 1024 * 1024 - 1], + [parseMaxQueueDepthPerWorker, 0], + [parseQueueWaitTimeout, 0], + [parseRequestLifetimeTimeout, 0] + ])('should reject an invalid enhancement limit', (parser, value) => { + expect(() => parser(value)).toThrowError(); + }); + test('it should thrown an error if token/tokens is not provided', async () => { const result = await cli([], '.'); expect(result.code).toEqual(1); @@ -221,6 +244,15 @@ describe('createCli command structure', () => { expect(minRemainingOption?.defaultValue).toBe(100); }); + test.each([ + ['--max-request-body-bytes', 1024 * 1024], + ['--max-queue-depth', 50], + ['--queue-wait-timeout', 30000], + ['--request-lifetime-timeout', 120000] + ])('should have %s with default %s', (name, value) => { + expect(program.options.find((option) => option.long === name)?.defaultValue).toBe(value); + }); + test('should have --silent option', () => { const silentOption = program.options.find((opt) => opt.long === '--silent'); expect(silentOption).toBeDefined(); @@ -359,6 +391,15 @@ describe('CLI environment variables', () => { expect(minRemainingOption?.envVar).toBe('GPS_MIN_REMAINING'); }); + test.each([ + ['--max-request-body-bytes', 'GPS_MAX_REQUEST_BODY_BYTES'], + ['--max-queue-depth', 'GPS_MAX_QUEUE_DEPTH'], + ['--queue-wait-timeout', 'GPS_QUEUE_WAIT_TIMEOUT'], + ['--request-lifetime-timeout', 'GPS_REQUEST_LIFETIME_TIMEOUT'] + ])('should support %s environment variable %s', (name, envVar) => { + expect(createCli().options.find((option) => option.long === name)?.envVar).toBe(envVar); + }); + test('should support GPS_AUTH_USERNAME environment variable', () => { const program = createCli(); const authUsernameOption = program.options.find((opt) => opt.long === '--auth-username'); diff --git a/src/cli.ts b/src/cli.ts index c43d475..4d82e54 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,8 +14,12 @@ import omitBy from 'lodash/omitBy.js'; import packageJson from '../package.json' with { type: 'json' }; import { parseExternalBaseUrl, + parseMaxQueueDepthPerWorker, + parseMaxRequestBodyBytes, parseMinRemaining, parsePort, + parseQueueWaitTimeout, + parseRequestLifetimeTimeout, parseRequestTimeout, parseTimeBudgetMultiplier } from './router.js'; @@ -70,6 +74,30 @@ export function createCli(): Command { .default(100) .env('GPS_MIN_REMAINING') ) + .addOption( + new Option('--max-request-body-bytes [bytes]', 'Maximum request body size (bytes)') + .argParser(parseMaxRequestBodyBytes) + .default(1024 * 1024) + .env('GPS_MAX_REQUEST_BODY_BYTES') + ) + .addOption( + new Option('--max-queue-depth [depth]', 'Maximum queued requests per worker') + .argParser(parseMaxQueueDepthPerWorker) + .default(50) + .env('GPS_MAX_QUEUE_DEPTH') + ) + .addOption( + new Option('--queue-wait-timeout [timeout]', 'Maximum queue wait (ms)') + .argParser(parseQueueWaitTimeout) + .default(30000) + .env('GPS_QUEUE_WAIT_TIMEOUT') + ) + .addOption( + new Option('--request-lifetime-timeout [timeout]', 'Maximum request lifetime (ms)') + .argParser(parseRequestLifetimeTimeout) + .default(120000) + .env('GPS_REQUEST_LIFETIME_TIMEOUT') + ) .addOption( new Option('--time-budget-multiplier [multiplier]', 'Time budget multiplier (>= 1.0)') .argParser(parseTimeBudgetMultiplier) @@ -122,6 +150,10 @@ export function createCli(): Command { const port = parsePort(options.port); const requestTimeout = parseRequestTimeout(options.requestTimeout); const minRemaining = parseMinRemaining(options.minRemaining); + const maxRequestBodyBytes = parseMaxRequestBodyBytes(options.maxRequestBodyBytes); + const maxQueueDepthPerWorker = parseMaxQueueDepthPerWorker(options.maxQueueDepth); + const queueWaitTimeout = parseQueueWaitTimeout(options.queueWaitTimeout); + const requestLifetimeTimeout = parseRequestLifetimeTimeout(options.requestLifetimeTimeout); const timeBudgetMultiplier = parseTimeBudgetMultiplier(options.timeBudgetMultiplier); const externalBaseUrl = parseExternalBaseUrl(options.externalBaseUrl); @@ -131,6 +163,10 @@ export function createCli(): Command { overrideAuthorization: options.overrideAuthorization, tokens: tokens, minRemaining, + maxRequestBodyBytes, + maxQueueDepthPerWorker, + queueWaitTimeout, + requestLifetimeTimeout, timeBudgetMultiplier, externalBaseUrl, statusMonitor: options.statusMonitor, diff --git a/src/proxy-client.spec.ts b/src/proxy-client.spec.ts index ec68322..c44bc11 100644 --- a/src/proxy-client.spec.ts +++ b/src/proxy-client.spec.ts @@ -1,5 +1,6 @@ /* Author: Hudson S. Borges */ +import EventEmitter from 'node:events'; import { IncomingMessage, ServerResponse } from 'node:http'; import { Agent as HttpsAgent } from 'node:https'; @@ -85,6 +86,50 @@ describe('ProxyClient', () => { expect(res.writableFinished).toBe(true); }); + test('should accept a request body exactly at the configured limit', async () => { + client = new ProxyClient({ target: TARGET, timeout: TIMEOUT, maxRequestBodyBytes: 7 }); + scope.post('/at-limit', '"12345"').reply(StatusCodes.OK, 'ok'); + const { req, res } = createMockRequestResponse('POST', '/at-limit', '12345', { + 'content-length': '7' + }); + + await expect(client.proxy(req, res)).resolves.toBeUndefined(); + expect(res.writableFinished).toBe(true); + }); + + test('should reject a declared body larger than the configured limit before buffering', async () => { + client = new ProxyClient({ target: TARGET, timeout: TIMEOUT, maxRequestBodyBytes: 4 }); + const { req, res } = createMockRequestResponse('POST', '/too-large', '12345', { + 'content-length': '5' + }); + + await expect(client.proxy(req, res)).rejects.toMatchObject({ code: 'PAYLOAD_TOO_LARGE' }); + expect(req.resume).toHaveBeenCalledTimes(1); + }); + + test('should reject a chunked body when it exceeds the configured limit', async () => { + client = new ProxyClient({ target: TARGET, timeout: TIMEOUT, maxRequestBodyBytes: 4 }); + const { req, res } = createMockRequestResponse('POST', '/too-large', '12345'); + + await expect(client.proxy(req, res)).rejects.toMatchObject({ code: 'PAYLOAD_TOO_LARGE' }); + }); + + test('should abort and clean up listeners while waiting for a slow upload', async () => { + const timeoutClient = new ProxyClient({ target: TARGET, timeout: 10 }); + const req = Object.assign(new EventEmitter(), { + method: 'POST', + url: '/slow-upload', + headers: { host: 'localhost:3000' }, + socket: { remoteAddress: '127.0.0.1', encrypted: false } + }) as unknown as IncomingMessage; + const { res } = createMockRequestResponse('POST', '/slow-upload'); + + await expect(timeoutClient.proxy(req, res)).rejects.toMatchObject({ code: 'ETIMEDOUT' }); + expect(req.listenerCount('data')).toBe(0); + expect(req.listenerCount('end')).toBe(0); + expect(req.listenerCount('error')).toBe(0); + }); + test('should copy request headers', async () => { let receivedHeaders: Record = {}; @@ -692,6 +737,7 @@ function createMockRequestResponse( remoteAddress: socketOptions.remoteAddress || '127.0.0.1', encrypted: socketOptions.encrypted || false }, + resume: vi.fn(), on: vi.fn((event: string, callback: (chunk?: Buffer) => void) => { if (event === 'data' && body) { // Emit body data diff --git a/src/proxy-client.ts b/src/proxy-client.ts index 693fb7a..5dc8312 100644 --- a/src/proxy-client.ts +++ b/src/proxy-client.ts @@ -21,17 +21,29 @@ const HOP_BY_HOP_HEADERS = new Set([ export interface ProxyClientOptions { target: string; timeout: number; + maxRequestBodyBytes?: number; dispatcher?: Dispatcher; } +export class PayloadTooLargeError extends Error { + readonly code = 'PAYLOAD_TOO_LARGE'; + + constructor() { + super('Request body too large'); + this.name = 'PayloadTooLargeError'; + } +} + export class ProxyClient { private readonly target: string; private readonly timeout: number; + private readonly maxRequestBodyBytes: number; private readonly dispatcher?: Dispatcher; constructor(options: ProxyClientOptions) { this.target = options.target; this.timeout = options.timeout; + this.maxRequestBodyBytes = options.maxRequestBodyBytes ?? 1024 * 1024; this.dispatcher = options.dispatcher; } @@ -227,6 +239,13 @@ export class ProxyClient { private readRequestBody(req: IncomingMessage, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; + let size = 0; + const contentLength = req.headers['content-length']; + const declaredLength = Array.isArray(contentLength) + ? Number(contentLength[0]) + : contentLength === undefined + ? undefined + : Number(contentLength); const cleanup = (): void => { req.removeListener?.('data', onData); req.removeListener?.('end', onEnd); @@ -234,8 +253,21 @@ export class ProxyClient { signal.removeEventListener('abort', onAbort); }; const onData = (chunk: Buffer): void => { + size += chunk.length; + if (size > this.maxRequestBodyBytes) { + cleanup(); + req.resume?.(); + reject(new PayloadTooLargeError()); + return; + } chunks.push(chunk); }; + + if (declaredLength !== undefined && declaredLength > this.maxRequestBodyBytes) { + reject(new PayloadTooLargeError()); + req.resume?.(); + return; + } const onEnd = (): void => { cleanup(); resolve(Buffer.concat(chunks)); diff --git a/src/router.spec.ts b/src/router.spec.ts index 6635c4a..ef9369e 100644 --- a/src/router.spec.ts +++ b/src/router.spec.ts @@ -8,11 +8,13 @@ import nock from 'nock'; import request from 'supertest'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import { PayloadTooLargeError } from './proxy-client'; import Middleware from './router'; let app: Express; const FAKE_TOKEN = repeat('t', 40); +const SECOND_TOKEN = `${repeat('u', 39)}1`; const RATE_LIMIT_RESOURCES = { core: { limit: 5000, remaining: 4000, reset: 2000000000 }, @@ -58,7 +60,11 @@ describe('Middleware constructor and methods', () => { test.each([ ['requestTimeout', { requestTimeout: 0 }], ['minRemaining', { minRemaining: -1 }], - ['timeBudgetMultiplier', { timeBudgetMultiplier: 11 }] + ['timeBudgetMultiplier', { timeBudgetMultiplier: 11 }], + ['maxRequestBodyBytes', { maxRequestBodyBytes: 1024 * 1024 - 1 }], + ['maxQueueDepthPerWorker', { maxQueueDepthPerWorker: 0 }], + ['queueWaitTimeout', { queueWaitTimeout: 0 }], + ['requestLifetimeTimeout', { requestLifetimeTimeout: 0 }] ])('it should reject invalid direct %s options', (_name, invalidOptions) => { expect(() => new Middleware([FAKE_TOKEN], invalidOptions)).toThrow(`Invalid ${_name}`); }); @@ -342,6 +348,79 @@ describe('Middleware constructor and methods', () => { } }); + test('should not write a local rejection response after the request disconnects', async () => { + const middleware = new Middleware([FAKE_TOKEN]); + const worker = ( + middleware as unknown as { + workersByResource: { + core: Array<{ + remaining: number; + reset: number; + proxy: { proxy: (...args: never[]) => Promise }; + schedule: (req: Request, res: Response) => Promise; + }>; + }; + } + ).workersByResource.core[0]; + worker.remaining = 5000; + worker.reset = 0; + const { req, res, json } = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + const proxy = vi.spyOn(worker.proxy, 'proxy').mockImplementation(async () => { + req.aborted = true; + throw new PayloadTooLargeError(); + }); + + try { + await worker.schedule(req, res); + expect(json).not.toHaveBeenCalled(); + } finally { + proxy.mockRestore(); + await middleware.destroy(); + } + }); + + test('should destroy partial responses from the worker lifetime-error path', async () => { + const middleware = new Middleware([FAKE_TOKEN]); + const worker = ( + middleware as unknown as { + workersByResource: { + core: Array<{ + remaining: number; + reset: number; + proxy: { proxy: (...args: never[]) => Promise }; + schedule: (req: Request, res: Response) => Promise; + }>; + }; + } + ).workersByResource.core[0]; + worker.remaining = 5000; + worker.reset = 0; + const requestResponse = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + const proxy = vi.spyOn(worker.proxy, 'proxy').mockImplementation(async (req) => { + requestResponse.res.headersSent = true; + (req as unknown as { proxyContext?: { timeoutReason?: string } }) + .proxyContext!.timeoutReason = 'lifetime'; + throw new Error('lifetime expired'); + }); + + try { + await worker.schedule(requestResponse.req, requestResponse.res); + expect(requestResponse.destroy).toHaveBeenCalledTimes(1); + expect(requestResponse.json).not.toHaveBeenCalled(); + } finally { + proxy.mockRestore(); + await middleware.destroy(); + } + }); + test('should abort the active proxy before settling a destroyed worker task', async () => { const middleware = new Middleware([FAKE_TOKEN]); const worker = ( @@ -399,6 +478,320 @@ describe('Middleware constructor and methods', () => { await middleware.destroy(); } }); + + test('should retain the router while destroying an active request context', async () => { + const middleware = new Middleware([FAKE_TOKEN], { minRemaining: 0 }); + const worker = ( + middleware as unknown as { + workersByResource: { + core: Array<{ + schedule: (req: Request, res: Response) => Promise; + destroy: () => Promise; + }>; + }; + } + ).workersByResource.core[0]; + const queue = ( + middleware as unknown as { + queues: { + core: { dequeue: () => { req: Request; res: Response } | undefined; size: number }; + }; + } + ).queues.core; + const { req, res } = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + (worker as unknown as { remaining: number; reset: number }).remaining = 5000; + (worker as unknown as { remaining: number; reset: number }).reset = 0; + const proxy = vi + .spyOn( + (worker as unknown as { proxy: { proxy: (...args: never[]) => Promise } }).proxy, + 'proxy' + ) + .mockImplementation(() => new Promise(() => undefined)); + + try { + await middleware.schedule(req, res); + const work = queue.dequeue(); + expect(work).toBeDefined(); + const scheduled = worker.schedule(work!.req, work!.res); + await Promise.resolve(); + const context = (req as Request & { proxyContext?: { controller: AbortController } }) + .proxyContext; + expect(context).toBeDefined(); + expect((worker as unknown as { ownedContexts: Set }).ownedContexts.size).toBe(1); + await worker.destroy(); + expect(context?.controller.signal.aborted).toBe(true); + expect((req as Request & { proxyContext?: unknown }).proxyContext).toBeUndefined(); + await scheduled; + } finally { + proxy.mockRestore(); + await middleware.destroy(); + } + }); + + test('should abort the shared controller and return one fresh 504 at the lifetime deadline', async () => { + const middleware = new Middleware([FAKE_TOKEN], { + minRemaining: 0, + requestLifetimeTimeout: 250, + queueWaitTimeout: 1000 + }); + const worker = ( + middleware as unknown as { + workersByResource: { + core: Array<{ + remaining: number; + reset: number; + proxy: { + proxy: ( + req: Request, + res: Response, + options?: { abortController?: AbortController } + ) => Promise; + }; + }>; + }; + } + ).workersByResource.core[0]; + worker.remaining = 5000; + worker.reset = 0; + const requestResponse = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + requestResponse.json.mockImplementation(() => { + requestResponse.res.headersSent = true; + }); + let observedSignal: AbortSignal | undefined; + let started!: () => void; + const startedPromise = new Promise((resolve) => { + started = resolve; + }); + const proxy = vi.spyOn(worker.proxy, 'proxy').mockImplementation((_req, _res, options) => { + observedSignal = options?.abortController?.signal; + started(); + return new Promise((_resolve, reject) => { + observedSignal?.addEventListener( + 'abort', + () => reject(new DOMException('The operation was aborted', 'AbortError')), + { once: true } + ); + }); + }); + + try { + await middleware.schedule(requestResponse.req, requestResponse.res); + await startedPromise; + const context = ( + requestResponse.req as Request & { + proxyContext?: { controller: AbortController }; + } + ).proxyContext; + expect(context).toBeDefined(); + await waitFor(() => requestResponse.json.mock.calls.length === 1); + expect(observedSignal).toBe(context?.controller.signal); + expect(observedSignal?.aborted).toBe(true); + expect(requestResponse.status).toHaveBeenCalledWith(StatusCodes.GATEWAY_TIMEOUT); + expect(requestResponse.json).toHaveBeenCalledWith({ message: 'Request lifetime exceeded' }); + } finally { + proxy.mockRestore(); + await middleware.destroy(); + } + }); + + test('should destroy a partial response on active lifetime expiry', async () => { + const middleware = new Middleware([FAKE_TOKEN], { + minRemaining: 0, + requestLifetimeTimeout: 250, + queueWaitTimeout: 1000 + }); + const worker = ( + middleware as unknown as { + workersByResource: { + core: Array<{ + remaining: number; + reset: number; + proxy: { + proxy: ( + req: Request, + res: Response, + options?: { abortController?: AbortController } + ) => Promise; + }; + }>; + }; + } + ).workersByResource.core[0]; + worker.remaining = 5000; + worker.reset = 0; + const requestResponse = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + requestResponse.destroy.mockImplementation(() => { + requestResponse.res.destroyed = true; + }); + let started!: () => void; + const startedPromise = new Promise((resolve) => { + started = resolve; + }); + const proxy = vi.spyOn(worker.proxy, 'proxy').mockImplementation((_req, res, options) => { + res.headersSent = true; + started(); + return new Promise((_resolve, reject) => { + options?.abortController?.signal.addEventListener( + 'abort', + () => reject(new DOMException('The operation was aborted', 'AbortError')), + { once: true } + ); + }); + }); + + try { + await middleware.schedule(requestResponse.req, requestResponse.res); + await startedPromise; + await waitFor(() => requestResponse.destroy.mock.calls.length === 1); + expect(requestResponse.json).not.toHaveBeenCalled(); + } finally { + proxy.mockRestore(); + await middleware.destroy(); + } + }); + + test.each([ + ['completed', { headersSent: false, writableEnded: true, destroyed: false }], + ['disconnected', { headersSent: false, writableEnded: false, destroyed: false }] + ])('should not write or destroy a %s response on active lifetime expiry', async (_name, state) => { + const middleware = new Middleware([FAKE_TOKEN], { + minRemaining: 0, + requestLifetimeTimeout: 250, + queueWaitTimeout: 1000 + }); + const worker = ( + middleware as unknown as { + workersByResource: { + core: Array<{ + remaining: number; + reset: number; + proxy: { + proxy: ( + req: Request, + res: Response, + options?: { abortController?: AbortController } + ) => Promise; + }; + }>; + }; + } + ).workersByResource.core[0]; + worker.remaining = 5000; + worker.reset = 0; + const requestResponse = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + let started!: () => void; + const startedPromise = new Promise((resolve) => { + started = resolve; + }); + const proxy = vi.spyOn(worker.proxy, 'proxy').mockImplementation((req, res, options) => { + if (_name === 'disconnected') req.aborted = true; + if (_name === 'completed') { + (res as unknown as { writableEnded: boolean }).writableEnded = true; + } + started(); + return new Promise((_resolve, reject) => { + options?.abortController?.signal.addEventListener( + 'abort', + () => reject(new DOMException('The operation was aborted', 'AbortError')), + { once: true } + ); + }); + }); + + try { + await middleware.schedule(requestResponse.req, requestResponse.res); + await startedPromise; + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(requestResponse.json).not.toHaveBeenCalled(); + expect(requestResponse.destroy).not.toHaveBeenCalled(); + } finally { + proxy.mockRestore(); + await middleware.destroy(); + } + }); + + test('should destroy a partial response when active lifetime expires', async () => { + const middleware = new Middleware([FAKE_TOKEN]); + const requestResponse = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + await middleware.schedule(requestResponse.req, requestResponse.res); + const queue = ( + middleware as unknown as { + queues: { + core: { dequeue: () => { req: Request; res: Response } | undefined; size: number }; + }; + } + ).queues.core; + queue.dequeue(); + const context = ( + requestResponse.req as Request & { + proxyContext?: { state: string; timeoutReason?: string }; + } + ).proxyContext; + expect(context).toBeDefined(); + context!.state = 'active'; + context!.timeoutReason = 'lifetime'; + requestResponse.res.headersSent = true; + + try { + ( + middleware as unknown as { + expireLifetime: (value: typeof context) => void; + } + ).expireLifetime(context); + expect(requestResponse.destroy).toHaveBeenCalledTimes(1); + expect(requestResponse.json).not.toHaveBeenCalled(); + } finally { + await middleware.destroy(); + } + }); + + test.each([ + ['completed', { headersSent: false, writableEnded: true, destroyed: false }], + ['disconnected', { headersSent: false, writableEnded: false, destroyed: false }] + ])('should leave %s responses untouched on lifetime expiry', async (_name, state) => { + const middleware = new Middleware([FAKE_TOKEN]); + const requestResponse = createStateAwareRequestResponse(state); + await middleware.schedule(requestResponse.req, requestResponse.res); + const context = ( + requestResponse.req as Request & { + proxyContext?: { state: string }; + } + ).proxyContext; + expect(context).toBeDefined(); + if (_name === 'disconnected') requestResponse.req.aborted = true; + + try { + ( + middleware as unknown as { + expireLifetime: (value: typeof context) => void; + } + ).expireLifetime(context); + expect(requestResponse.json).not.toHaveBeenCalled(); + expect(requestResponse.destroy).not.toHaveBeenCalled(); + } finally { + await middleware.destroy(); + } + }); }); describe('Rate-limit refresh policy', () => { @@ -623,6 +1016,14 @@ describe('Rate-limit refresh policy', () => { }); }); +async function waitFor(condition: () => boolean, timeout = 1000): Promise { + const deadline = Date.now() + timeout; + while (!condition()) { + if (Date.now() >= deadline) throw new Error('Timed out waiting for test condition'); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + function createStateAwareRequestResponse(state: { headersSent: boolean; writableEnded: boolean; @@ -631,28 +1032,37 @@ function createStateAwareRequestResponse(state: { req: Request & { aborted: boolean }; res: Response; send: ReturnType; + json: ReturnType; status: ReturnType; + setHeader: ReturnType; destroy: ReturnType; + resume: ReturnType; } { const socket = Object.assign(new EventEmitter(), { destroyed: false }); + const resume = vi.fn(); const req = Object.assign(new EventEmitter(), { method: 'GET', url: '/', + path: '/', headers: { host: 'localhost:3000' }, socket, + resume, aborted: false, destroyed: false }) as unknown as Request & { aborted: boolean }; const send = vi.fn(); - const status = vi.fn(() => ({ send })); + const json = vi.fn(); + const status = vi.fn(() => ({ send, json })); + const setHeader = vi.fn(); const destroy = vi.fn(); const res = Object.assign(new EventEmitter(), { ...state, status, + setHeader, destroy }) as unknown as Response; - return { req, res, send, status, destroy }; + return { req, res, send, json, status, setHeader, destroy, resume }; } describe('Middleware core', () => { @@ -725,6 +1135,279 @@ describe('Middleware core', () => { ); }); + test('it should reject requests when the shared resource queue is full', async () => { + const limited = new Middleware([FAKE_TOKEN], { + minRemaining: 5000, + maxQueueDepthPerWorker: 1 + }); + const first = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + const second = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + + try { + await limited.schedule(first.req, first.res); + await limited.schedule(second.req, second.res); + expect(second.setHeader).toHaveBeenCalledWith('Retry-After', '1'); + expect(second.status).toHaveBeenCalledWith(StatusCodes.SERVICE_UNAVAILABLE); + expect(second.json).toHaveBeenCalledWith({ message: 'Proxy queue is full' }); + expect(second.resume).toHaveBeenCalledTimes(1); + } finally { + await limited.destroy(); + } + }); + + test('it should expire a queued request and remove it from the resource queue', async () => { + const limited = new Middleware([FAKE_TOKEN], { + minRemaining: 5000, + queueWaitTimeout: 1 + }); + const requestResponse = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + + try { + await limited.schedule(requestResponse.req, requestResponse.res); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(requestResponse.status).toHaveBeenCalledWith(StatusCodes.GATEWAY_TIMEOUT); + expect(requestResponse.json).toHaveBeenCalledWith({ + message: 'Request expired in proxy queue' + }); + expect(requestResponse.resume).toHaveBeenCalledTimes(1); + } finally { + await limited.destroy(); + } + }); + + test('it should abort the shared signal when queued lifetime expires', async () => { + vi.useFakeTimers(); + const limited = new Middleware([FAKE_TOKEN], { + minRemaining: 5000, + requestLifetimeTimeout: 10, + queueWaitTimeout: 100 + }); + const requestResponse = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + + try { + await limited.schedule(requestResponse.req, requestResponse.res); + const context = ( + requestResponse.req as Request & { + proxyContext?: { controller: AbortController }; + } + ).proxyContext; + expect(context).toBeDefined(); + vi.advanceTimersByTime(10); + expect(context?.controller.signal.aborted).toBe(true); + expect(requestResponse.json).toHaveBeenCalledWith({ message: 'Request lifetime exceeded' }); + } finally { + await limited.destroy(); + vi.useRealTimers(); + } + }); + + test('it should remove exactly one queued entry when a client disconnects', async () => { + const limited = new Middleware([FAKE_TOKEN], { + minRemaining: 5000, + maxQueueDepthPerWorker: 2 + }); + const first = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + const second = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + + try { + await limited.schedule(first.req, first.res); + await limited.schedule(second.req, second.res); + first.req.aborted = true; + first.req.emit('aborted'); + first.req.emit('aborted'); + const queue = ( + limited as unknown as { + queues: { core: { size: number; items: Array<{ req: Request }> } }; + } + ).queues.core; + expect(queue.size).toBe(1); + expect(queue.items[0]?.req).toBe(second.req); + expect((first.req as Request & { proxyContext?: unknown }).proxyContext).toBeUndefined(); + } finally { + await limited.destroy(); + } + }); + + test('it should preserve absolute deadlines and clear worker ownership across retries', async () => { + const middleware = new Middleware([FAKE_TOKEN], { minRemaining: 0 }); + const worker = ( + middleware as unknown as { + workersByResource: { + core: Array<{ + remaining: number; + reset: number; + schedule: (req: Request, res: Response) => Promise; + }>; + }; + } + ).workersByResource.core[0]; + worker.remaining = 0; + worker.reset = Math.floor(Date.now() / 1000) + 60; + const requestResponse = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + const queue = ( + middleware as unknown as { + queues: { + core: { dequeue: () => { req: Request; res: Response } | undefined; size: number }; + }; + } + ).queues.core; + + try { + await middleware.schedule(requestResponse.req, requestResponse.res); + const context = ( + requestResponse.req as Request & { + proxyContext?: { queueDeadline: number; lifetimeDeadline: number; worker?: unknown }; + } + ).proxyContext; + const deadlines = [context?.queueDeadline, context?.lifetimeDeadline]; + const work = queue.dequeue(); + await worker.schedule(work!.req, work!.res); + expect([context?.queueDeadline, context?.lifetimeDeadline]).toEqual(deadlines); + expect(context?.worker).toBeUndefined(); + expect(queue.size).toBe(1); + } finally { + await middleware.destroy(); + } + }); + + test('should preserve a requeued context when worker A is destroyed before worker B claims it', async () => { + const middleware = new Middleware([FAKE_TOKEN, SECOND_TOKEN], { + minRemaining: 5000, + queueWaitTimeout: 1000, + requestLifetimeTimeout: 2000 + }); + await middleware.refreshRateLimits(); + const workerA = ( + middleware as unknown as { + workersByResource: { + core: Array<{ + remaining: number; + reset: number; + schedule: (req: Request, res: Response) => Promise; + destroy: () => Promise; + }>; + }; + } + ).workersByResource.core[0]; + workerA.remaining = 0; + workerA.reset = Math.floor(Date.now() / 1000) + 60; + const workerB = ( + middleware as unknown as { + workersByResource: { + core: Array<{ + remaining: number; + reset: number; + proxy: { + proxy: ( + req: Request, + res: Response, + options?: { abortController?: AbortController } + ) => Promise; + }; + }>; + }; + } + ).workersByResource.core[1]; + workerB.remaining = 0; + workerB.reset = Math.floor(Date.now() / 1000) + 60; + const requestResponse = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + const queue = ( + middleware as unknown as { + queues: { + core: { dequeue: () => { req: Request; res: Response } | undefined; size: number }; + }; + } + ).queues.core; + let observedSignal: AbortSignal | undefined; + let started!: () => void; + const startedPromise = new Promise((resolve) => { + started = resolve; + }); + + try { + await middleware.schedule(requestResponse.req, requestResponse.res); + const context = ( + requestResponse.req as Request & { + proxyContext?: { + controller: AbortController; + queueDeadline: number; + lifetimeDeadline: number; + worker?: unknown; + }; + } + ).proxyContext; + expect(context).toBeDefined(); + const deadlines = [context!.queueDeadline, context!.lifetimeDeadline]; + const work = queue.dequeue(); + await workerA.schedule(work!.req, work!.res); + expect(queue.size).toBe(1); + expect(context!.controller.signal.aborted).toBe(false); + expect([context!.queueDeadline, context!.lifetimeDeadline]).toEqual(deadlines); + expect(context!.worker).toBeUndefined(); + + await workerA.destroy(); + expect(queue.size).toBe(1); + expect(requestResponse.destroy).not.toHaveBeenCalled(); + expect(context!.controller.signal.aborted).toBe(false); + + const proxy = vi.spyOn(workerB.proxy, 'proxy').mockImplementation((_req, _res, options) => { + observedSignal = options?.abortController?.signal; + started(); + return new Promise((_resolve, reject) => { + observedSignal?.addEventListener( + 'abort', + () => reject(new DOMException('The operation was aborted', 'AbortError')), + { once: true } + ); + }); + }); + + try { + workerB.remaining = 5000; + workerB.reset = 0; + await startedPromise; + expect(observedSignal).toBe(context!.controller.signal); + expect([context!.queueDeadline, context!.lifetimeDeadline]).toEqual(deadlines); + } finally { + proxy.mockRestore(); + } + } finally { + await middleware.destroy(); + } + }); + describe('GitHub API is online', () => { let scope: nock.Scope; diff --git a/src/router.ts b/src/router.ts index c8173dd..4396106 100644 --- a/src/router.ts +++ b/src/router.ts @@ -6,11 +6,15 @@ import { StatusCodes } from 'http-status-codes'; import PQueue from 'p-queue'; import { Agent } from 'undici'; -import { ProxyClient, type ProxyHeaderValue } from './proxy-client.js'; +import { PayloadTooLargeError, ProxyClient, type ProxyHeaderValue } from './proxy-client.js'; export type ProxyRouterOpts = { requestTimeout: number; minRemaining: number; + maxRequestBodyBytes?: number; + maxQueueDepthPerWorker?: number; + queueWaitTimeout?: number; + requestLifetimeTimeout?: number; overrideAuthorization?: boolean; timeBudgetMultiplier?: number; externalBaseUrl?: string; @@ -19,6 +23,7 @@ export type ProxyRouterOpts = { type ExtendedRequest = Request & { startedAt?: Date; abortController?: AbortController; + proxyContext?: RequestContext; }; type APIResources = 'core' | 'search' | 'code_search' | 'graphql'; @@ -34,6 +39,28 @@ const REFRESH_MAX_ATTEMPTS = 3; const REFRESH_BACKOFF_BASE_MS = 250; const REFRESH_BACKOFF_MAX_MS = 2000; const GITHUB_API_ORIGIN = 'https://api.github.com'; +const REQUEST_BODY_DEFAULT = 1024 * 1024; +const QUEUE_DEPTH_DEFAULT = 50; +const QUEUE_WAIT_DEFAULT = 30000; +const REQUEST_LIFETIME_DEFAULT = 120000; + +type RequestContextState = 'queued' | 'active' | 'settled'; + +type RequestContext = { + req: ExtendedRequest; + res: Response; + controller: AbortController; + acceptedAt: number; + enqueuedAt: number; + queueDeadline: number; + lifetimeDeadline: number; + state: RequestContextState; + queueTimer?: NodeJS.Timeout; + lifetimeTimer?: NodeJS.Timeout; + worker?: ProxyWorker; + timeoutReason?: 'queue' | 'lifetime'; + onDisconnect: () => void; +}; function headerString(value: ProxyHeaderValue | undefined): string | undefined { if (value === undefined) return undefined; @@ -110,7 +137,11 @@ export const NUMERIC_CONFIGURATION_LIMITS = { port: { min: 0, max: 65535 }, requestTimeout: { min: 1, max: 120000 }, minRemaining: { min: 0, max: 5000 }, - timeBudgetMultiplier: { min: 1, max: 10 } + timeBudgetMultiplier: { min: 1, max: 10 }, + maxRequestBodyBytes: { min: REQUEST_BODY_DEFAULT, max: REQUEST_BODY_DEFAULT * 16 }, + maxQueueDepthPerWorker: { min: 1, max: 1000 }, + queueWaitTimeout: { min: 1, max: 120000 }, + requestLifetimeTimeout: { min: 1, max: 600000 } } as const; function parseNumericConfiguration( @@ -157,6 +188,24 @@ export function parseTimeBudgetMultiplier(value: unknown): number { return parseNumericConfiguration(value, 'timeBudgetMultiplier', false); } +export function parseMaxRequestBodyBytes(value: unknown): number { + return parseNumericConfiguration(value, 'maxRequestBodyBytes', true); +} + +export function parseMaxQueueDepthPerWorker(value: unknown): number { + return parseNumericConfiguration(value, 'maxQueueDepthPerWorker', true); +} + +export const parseMaxQueueDepth = parseMaxQueueDepthPerWorker; + +export function parseQueueWaitTimeout(value: unknown): number { + return parseNumericConfiguration(value, 'queueWaitTimeout', true); +} + +export function parseRequestLifetimeTimeout(value: unknown): number { + return parseNumericConfiguration(value, 'requestLifetimeTimeout', true); +} + export function parseExternalBaseUrl(value: unknown): string | undefined { if (value === undefined) return undefined; if (typeof value !== 'string') @@ -191,8 +240,28 @@ export function validateProxyRouterOptions(options: ProxyRouterOpts): ProxyRoute ? undefined : parseTimeBudgetMultiplier(options.timeBudgetMultiplier); const externalBaseUrl = parseExternalBaseUrl(options.externalBaseUrl); - - return { ...options, requestTimeout, minRemaining, timeBudgetMultiplier, externalBaseUrl }; + const maxRequestBodyBytes = parseMaxRequestBodyBytes( + options.maxRequestBodyBytes ?? REQUEST_BODY_DEFAULT + ); + const maxQueueDepthPerWorker = parseMaxQueueDepthPerWorker( + options.maxQueueDepthPerWorker ?? QUEUE_DEPTH_DEFAULT + ); + const queueWaitTimeout = parseQueueWaitTimeout(options.queueWaitTimeout ?? QUEUE_WAIT_DEFAULT); + const requestLifetimeTimeout = parseRequestLifetimeTimeout( + options.requestLifetimeTimeout ?? REQUEST_LIFETIME_DEFAULT + ); + + return { + ...options, + requestTimeout, + minRemaining, + maxRequestBodyBytes, + maxQueueDepthPerWorker, + queueWaitTimeout, + requestLifetimeTimeout, + timeBudgetMultiplier, + externalBaseUrl + }; } const GITHUB_TOKEN_PATTERNS = [ @@ -209,6 +278,7 @@ export function validateGitHubToken(token: unknown): asserts token is string { type ScheduledRequest = Omit & { req: ExtendedRequest; + context: RequestContext; settle: () => void; }; @@ -237,6 +307,90 @@ function terminatePartialResponse(req: ExtendedRequest, res: Response): void { res.destroy(); } +function sendJsonResponse( + req: ExtendedRequest, + res: Response, + status: number, + body: Record +): void { + if (requestDisconnected(req) || responseUnavailable(res)) return; + res.status(status).json(body); +} + +function disposeUnreadRequest(req: ExtendedRequest): void { + if (requestDisconnected(req) || req.readableEnded || req.complete) return; + if (typeof req.resume === 'function') { + req.resume(); + } else if (typeof req.destroy === 'function') { + req.destroy(); + } +} + +function rejectRequest( + req: ExtendedRequest, + res: Response, + status: number, + body: Record, + retryAfter?: string +): void { + if (!requestDisconnected(req) && !res.writableEnded && !res.destroyed) { + if (res.headersSent) { + terminatePartialResponse(req, res); + } else { + if (retryAfter !== undefined) res.setHeader('Retry-After', retryAfter); + sendJsonResponse(req, res, status, body); + } + } + disposeUnreadRequest(req); +} + +function createRequestContext( + req: ExtendedRequest, + res: Response, + onDisconnect?: (context: RequestContext) => void +): RequestContext { + const controller = new AbortController(); + let context!: RequestContext; + const disconnect = (): void => { + if (context.state === 'settled') return; + controller.abort(); + onDisconnect?.(context); + }; + + context = { + req, + res, + controller, + acceptedAt: Date.now(), + enqueuedAt: Date.now(), + queueDeadline: 0, + lifetimeDeadline: 0, + state: 'queued', + onDisconnect: disconnect + }; + + req.once?.('aborted', disconnect); + req.once?.('error', disconnect); + req.socket?.once?.('close', disconnect); + res.once?.('close', disconnect); + req.proxyContext = context; + return context; +} + +function cleanupRequestContext(context: RequestContext): void { + if (context.queueTimer) clearTimeout(context.queueTimer); + if (context.lifetimeTimer) clearTimeout(context.lifetimeTimer); + context.queueTimer = undefined; + context.lifetimeTimer = undefined; + context.req.removeListener?.('aborted', context.onDisconnect); + context.req.removeListener?.('error', context.onDisconnect); + context.req.socket?.removeListener?.('close', context.onDisconnect); + context.res.removeListener?.('close', context.onDisconnect); + if (context.req.proxyContext === context) delete context.req.proxyContext; + context.worker = undefined; + context.state = 'settled'; +} + export interface WorkerLogger { resource: APIResources; token: string; @@ -257,6 +411,7 @@ export interface RequestQueue { items: QueuedRequest[]; enqueue(req: Request, res: Response): void; dequeue(): QueuedRequest | undefined; + remove(req: Request, res: Response): boolean; get size(): number; } @@ -270,6 +425,7 @@ class ProxyWorker extends EventEmitter { private readonly opts: ProxyRouterOpts; private readonly agent: Agent; private readonly scheduledRequests = new Set(); + private readonly ownedContexts = new Set(); private router?: ProxyRouter; private resourceQueue?: RequestQueue; private pullInterval?: NodeJS.Timeout; @@ -338,6 +494,7 @@ class ProxyWorker extends EventEmitter { this.proxy = new ProxyClient({ target: 'https://api.github.com', timeout: opts.requestTimeout, + maxRequestBodyBytes: opts.maxRequestBodyBytes, dispatcher: this.agent }); @@ -352,13 +509,22 @@ class ProxyWorker extends EventEmitter { return; } + const context = req.proxyContext ?? createRequestContext(req, res); + if (context.state === 'settled') return; + context.state = 'active'; + context.worker = this; + this.ownedContexts.add(context); + if (context.queueTimer) clearTimeout(context.queueTimer); + context.queueTimer = undefined; + let settleTask!: () => void; const completion = new Promise((resolve) => { settleTask = resolve; }); - const scheduledRequest = { req, res, settle: settleTask }; + const scheduledRequest = { req, res, context, settle: settleTask }; this.scheduledRequests.add(scheduledRequest); - let activeAbortController: AbortController | undefined; + const abortController = context.controller; + let activeAbortController: AbortController | undefined = abortController; try { void this.queue @@ -368,12 +534,20 @@ class ProxyWorker extends EventEmitter { this.destroyed || requestDisconnected(req) || res.writableEnded || - res.destroyed + res.destroyed || + abortController.signal.aborted ) { + if (context.timeoutReason === 'lifetime') { + sendJsonResponse(req, res, StatusCodes.GATEWAY_TIMEOUT, { + message: 'Request lifetime exceeded' + }); + } this.log(); return; } + if (context.state !== 'active') return; + const noTimeBudget = this.timeBudget < this.queue.pending * 1000; const noRequests = this.remaining <= opts.minRemaining && this.reset >= Math.floor(Date.now() / 1000); @@ -386,8 +560,6 @@ class ProxyWorker extends EventEmitter { req.startedAt = new Date(); this.remaining -= 1; - const abortController = new AbortController(); - activeAbortController = abortController; req.abortController = abortController; const abortRequest = (): void => abortController.abort(); const abortResponse = (): void => { @@ -485,7 +657,15 @@ class ProxyWorker extends EventEmitter { ); if (!this.destroyed) { - if (!requestDisconnected(req) && !responseUnavailable(res)) { + if (error instanceof PayloadTooLargeError) { + rejectRequest(req, res, 413, { + message: 'Request body too large' + }); + } else if (context.timeoutReason === 'lifetime') { + rejectRequest(req, res, StatusCodes.GATEWAY_TIMEOUT, { + message: 'Request lifetime exceeded' + }); + } else if (!requestDisconnected(req) && !responseUnavailable(res)) { res.status(StatusCodes.BAD_GATEWAY).send(); } else { terminatePartialResponse(req, res); @@ -494,6 +674,11 @@ class ProxyWorker extends EventEmitter { } finally { activeAbortController = undefined; this.scheduledRequests.delete(scheduledRequest); + if (context.state === 'active') { + this.router?.completeWork(context); + if (!this.router) cleanupRequestContext(context); + } + this.ownedContexts.delete(context); settleTask(); } }) @@ -590,17 +775,30 @@ class ProxyWorker extends EventEmitter { this.pullInterval = undefined; this._budgetResetInterval = undefined; - this.router = undefined; this.resourceQueue = undefined; this.checkForWork = undefined; this.removeAllListeners(); this.destroyPromise = (async () => { const errors: unknown[] = []; + this.router?.completeWorkerContexts(this); + + for (const context of [...this.ownedContexts]) { + if (context.worker && context.worker !== this) continue; + context.controller.abort(); + this.router?.completeWork(context); + if (!this.router) cleanupRequestContext(context); + disposeUnreadRequest(context.req); + terminateResponse(context.res); + this.ownedContexts.delete(context); + } - for (const { req, res, settle } of this.scheduledRequests) { + for (const { req, res, context, settle } of this.scheduledRequests) { try { + context.controller.abort(); req.abortController?.abort(); + this.router?.completeWork(context); + disposeUnreadRequest(req); terminateResponse(res); } catch (error) { errors.push(error); @@ -609,6 +807,7 @@ class ProxyWorker extends EventEmitter { } } this.scheduledRequests.clear(); + this.router = undefined; try { await this.agent.destroy(); @@ -634,6 +833,13 @@ class QueueImpl implements RequestQueue { return this.items.shift(); } + remove(req: Request, res: Response): boolean { + const index = this.items.findIndex((item) => item.req === req && item.res === res); + if (index === -1) return false; + this.items.splice(index, 1); + return true; + } + get size(): number { return this.items.length; } @@ -671,6 +877,7 @@ export default class ProxyRouter extends EventEmitter { private destroyed = false; private destroyPromise?: Promise; private readonly removals = new Set>(); + private readonly requestContexts = new Set(); private emitError(error: unknown): void { if (!this.listenerCount('error')) return; @@ -702,7 +909,17 @@ export default class ProxyRouter extends EventEmitter { this.clients = []; this.options = validateProxyRouterOptions( - Object.assign({ requestTimeout: 20000, minRemaining: 100 }, opts) + Object.assign( + { + requestTimeout: 20000, + minRemaining: 100, + maxRequestBodyBytes: REQUEST_BODY_DEFAULT, + maxQueueDepthPerWorker: QUEUE_DEPTH_DEFAULT, + queueWaitTimeout: QUEUE_WAIT_DEFAULT, + requestLifetimeTimeout: REQUEST_LIFETIME_DEFAULT + }, + opts + ) ); // Initialize per-resource queues @@ -716,24 +933,154 @@ export default class ProxyRouter extends EventEmitter { tokens.forEach((token) => this.addToken(token)); } + private resourceFor(req: Request): APIResources { + const isGraphQL = req.path.startsWith('/graphql') && req.method === 'POST'; + const isCodeSearch = req.path.startsWith('/search/code'); + const isSearch = req.path.startsWith('/search'); + return isGraphQL ? 'graphql' : isCodeSearch ? 'code_search' : isSearch ? 'search' : 'core'; + } + + private completeContext(context: RequestContext): void { + this.requestContexts.delete(context); + if (context.state === 'settled') return; + context.worker = undefined; + cleanupRequestContext(context); + } + + completeWork(context: RequestContext): void { + if (context.state === 'settled') return; + if (context.state === 'queued') { + this.queues[this.resourceFor(context.req)].remove(context.req, context.res); + } + this.completeContext(context); + } + + completeWorkerContexts(worker: ProxyWorker): void { + for (const context of [...this.requestContexts]) { + if (context.worker !== worker) continue; + context.controller.abort(); + if (context.state === 'queued') { + this.queues[this.resourceFor(context.req)].remove(context.req, context.res); + } + disposeUnreadRequest(context.req); + terminateResponse(context.res); + this.completeContext(context); + } + } + + private disconnectContext(context: RequestContext): void { + if (context.state === 'queued') { + this.queues[this.resourceFor(context.req)].remove(context.req, context.res); + disposeUnreadRequest(context.req); + this.completeContext(context); + } + } + + private rejectContext( + context: RequestContext, + status: number, + body: Record, + retryAfter?: string + ): void { + if (context.state === 'settled') return; + if (context.state === 'queued') { + this.queues[this.resourceFor(context.req)].remove(context.req, context.res); + } + context.controller.abort(); + rejectRequest(context.req, context.res, status, body, retryAfter); + this.completeContext(context); + } + + private expireQueueContext(context: RequestContext): void { + if (this.destroyed || context.state !== 'queued') return; + context.timeoutReason = 'queue'; + this.rejectContext(context, StatusCodes.GATEWAY_TIMEOUT, { + message: 'Request expired in proxy queue' + }); + } + + private expireLifetime(context: RequestContext): void { + if (this.destroyed || context.state === 'settled') return; + context.timeoutReason = 'lifetime'; + if (context.state === 'queued') { + this.rejectContext(context, StatusCodes.GATEWAY_TIMEOUT, { + message: 'Request lifetime exceeded' + }); + return; + } + this.rejectContext(context, StatusCodes.GATEWAY_TIMEOUT, { + message: 'Request lifetime exceeded' + }); + } + async schedule(req: Request, res: Response): Promise { if (this.destroyed) { terminateResponse(res); return; } - const isGraphQL = req.path.startsWith('/graphql') && req.method === 'POST'; - const isCodeSearch = req.path.startsWith('/search/code'); - const isSearch = req.path.startsWith('/search'); - - const queue = isGraphQL - ? this.queues['graphql'] - : isCodeSearch - ? this.queues['code_search'] - : isSearch - ? this.queues['search'] - : this.queues['core']; + const resource = this.resourceFor(req); + const queue = this.queues[resource]; + const capacity = + this.workersByResource[resource].filter((worker) => !worker.isDestroyed).length * + (this.options.maxQueueDepthPerWorker ?? QUEUE_DEPTH_DEFAULT); + const context = (req as ExtendedRequest).proxyContext; + + if (queue.size >= capacity) { + if (context) { + this.rejectContext( + context, + StatusCodes.SERVICE_UNAVAILABLE, + { + message: 'Proxy queue is full' + }, + '1' + ); + return; + } + rejectRequest( + req as ExtendedRequest, + res, + StatusCodes.SERVICE_UNAVAILABLE, + { + message: 'Proxy queue is full' + }, + '1' + ); + return; + } + const requestContext = + context ?? + createRequestContext(req as ExtendedRequest, res, (disconnectedContext) => + this.disconnectContext(disconnectedContext) + ); + if (requestContext.state === 'settled') return; + if (requestContext.lifetimeDeadline === 0) { + requestContext.lifetimeDeadline = + requestContext.acceptedAt + + (this.options.requestLifetimeTimeout ?? REQUEST_LIFETIME_DEFAULT); + requestContext.queueDeadline = + requestContext.acceptedAt + (this.options.queueWaitTimeout ?? QUEUE_WAIT_DEFAULT); + this.requestContexts.add(requestContext); + const lifetimeDelay = Math.max(0, requestContext.lifetimeDeadline - Date.now()); + requestContext.lifetimeTimer = setTimeout( + () => this.expireLifetime(requestContext), + lifetimeDelay + ).unref(); + } + requestContext.state = 'queued'; + requestContext.worker = undefined; + requestContext.enqueuedAt = Date.now(); + if (requestContext.queueDeadline <= Date.now()) { + this.expireQueueContext(requestContext); + return; + } + if (requestContext.queueTimer) clearTimeout(requestContext.queueTimer); + requestContext.queueTimer = setTimeout( + () => this.expireQueueContext(requestContext), + Math.max(0, requestContext.queueDeadline - Date.now()) + ).unref(); queue.enqueue(req, res); } @@ -1014,6 +1361,10 @@ export default class ProxyRouter extends EventEmitter { let work = queue.dequeue(); while (work) { try { + const context = (work.req as ExtendedRequest).proxyContext; + context?.controller.abort(); + disposeUnreadRequest(work.req as ExtendedRequest); + if (context) this.completeContext(context); terminateResponse(work.res); } catch (error) { errors.push(error); @@ -1022,6 +1373,18 @@ export default class ProxyRouter extends EventEmitter { } } + for (const context of [...this.requestContexts]) { + try { + context.controller.abort(); + disposeUnreadRequest(context.req); + terminateResponse(context.res); + } catch (error) { + errors.push(error); + } finally { + this.completeContext(context); + } + } + const results = await Promise.allSettled([ ...clients.map((client) => this.destroyClient(client)), ...removals From ddc740c5021b9a59322094ea394e9ee8e97f3adf Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Sat, 29 Aug 2026 01:24:13 -0400 Subject: [PATCH 12/26] perf(router): use event-driven dispatch --- .../14-event-driven-dispatcher.md | 68 ++- docs/enhancements/README.md | 3 +- src/router.spec.ts | 387 +++++++++++++++++- src/router.ts | 210 ++++++++-- 4 files changed, 595 insertions(+), 73 deletions(-) diff --git a/docs/enhancements/14-event-driven-dispatcher.md b/docs/enhancements/14-event-driven-dispatcher.md index 78581a0..019dd64 100644 --- a/docs/enhancements/14-event-driven-dispatcher.md +++ b/docs/enhancements/14-event-driven-dispatcher.md @@ -1,30 +1,33 @@ --- id: 14 title: Replace per-worker polling with an event-driven bounded dispatcher -status: planned +status: verified risk: highest urgency: normal scope: scheduling architecture, queue notification, fairness, and bounded dispatch --- -**Status:** Planned; not yet implemented. +**Status:** Verified; focused and full validation are complete. ## Problem -Workers poll every 100ms, enqueueing does not notify workers, queue removal is O(n), and each token -creates four workers. This makes dispatch latency, fairness, and resource behavior harder to bound. +Workers previously polled every 100ms and enqueueing did not notify workers. This made dispatch +latency, fairness, and resource behavior harder to bound. ## Evidence -- Polling is implemented at `src/router.ts:276-287`. -- Queue enqueueing has no notification at `src/router.ts:301-306`. -- Dequeue uses O(n) `shift()` at `src/router.ts:308-310`. -- Four workers per token are created at `src/router.ts:394-409`. +- `ProxyRouter` now owns per-resource dispatch state and schedules one coalesced microtask per + notification burst. +- Workers notify the router when they become available, update rate limits, reset their time budget, + retry work, or complete work. +- Dispatch uses per-resource round-robin selection and atomic worker reservations. +- A single reset wake timer is maintained per resource when all eligible workers are rate-limited. +- Router-owned budget reset scheduling replaces per-worker budget polling. ## Expected benefit Dispatch reacts immediately to available work, has explicit fairness and capacity behavior, and -avoids unnecessary polling and queue scans. +avoids unnecessary polling and repeated dispatch passes. ## Dependencies/decisions @@ -34,15 +37,50 @@ behavior before changing the scheduling architecture. ## Implementation notes -Replace polling with explicit queue/worker notifications and a bounded dispatcher. Preserve resource -routing, rate-limit constraints, cancellation, and intentional retry behavior. Avoid changing all -dispatch semantics in one untested rewrite. +Polling was replaced with explicit queue/worker notifications and a bounded dispatcher. Resource +routing, rate-limit constraints, cancellation, retry behavior, queue capacity, and shared request +contexts remain unchanged. ## Validation plan -Benchmark and test dispatch latency, fairness across tokens/resources, capacity limits, retries, -shutdown, cancellation, and queue saturation. Compare behavior against the bounded queue and -request-lifetime contract from item 13. +Validation covers dispatch latency, round-robin fairness, capacity limits, retries, shutdown, +cancellation, reset wakeups, worker destruction, and queue saturation. The item-14 focused tests +use direct queue inspection, mocked worker scheduling, and fake timers; no network throughput is +measured. + +### Reproducible evidence + +Commands run from the repository root: + +```text +npx vitest run src/router.spec.ts --reporter=dot +npx vitest run src/router.spec.ts -t "enqueue notification|next request immediately|resource dispatch queues|round-robin|atomic concurrency|dispatcher timers|exhausted worker" +``` + +Observed results: + +- Focused dispatcher checks: 7 passed. +- Complete router suite: 75 passed. +- Full project suite: 232 passed across 4 files. +- TypeScript, Biome lint, production build, and `git diff --check`: passed. +- Enqueue dispatch occurs after the next microtask; no 100ms polling advance is required. +- Exhausted-budget fake-timer coverage observed zero schedule/dequeue notifications through 59,999ms; + one dispatch occurred at the 60,000ms budget reset. +- Two-worker round-robin order was exactly `[0, 1, 0, 1]`; the existing multi-token integration + check observed every configured token serving work. +- Saturated capacity returned HTTP 503 with `Retry-After: 1`; adding a token increased capacity, + and removing it restored the bounded worker count. + +Timer accounting after refresh completion is linear only for the existing per-token refresh +intervals: 1, 10, and 100 tokens create respectively 1, 10, and 100 refresh intervals. The +dispatcher itself uses one global budget-reset timer, zero idle resource wake timers, and at most +one rate-reset wake timer per resource; it creates no per-worker polling intervals. The measured +steady-state dispatcher timer count is therefore one for each of 1/10/100 tokens when no resource +is rate-limited (or up to five including four resource wake timers while blocked). + +Baseline commit `6882049` was not benchmarked: it has polling-driven scheduling and no equivalent +deterministic dispatcher boundary, so a wall-clock comparison would conflate polling, network, and +test-harness timing. No throughput claim is made. ## Definition of done diff --git a/docs/enhancements/README.md b/docs/enhancements/README.md index d2076a0..5e6c6c5 100644 --- a/docs/enhancements/README.md +++ b/docs/enhancements/README.md @@ -2,8 +2,7 @@ This dossier records the project-improvement recommendations from the prior analysis. It expands the earlier grouped analysis into 15 actionable work items, ordered from lowest to highest implementation -risk. Recommendations 01 through 13 are **verified**; recommendations 14 through 15 are currently -**planned**. +risk. Recommendations 01 through 14 are **verified**; recommendation 15 is currently **planned**. ## Project baseline diff --git a/src/router.spec.ts b/src/router.spec.ts index ef9369e..8a5199f 100644 --- a/src/router.spec.ts +++ b/src/router.spec.ts @@ -82,15 +82,46 @@ describe('Middleware constructor and methods', () => { } }); + test('it should use token refresh intervals but no per-worker polling intervals', async () => { + vi.useFakeTimers(); + const setInterval = vi.spyOn(globalThis, 'setInterval'); + const middleware = new Middleware([FAKE_TOKEN, SECOND_TOKEN]); + + try { + expect(setInterval).toHaveBeenCalledTimes(2); + const workersByResource = ( + middleware as unknown as { + workersByResource: Record>>; + } + ).workersByResource; + for (const workers of Object.values(workersByResource)) { + for (const worker of workers) { + expect(worker).not.toHaveProperty('pullInterval'); + expect(worker).not.toHaveProperty('checkForWork'); + } + } + } finally { + setInterval.mockRestore(); + await middleware.destroy(); + vi.useRealTimers(); + } + }); + test('it should remove/add tokens', async () => { const middleware = new Middleware([FAKE_TOKEN]); + const notifyDispatch = vi.spyOn( + middleware as unknown as { notifyDispatch: (resource: 'core') => void }, + 'notifyDispatch' + ); expect(middleware.tokens).toHaveLength(1); await middleware.removeToken(FAKE_TOKEN); expect(middleware.tokens).toHaveLength(0); + expect(notifyDispatch).toHaveBeenCalledTimes(4); middleware.addToken(FAKE_TOKEN); expect(middleware.tokens).toHaveLength(1); + expect(notifyDispatch).toHaveBeenCalledTimes(12); return middleware.destroy(); }); @@ -123,6 +154,44 @@ describe('Middleware constructor and methods', () => { } }); + test('it should clear dispatcher timers synchronously during destruction', async () => { + vi.useFakeTimers(); + const middleware = new Middleware([FAKE_TOKEN], { minRemaining: 5000 }); + const requestResponse = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + const state = middleware as unknown as { + dispatchStates: Record; + budgetResetTimer?: NodeJS.Timeout; + dispatch: (resource: 'core') => void; + }; + const dispatch = vi.spyOn(state, 'dispatch'); + + try { + await middleware.schedule(requestResponse.req, requestResponse.res); + await Promise.resolve(); + await Promise.resolve(); + expect(state.dispatchStates.core.resetTimer).toBeDefined(); + + const dispatchCallsBeforeDestroy = dispatch.mock.calls.length; + const destruction = middleware.destroy(); + expect(state.budgetResetTimer).toBeUndefined(); + for (const resource of Object.values(state.dispatchStates)) { + expect(resource.resetTimer).toBeUndefined(); + expect(resource.notificationQueued).toBe(false); + } + await destruction; + vi.advanceTimersByTime(120000); + await Promise.resolve(); + expect(dispatch.mock.calls.length).toBe(dispatchCallsBeforeDestroy); + } finally { + await middleware.destroy(); + vi.useRealTimers(); + } + }); + test('it should terminate queued responses and ignore work after destroy', async () => { const middleware = new Middleware([FAKE_TOKEN], { minRemaining: 5000 }); const response = { @@ -491,13 +560,6 @@ describe('Middleware constructor and methods', () => { }; } ).workersByResource.core[0]; - const queue = ( - middleware as unknown as { - queues: { - core: { dequeue: () => { req: Request; res: Response } | undefined; size: number }; - }; - } - ).queues.core; const { req, res } = createStateAwareRequestResponse({ headersSent: false, writableEnded: false, @@ -514,10 +576,9 @@ describe('Middleware constructor and methods', () => { try { await middleware.schedule(req, res); - const work = queue.dequeue(); - expect(work).toBeDefined(); - const scheduled = worker.schedule(work!.req, work!.res); - await Promise.resolve(); + await waitFor( + () => (worker as unknown as { ownedContexts: Set }).ownedContexts.size === 1 + ); const context = (req as Request & { proxyContext?: { controller: AbortController } }) .proxyContext; expect(context).toBeDefined(); @@ -525,7 +586,6 @@ describe('Middleware constructor and methods', () => { await worker.destroy(); expect(context?.controller.signal.aborted).toBe(true); expect((req as Request & { proxyContext?: unknown }).proxyContext).toBeUndefined(); - await scheduled; } finally { proxy.mockRestore(); await middleware.destroy(); @@ -1158,6 +1218,18 @@ describe('Middleware core', () => { expect(second.status).toHaveBeenCalledWith(StatusCodes.SERVICE_UNAVAILABLE); expect(second.json).toHaveBeenCalledWith({ message: 'Proxy queue is full' }); expect(second.resume).toHaveBeenCalledTimes(1); + + limited.addToken(SECOND_TOKEN); + const third = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + await limited.schedule(third.req, third.res); + expect(third.status).not.toHaveBeenCalled(); + + await limited.removeToken(SECOND_TOKEN); + expect(limited.tokens).toEqual([FAKE_TOKEN]); } finally { await limited.destroy(); } @@ -1252,6 +1324,277 @@ describe('Middleware core', () => { } }); + test('should not spin on an exhausted worker before the budget reset', async () => { + vi.useFakeTimers(); + const limited = new Middleware([FAKE_TOKEN], { + minRemaining: 0, + queueWaitTimeout: 120000, + requestLifetimeTimeout: 120000 + }); + const worker = ( + limited as unknown as { + workersByResource: { + core: Array<{ + remaining: number; + reset: number; + timeBudget: number; + schedule: (req: Request, res: Response) => Promise; + }>; + }; + } + ).workersByResource.core[0]; + const queue = ( + limited as unknown as { + queues: { core: { size: number } }; + } + ).queues.core; + const requestResponse = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + const schedule = vi.spyOn(worker, 'schedule').mockResolvedValue(undefined); + const notifyDispatch = vi.spyOn( + limited as unknown as { notifyDispatch: (resource: 'core') => void }, + 'notifyDispatch' + ); + + try { + await limited.refreshRateLimits(); + worker.remaining = 5000; + worker.reset = 0; + worker.timeBudget = 0; + + await limited.schedule(requestResponse.req, requestResponse.res); + await Promise.resolve(); + await Promise.resolve(); + expect(schedule).not.toHaveBeenCalled(); + expect(queue.size).toBe(1); + const notificationsBeforeAdvance = notifyDispatch.mock.calls.length; + + vi.advanceTimersByTime(59999); + await Promise.resolve(); + expect(schedule).not.toHaveBeenCalled(); + expect(queue.size).toBe(1); + expect(notifyDispatch.mock.calls.length).toBe(notificationsBeforeAdvance); + + vi.advanceTimersByTime(1); + await Promise.resolve(); + await Promise.resolve(); + expect(schedule).toHaveBeenCalledTimes(1); + expect(queue.size).toBe(0); + } finally { + await limited.destroy(); + vi.useRealTimers(); + } + }); + + test('it should dispatch queued work immediately in exact round-robin order', async () => { + const middleware = new Middleware([FAKE_TOKEN, SECOND_TOKEN], { minRemaining: 0 }); + const workers = ( + middleware as unknown as { + workersByResource: { + core: Array<{ + remaining: number; + reset: number; + schedule: (req: Request, res: Response) => Promise; + }>; + }; + queues: { core: { enqueue: (req: Request, res: Response) => void; size: number } }; + dispatch: (resource: 'core') => void; + } + ).workersByResource.core; + const router = middleware as unknown as { + queues: { core: { enqueue: (req: Request, res: Response) => void; size: number } }; + dispatch: (resource: 'core') => void; + }; + const order: number[] = []; + workers.forEach((worker, index) => { + worker.remaining = 5000; + worker.reset = 0; + vi.spyOn(worker, 'schedule').mockImplementation(async () => { + order.push(index); + }); + }); + + try { + for (let index = 0; index < 4; index += 1) { + const requestResponse = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + router.queues.core.enqueue(requestResponse.req, requestResponse.res); + } + router.dispatch('core'); + expect(order).toEqual([0, 1, 0, 1]); + expect(router.queues.core.size).toBe(0); + } finally { + await middleware.destroy(); + } + }); + + test('it should dispatch an enqueue notification without polling delay', async () => { + const middleware = new Middleware([FAKE_TOKEN], { minRemaining: 0 }); + const worker = ( + middleware as unknown as { + workersByResource: { + core: Array<{ + remaining: number; + reset: number; + schedule: (req: Request, res: Response) => Promise; + }>; + }; + } + ).workersByResource.core[0]; + worker.remaining = 5000; + worker.reset = 0; + const schedule = vi.spyOn(worker, 'schedule').mockResolvedValue(undefined); + const requestResponse = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + + try { + await middleware.schedule(requestResponse.req, requestResponse.res); + await Promise.resolve(); + await Promise.resolve(); + expect(schedule).toHaveBeenCalledTimes(1); + } finally { + await middleware.destroy(); + } + }); + + test('it should dispatch the next request immediately after a worker completes', async () => { + const middleware = new Middleware([FAKE_TOKEN], { minRemaining: 0 }); + const worker = ( + middleware as unknown as { + workersByResource: { + search: Array<{ + remaining: number; + reset: number; + proxy: { proxy: (...args: never[]) => Promise }; + }>; + }; + } + ).workersByResource.search[0]; + worker.remaining = 5000; + worker.reset = 0; + let releaseFirst!: () => void; + let markFirstStarted!: () => void; + let calls = 0; + const firstStarted = new Promise((resolve) => { + markFirstStarted = resolve; + }); + vi.spyOn(worker.proxy, 'proxy').mockImplementation(async () => { + calls += 1; + if (calls === 1) { + markFirstStarted(); + await new Promise((resolveFirst) => (releaseFirst = resolveFirst)); + } + }); + const first = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + Object.defineProperty(first.req, 'path', { value: '/search', configurable: true }); + const second = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + Object.defineProperty(second.req, 'path', { value: '/search', configurable: true }); + + try { + await middleware.schedule(first.req, first.res); + await middleware.schedule(second.req, second.res); + await firstStarted; + expect(calls).toBe(1); + releaseFirst(); + await waitFor(() => calls === 2); + } finally { + await middleware.destroy(); + } + }); + + test('it should keep resource dispatch queues independent', async () => { + const middleware = new Middleware([FAKE_TOKEN], { minRemaining: 0 }); + const workers = ( + middleware as unknown as { + workersByResource: { + core: Array<{ remaining: number; reset: number }>; + search: Array<{ + remaining: number; + reset: number; + schedule: (req: Request, res: Response) => Promise; + }>; + }; + } + ).workersByResource; + workers.core[0].remaining = 0; + workers.core[0].reset = Math.floor(Date.now() / 1000) + 60; + workers.search[0].remaining = 5000; + workers.search[0].reset = 0; + const schedule = vi.spyOn(workers.search[0], 'schedule').mockResolvedValue(undefined); + const core = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + const search = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + Object.defineProperty(search.req, 'path', { value: '/search', configurable: true }); + + try { + await middleware.schedule(core.req, core.res); + await middleware.schedule(search.req, search.res); + await Promise.resolve(); + await Promise.resolve(); + expect(schedule).toHaveBeenCalledTimes(1); + } finally { + await middleware.destroy(); + } + }); + + test('it should reserve atomic concurrency slots for each resource', async () => { + const middleware = new Middleware([FAKE_TOKEN], { minRemaining: 0 }); + const workers = ( + middleware as unknown as { + workersByResource: Record< + 'core' | 'search' | 'code_search' | 'graphql', + Array<{ + remaining: number; + reset: number; + timeBudget: number; + reserveWork: () => boolean; + releaseReservation: () => void; + }> + >; + } + ).workersByResource; + + try { + for (const resource of ['core', 'graphql', 'search', 'code_search'] as const) { + const worker = workers[resource][0]; + worker.remaining = 5000; + worker.reset = 0; + worker.timeBudget = 100000; + const limit = resource === 'core' || resource === 'graphql' ? 10 : 1; + const reservations = times(limit, () => worker.reserveWork()); + expect(reservations).toEqual(times(limit, () => true)); + expect(worker.reserveWork()).toBe(false); + times(limit, () => worker.releaseReservation()); + } + } finally { + await middleware.destroy(); + } + }); + test('it should preserve absolute deadlines and clear worker ownership across retries', async () => { const middleware = new Middleware([FAKE_TOKEN], { minRemaining: 0 }); const worker = ( @@ -1317,14 +1660,15 @@ describe('Middleware core', () => { }; } ).workersByResource.core[0]; - workerA.remaining = 0; - workerA.reset = Math.floor(Date.now() / 1000) + 60; + workerA.remaining = 5000; + workerA.reset = 0; const workerB = ( middleware as unknown as { workersByResource: { core: Array<{ remaining: number; reset: number; + applyRateLimit: (limit: { remaining: number; reset: number }) => void; proxy: { proxy: ( req: Request, @@ -1355,6 +1699,12 @@ describe('Middleware core', () => { const startedPromise = new Promise((resolve) => { started = resolve; }); + const originalSchedule = workerA.schedule; + const schedule = vi.spyOn(workerA, 'schedule').mockImplementation((req, res) => { + workerA.remaining = 0; + workerA.reset = Math.floor(Date.now() / 1000) + 60; + return originalSchedule(req, res); + }); try { await middleware.schedule(requestResponse.req, requestResponse.res); @@ -1370,8 +1720,9 @@ describe('Middleware core', () => { ).proxyContext; expect(context).toBeDefined(); const deadlines = [context!.queueDeadline, context!.lifetimeDeadline]; - const work = queue.dequeue(); - await workerA.schedule(work!.req, work!.res); + await waitFor( + () => schedule.mock.calls.length === 1 && queue.size === 1 && context!.worker === undefined + ); expect(queue.size).toBe(1); expect(context!.controller.signal.aborted).toBe(false); expect([context!.queueDeadline, context!.lifetimeDeadline]).toEqual(deadlines); @@ -1395,8 +1746,7 @@ describe('Middleware core', () => { }); try { - workerB.remaining = 5000; - workerB.reset = 0; + workerB.applyRateLimit({ remaining: 5000, reset: 0 }); await startedPromise; expect(observedSignal).toBe(context!.controller.signal); expect([context!.queueDeadline, context!.lifetimeDeadline]).toEqual(deadlines); @@ -1404,6 +1754,7 @@ describe('Middleware core', () => { proxy.mockRestore(); } } finally { + schedule.mockRestore(); await middleware.destroy(); } }); diff --git a/src/router.ts b/src/router.ts index 4396106..6c97678 100644 --- a/src/router.ts +++ b/src/router.ts @@ -62,6 +62,12 @@ type RequestContext = { onDisconnect: () => void; }; +type DispatchState = { + cursor: number; + notificationQueued: boolean; + resetTimer?: NodeJS.Timeout; +}; + function headerString(value: ProxyHeaderValue | undefined): string | undefined { if (value === undefined) return undefined; return Array.isArray(value) ? value.join(', ') : value; @@ -427,10 +433,7 @@ class ProxyWorker extends EventEmitter { private readonly scheduledRequests = new Set(); private readonly ownedContexts = new Set(); private router?: ProxyRouter; - private resourceQueue?: RequestQueue; - private pullInterval?: NodeJS.Timeout; - private _budgetResetInterval?: NodeJS.Timeout; - private checkForWork?: () => Promise; + private reservations = 0; private destroyed = false; private destroyPromise?: Promise; @@ -476,12 +479,6 @@ class ProxyWorker extends EventEmitter { }; } - // Initialize time budget tracking - this._budgetResetInterval = setInterval(() => { - this.timeBudget = - (this.defaults.resource === 'graphql' ? 60000 : 90000) * (opts.timeBudgetMultiplier || 1); - }, 60000).unref(); - this.agent = new Agent({ connections: 20, pipelining: 1, @@ -613,6 +610,7 @@ class ProxyWorker extends EventEmitter { } this.timeBudget -= Date.now() - (req.startedAt?.getTime() || 1000); + this.router?.notifyWorkerAvailability(this); this.log(data.status, req.startedAt); @@ -679,6 +677,7 @@ class ProxyWorker extends EventEmitter { if (!this.router) cleanupRequestContext(context); } this.ownedContexts.delete(context); + queueMicrotask(() => this.router?.notifyWorkerAvailability(this)); settleTask(); } }) @@ -700,6 +699,7 @@ class ProxyWorker extends EventEmitter { this.remaining = limit.remaining; this.reset = limit.reset; this.log(undefined, new Date()); + this.router?.notifyWorkerAvailability(this); } private updateLimits(headers: Record): void { @@ -711,6 +711,7 @@ class ProxyWorker extends EventEmitter { this.remaining = Number.parseInt(headers['x-ratelimit-remaining'], 10) - this.queue.pending; this.reset = Number.parseInt(headers['x-ratelimit-reset'], 10); } + this.router?.notifyWorkerAvailability(this); } private log(status?: number | string, startedAt?: Date): void { @@ -732,31 +733,37 @@ class ProxyWorker extends EventEmitter { if (this.destroyed) return false; return ( - this.queue.pending < (this.queue.concurrency ?? 1) && - this.timeBudget >= this.queue.pending * 1000 && - (this.remaining > this.opts.minRemaining || this.reset * 1000 < Date.now()) + this.queue.pending + this.reservations < (this.queue.concurrency ?? 1) && + this.timeBudget >= (this.queue.pending + this.reservations + 1) * 1000 && + (this.remaining > this.opts.minRemaining || this.reset < Math.floor(Date.now() / 1000)) ); } - setRouter(router: ProxyRouter): void { - if (this.destroyed) return; + reserveWork(): boolean { + if (!this.canAcceptWork()) return false; + this.reservations += 1; + return true; + } - this.router = router; - this.resourceQueue = router.getQueue(this.defaults.resource); - this.startPullLoop(); + releaseReservation(): void { + if (!this.reservations) return; + this.reservations -= 1; + this.router?.notifyWorkerAvailability(this); } - private startPullLoop(): void { - if (this.destroyed || !this.router || !this.resourceQueue) return; + resetTimeBudget(): void { + if (this.destroyed) return; + this.timeBudget = + (this.defaults.resource === 'graphql' ? 60000 : 90000) * + (this.opts.timeBudgetMultiplier || 1); + this.router?.notifyWorkerAvailability(this); + } - this.checkForWork = async () => { - if (this.destroyed || !this.canAcceptWork() || !this.resourceQueue) return; - const work = this.resourceQueue.dequeue(); - if (work) await this.schedule(work.req, work.res); - }; + setRouter(router: ProxyRouter): void { + if (this.destroyed) return; - // Fallback polling every 100ms for missed events - this.pullInterval = setInterval(this.checkForWork, 100).unref(); + this.router = router; + router.notifyWorkerAvailability(this); } get isDestroyed(): boolean { @@ -770,13 +777,6 @@ class ProxyWorker extends EventEmitter { this.queue.pause(); this.queue.clear(); - if (this.pullInterval) clearInterval(this.pullInterval); - if (this._budgetResetInterval) clearInterval(this._budgetResetInterval); - this.pullInterval = undefined; - this._budgetResetInterval = undefined; - - this.resourceQueue = undefined; - this.checkForWork = undefined; this.removeAllListeners(); this.destroyPromise = (async () => { @@ -878,6 +878,13 @@ export default class ProxyRouter extends EventEmitter { private destroyPromise?: Promise; private readonly removals = new Set>(); private readonly requestContexts = new Set(); + private budgetResetTimer?: NodeJS.Timeout; + private readonly dispatchStates: Record = { + core: { cursor: 0, notificationQueued: false }, + search: { cursor: 0, notificationQueued: false }, + code_search: { cursor: 0, notificationQueued: false }, + graphql: { cursor: 0, notificationQueued: false } + }; private emitError(error: unknown): void { if (!this.listenerCount('error')) return; @@ -931,6 +938,117 @@ export default class ProxyRouter extends EventEmitter { }; tokens.forEach((token) => this.addToken(token)); + this.armBudgetResetTimer(); + } + + notifyWorkerAvailability(worker: ProxyWorker): void { + if (this.destroyed) return; + this.notifyDispatch(worker.defaults.resource); + } + + private notifyDispatch(resource: APIResources): void { + if (this.destroyed) return; + const state = this.dispatchStates[resource]; + if (state.notificationQueued) return; + state.notificationQueued = true; + queueMicrotask(() => { + state.notificationQueued = false; + if (!this.destroyed) this.dispatch(resource); + }); + } + + private clearDispatchTimers(): void { + if (this.budgetResetTimer) clearTimeout(this.budgetResetTimer); + this.budgetResetTimer = undefined; + + for (const state of Object.values(this.dispatchStates)) { + if (state.resetTimer) clearTimeout(state.resetTimer); + state.resetTimer = undefined; + state.notificationQueued = false; + } + } + + private dispatch(resource: APIResources): void { + if (this.destroyed) return; + const state = this.dispatchStates[resource]; + const queue = this.queues[resource]; + if (!queue.size) { + if (state.resetTimer) clearTimeout(state.resetTimer); + state.resetTimer = undefined; + return; + } + + while (queue.size) { + const workers = this.workersByResource[resource].filter((worker) => !worker.isDestroyed); + if (!workers.length) { + if (state.resetTimer) clearTimeout(state.resetTimer); + state.resetTimer = undefined; + return; + } + + let selected: ProxyWorker | undefined; + for (let offset = 0; offset < workers.length; offset += 1) { + const index = (state.cursor + offset) % workers.length; + const worker = workers[index]; + if (worker.reserveWork()) { + selected = worker; + state.cursor = (index + 1) % workers.length; + break; + } + } + + if (!selected) { + this.armResetWake(resource, workers); + return; + } + + const work = queue.dequeue(); + if (!work) { + selected.releaseReservation(); + return; + } + void selected.schedule(work.req as ExtendedRequest, work.res); + selected.releaseReservation(); + } + + if (state.resetTimer) clearTimeout(state.resetTimer); + state.resetTimer = undefined; + } + + private armResetWake(resource: APIResources, workers: ProxyWorker[]): void { + const state = this.dispatchStates[resource]; + if (state.resetTimer) clearTimeout(state.resetTimer); + const now = Date.now(); + const earliest = workers.reduce((minimum, worker) => { + const resetAt = (worker.reset + 1) * 1000; + return worker.remaining <= (this.options.minRemaining ?? 0) && resetAt > now + ? Math.min(minimum, resetAt) + : minimum; + }, Number.POSITIVE_INFINITY); + if (!Number.isFinite(earliest)) { + state.resetTimer = undefined; + return; + } + state.resetTimer = setTimeout( + () => { + state.resetTimer = undefined; + this.notifyDispatch(resource); + }, + Math.max(1, earliest - now) + ).unref(); + } + + private armBudgetResetTimer(): void { + if (this.destroyed) return; + if (this.budgetResetTimer) clearTimeout(this.budgetResetTimer); + this.budgetResetTimer = setTimeout(() => { + this.budgetResetTimer = undefined; + if (this.destroyed) return; + for (const workers of Object.values(this.workersByResource)) { + for (const worker of workers) worker.resetTimeBudget(); + } + this.armBudgetResetTimer(); + }, 60000).unref(); } private resourceFor(req: Request): APIResources { @@ -950,7 +1068,9 @@ export default class ProxyRouter extends EventEmitter { completeWork(context: RequestContext): void { if (context.state === 'settled') return; if (context.state === 'queued') { - this.queues[this.resourceFor(context.req)].remove(context.req, context.res); + const resource = this.resourceFor(context.req); + this.queues[resource].remove(context.req, context.res); + this.notifyDispatch(resource); } this.completeContext(context); } @@ -970,9 +1090,11 @@ export default class ProxyRouter extends EventEmitter { private disconnectContext(context: RequestContext): void { if (context.state === 'queued') { - this.queues[this.resourceFor(context.req)].remove(context.req, context.res); + const resource = this.resourceFor(context.req); + this.queues[resource].remove(context.req, context.res); disposeUnreadRequest(context.req); this.completeContext(context); + this.notifyDispatch(resource); } } @@ -983,12 +1105,14 @@ export default class ProxyRouter extends EventEmitter { retryAfter?: string ): void { if (context.state === 'settled') return; + const resource = this.resourceFor(context.req); if (context.state === 'queued') { - this.queues[this.resourceFor(context.req)].remove(context.req, context.res); + this.queues[resource].remove(context.req, context.res); } context.controller.abort(); rejectRequest(context.req, context.res, status, body, retryAfter); this.completeContext(context); + this.notifyDispatch(resource); } private expireQueueContext(context: RequestContext): void { @@ -1082,6 +1206,7 @@ export default class ProxyRouter extends EventEmitter { Math.max(0, requestContext.queueDeadline - Date.now()) ).unref(); queue.enqueue(req, res); + this.notifyDispatch(resource); } getQueue(resource: APIResources): RequestQueue { @@ -1105,7 +1230,7 @@ export default class ProxyRouter extends EventEmitter { worker.on('retry', (req: ExtendedRequest, res: Response) => this.schedule(req, res)); worker.on('log', (log: WorkerLogger) => this.emit('log', log)); worker.on('warn', (message: string) => this.emit('warn', message)); - // Phase 3: Set router reference to enable pull mechanism + // Workers notify the router when their scheduling state changes. worker.setRouter(this); } @@ -1125,6 +1250,10 @@ export default class ProxyRouter extends EventEmitter { this.workersByResource.code_search.push(codeSearch); this.workersByResource.graphql.push(graphql); + for (const resource of ['core', 'search', 'code_search', 'graphql'] as APIResources[]) { + this.notifyDispatch(resource); + } + this.startDetachedRefresh(client, true); // Auto-refresh rate limits every 15 minutes, once per token. client.refreshTimers.push( @@ -1309,6 +1438,10 @@ export default class ProxyRouter extends EventEmitter { worker.removeAllListeners(); } + for (const resource of ['core', 'search', 'code_search', 'graphql'] as APIResources[]) { + this.notifyDispatch(resource); + } + const error = disposalError(errors, 'Proxy token destruction failed'); if (error) throw error; } @@ -1351,6 +1484,7 @@ export default class ProxyRouter extends EventEmitter { if (this.destroyPromise) return this.destroyPromise; this.destroyed = true; + this.clearDispatchTimers(); const clients = this.clients.splice(0); const removals = [...this.removals]; From ca37d3f0b28ed3f2e8744e45a227d6e9b367a35d Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Sat, 29 Aug 2026 01:33:40 -0400 Subject: [PATCH 13/26] docs: align developer guidance --- .husky/pre-commit | 4 +- AGENTS.md | 2 +- README.md | 80 +++++++++++-------- biome.json | 2 +- .../15-documentation-developer-experience.md | 22 ++--- docs/enhancements/README.md | 2 +- src/cli.ts | 4 +- 7 files changed, 68 insertions(+), 48 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 5e5b976..a6a0450 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,2 @@ -npm run lint -npm run build \ No newline at end of file +yarn lint +yarn build diff --git a/AGENTS.md b/AGENTS.md index 79993a2..6aeadf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ npm run format # Format code with Biome ``` ### Git Hooks -Pre-commit: Runs `npm run lint` and `npm run build` +Pre-commit: Runs `yarn lint` and `yarn build` Commit-msg: Validates commit messages using commitlint (conventional commits) ### Release diff --git a/README.md b/README.md index f26f6cd..63d3e56 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,23 @@ # 🖥️ GitHub Proxy Server -[![Build + S3 sync](https://github.com/gittrends-app/github-proxy-server/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/gittrends-app/github-proxy-server/actions/workflows/build.yml) +[![CI](https://github.com/gittrends-app/github-proxy-server/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/gittrends-app/github-proxy-server/actions/workflows/ci.yml) [![Coverage Status](https://coveralls.io/repos/github/gittrends-app/github-proxy-server/badge.svg)](https://coveralls.io/github/gittrends-app/github-proxy-server) [![GitHub version](https://badge.fury.io/gh/gittrends-app%2Fgithub-proxy-server.svg)](https://badge.fury.io/gh/gittrends-app%2Fgithub-proxy-server) ![GitHub](https://img.shields.io/github/license/gittrends-app/github-proxy-server)
-> GitHub Proxy Server is a tool to support developers and researchers collect massive amount of data from GitHub API (REST or GraphQL) by automatically managing access tokens and client requests to avoid triggering the GitHub API abuse detection mechanisms. +> GitHub Proxy Server is a tool to support developers and researchers collecting massive amounts of data from the GitHub API (REST or GraphQL) by automatically managing access tokens and client requests to avoid triggering GitHub API abuse-detection mechanisms.
-**Why should I use it?** GitHub API has a limited number of requests per client and implements several mechanisms to detect user abuses. Thus, users must handle these restrictions in their applications. GitHub Proxy Server is a tool that abstracts these problems by means of a proxy server. +**Why should I use it?** The GitHub API has a limited number of requests per client and implements several mechanisms to detect abuse. Thus, users must handle these restrictions in their applications. GitHub Proxy Server is a tool that abstracts these problems by means of a proxy server. -**When should I use it?** This tool is intended to be used by developers and researches that need to perform massive data collection of public repositories using both REST and GraphQL APIs. +**When should I use it?** This tool is intended to be used by developers and researchers who need to perform massive data collection from public repositories using both REST and GraphQL APIs. **When should I not use it?** If you need to deal with private information of users and repositories this tool is not for you (see [limitations section](#limitations)). -**Can I use it with other libs?** Yes, as long they allow the users setup the proxy server as base url (see [samples](samples)). +**Can I use it with other libs?** Yes, as long as they allow users to set up the proxy server as a base URL (see [samples](samples)). **How it works?** @@ -28,7 +28,7 @@ ## Features -- Support to multiple access tokens +- Support for multiple access tokens - Load balancing @@ -46,10 +46,10 @@ First, you need to clone the repository: git clone https://github.com/gittrends-app/github-proxy-server.git ``` -Then, install dependencies, build files, and run the server: +Node.js >=24 and Yarn 1.22.22 are required. Then, install dependencies, build files, and run the server: ```bash -yarn install +yarn install --frozen-lockfile yarn build yarn start --help ``` @@ -69,7 +69,7 @@ To use this tool you need to provide at least one GitHub access token: github-proxy-server -p 3000 -t ``` -Or provide a file with several access token (one per line): +Or provide a file with several access tokens (one per line): ```bash github-proxy-server -p 3000 --tokens @@ -130,37 +130,37 @@ when credentials or traffic cross an untrusted network. A local or private-netwo check may continue to use `http://localhost:3000/status`; external health checks should use the trusted HTTPS endpoint. -To more usage information, use the option `--help`. +For more information, use the option `--help`. ```bash -Usage: index [options] +Usage: cli [options] Options: - -p, --port [port] Port to start the proxy server (default: 3000, env: PORT) - -t, --token [token] GitHub token to be used (default: []) - --tokens [file] File containing a list of tokens (env: GPS_TOKENS_FILE) - --request-timeout [timeout] Request timeout (ms) (default: 30000, env: GPS_REQUEST_TIMEOUT) - --min-remaining Stop using token on a minimum of (default: 100, env: GPS_MIN_REMAINING) - --max-request-body-bytes [bytes] Maximum request body size (bytes) (default: 1048576, env: GPS_MAX_REQUEST_BODY_BYTES) - --max-queue-depth [depth] Maximum queued requests per worker (default: 50, env: GPS_MAX_QUEUE_DEPTH) - --queue-wait-timeout [timeout] Maximum queue wait (ms) (default: 30000, env: GPS_QUEUE_WAIT_TIMEOUT) - --request-lifetime-timeout [timeout] Maximum request lifetime (ms) (default: 120000, env: GPS_REQUEST_LIFETIME_TIMEOUT) - --time-budget-multiplier [multiplier] Time budget multiplier (>= 1.0) (default: 1, env: GPS_TIME_BUDGET_MULTIPLIER) - --external-base-url Trusted external HTTP(S) base URL (env: GPS_EXTERNAL_BASE_URL) - --silent Dont show requests outputs (env: GPS_SILENT) - --no-override-authorization By default, the authorization header is overrided with a configured token - --auth-username [username] Proxy authentication username (env: GPS_AUTH_USERNAME) - --auth-password [password] Proxy authentication password (env: GPS_AUTH_PASSWORD) - --no-status-monitor Disable requests monitoring on /metrics - -v, --version output the current version - -h, --help display help for command + -p, --port [port] Port to start the proxy server (default: 3000, env: PORT) + -t, --token [token] GitHub token to be used (default: []) + --tokens [file] File containing a list of tokens (env: GPS_TOKENS_FILE) + --request-timeout [timeout] Request timeout (ms) (default: 30000, env: GPS_REQUEST_TIMEOUT) + --min-remaining Stop using token on a minimum of (default: 100, env: GPS_MIN_REMAINING) + --max-request-body-bytes [bytes] Maximum request body size (bytes) (default: 1048576, env: GPS_MAX_REQUEST_BODY_BYTES) + --max-queue-depth [depth] Maximum queued requests per worker (default: 50, env: GPS_MAX_QUEUE_DEPTH) + --queue-wait-timeout [timeout] Maximum queue wait (ms) (default: 30000, env: GPS_QUEUE_WAIT_TIMEOUT) + --request-lifetime-timeout [timeout] Maximum request lifetime (ms) (default: 120000, env: GPS_REQUEST_LIFETIME_TIMEOUT) + --time-budget-multiplier [multiplier] Time budget multiplier (>= 1.0) (default: 1, env: GPS_TIME_BUDGET_MULTIPLIER) + --external-base-url Trusted external HTTP(S) base URL (env: GPS_EXTERNAL_BASE_URL) + --silent Don't show request output (env: GPS_SILENT) + --no-override-authorization By default, the authorization header is overridden with a configured token + --auth-username [username] Proxy authentication username (env: GPS_AUTH_USERNAME) + --auth-password [password] Proxy authentication password (env: GPS_AUTH_PASSWORD) + --no-status-monitor Disable requests monitoring on /metrics + -v, --version output the current version + -h, --help display help for command ``` ## Limitations -GitHub Proxy Server was primarly intended to be a tool to support massive data collection of public repositories and users. To this purpose, we use a pool of access tokens to proxy requests to GitHub servers. For each request, we select the token with the lowest queue size and with more requests available. +GitHub Proxy Server was primarily intended to support massive data collection from public repositories and users. For this purpose, we use a pool of access tokens to proxy requests to GitHub servers. Requests are routed to a per-resource FIFO queue, then an event-driven dispatcher assigns them in round-robin order among eligible workers for that resource. -Besides that, **we do not perform any verification on the clients requests, which may implies in security issues for the users who provided their tokens**. +Besides that, **we do not perform any verification on clients' requests, which may imply security issues for users who provided their tokens**. To mitigate this problem, you can: @@ -170,12 +170,28 @@ To mitigate this problem, you can: ## Integrations -As mentioned, this tool can be used with serveral other libraries. You can find several examples in [samples](samples) folder. +As mentioned, this tool can be used with several other libraries. You can find several examples in the [samples](samples) folder. ## Contributing Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. +### Development checks + +Use Yarn 1.22.22 with Node.js >=24: + +```bash +yarn install --frozen-lockfile +yarn test +yarn lint +npx tsc --noEmit +yarn build +``` + +The `.husky/pre-commit` hook runs `yarn lint` and `yarn build` automatically. Biome intentionally +enables only the current limited `noConsole` and `noExplicitAny` checks; other recommended rules +are not enabled by this project. + ## License [MIT](https://choosealicense.com/licenses/mit/) diff --git a/biome.json b/biome.json index 4b003fe..899a13b 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/docs/enhancements/15-documentation-developer-experience.md b/docs/enhancements/15-documentation-developer-experience.md index 4d5754e..eb91831 100644 --- a/docs/enhancements/15-documentation-developer-experience.md +++ b/docs/enhancements/15-documentation-developer-experience.md @@ -1,13 +1,13 @@ --- id: 15 title: Documentation and developer-experience cleanup -status: planned +status: verified risk: low urgency: optional scope: README, CLI help, package-manager guidance, hooks, and Biome configuration --- -**Status:** Planned; not yet implemented. **Priority:** Optional cleanup. +**Status:** Verified; final review and validation are complete. **Priority:** Optional cleanup. ## Problem @@ -18,8 +18,8 @@ mislead contributors or operators, but do not require product behavior changes. - The README badge is at `README.md:3`. - Package-manager commands are at `README.md:49-55`. -- CLI help text includes the relevant wording at `src/cli.ts:64` and `src/cli.ts:68`. -- README wording appears at `README.md:118-120`. +- CLI help text includes the relevant wording at `src/cli.ts:112-116`. +- The synchronized CLI help snapshot appears at `README.md:133-157`. - The pre-commit hook is at `.husky/pre-commit:1-2`. - Limited Biome rules are configured at `biome.json:25-33`. @@ -35,14 +35,18 @@ earlier items. Keep scope limited to documentation and developer-experience cons ## Implementation notes -Refresh stale badges and package commands, correct CLI/README wording, review hook behavior, and -document only Biome rules that the project intentionally supports. Do not use this item to hide -failures or perform a broad formatting rewrite. +The README now references the existing `ci.yml` workflow, uses Yarn 1.22.22 with Node.js >=24, and +documents frozen installation, test, lint, typecheck, build, and pre-commit checks. CLI help wording +was corrected without changing option names, defaults, parsing, or runtime behavior. The hook now +uses the same Yarn commands as the contributor instructions. The Biome schema URL matches the +locked Biome 2.3.11 tool, while its intentionally limited `noConsole` and `noExplicitAny` rules are +unchanged. No dependencies, workflows, public options, or product behavior were changed. ## Validation plan -Verify every documented command against the selected package manager, inspect CLI help output, and -run the existing hook/lint checks after any configuration change. +Verified with Yarn 1.22.22 and Node.js v24.19.0 using `yarn test`, `yarn lint`, `npx tsc --noEmit`, +`yarn build`, `node dist/cli.js --help`, and `git diff --check`. The generated help output was +compared with the synchronized README snapshot; the full test suite passed with 232 tests. ## Definition of done diff --git a/docs/enhancements/README.md b/docs/enhancements/README.md index 5e6c6c5..211367c 100644 --- a/docs/enhancements/README.md +++ b/docs/enhancements/README.md @@ -2,7 +2,7 @@ This dossier records the project-improvement recommendations from the prior analysis. It expands the earlier grouped analysis into 15 actionable work items, ordered from lowest to highest implementation -risk. Recommendations 01 through 14 are **verified**; recommendation 15 is currently **planned**. +risk. Recommendations 01 through 15 are **verified**. ## Project baseline diff --git a/src/cli.ts b/src/cli.ts index 4d82e54..8f7aa29 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -109,11 +109,11 @@ export function createCli(): Command { .argParser(parseExternalBaseUrl) .env('GPS_EXTERNAL_BASE_URL') ) - .addOption(new Option('--silent', 'Dont show requests outputs').env('GPS_SILENT')) + .addOption(new Option('--silent', "Don't show request output").env('GPS_SILENT')) .addOption( new Option( '--no-override-authorization', - 'By default, the authorization header is overrided with a configured token' + 'By default, the authorization header is overridden with a configured token' ) ) .addOption( From b480eaba2e6ee191730d48aacd1c23653265f2d6 Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Sat, 29 Aug 2026 09:31:03 -0400 Subject: [PATCH 14/26] chore(deps): update safe dependencies Keep lint passing after the Biome formatter upgrade. --- package.json | 36 +- src/cli.spec.ts | 16 +- src/cli.ts | 2 +- src/proxy-client.spec.ts | 2 +- src/proxy-client.ts | 2 +- src/router.spec.ts | 137 ++-- src/router.ts | 2 +- src/server.spec.ts | 2 +- src/server.ts | 2 +- yarn.lock | 1377 ++++++++++++++++++++++++-------------- 10 files changed, 979 insertions(+), 599 deletions(-) diff --git a/package.json b/package.json index 58762c6..25de176 100644 --- a/package.json +++ b/package.json @@ -39,51 +39,51 @@ "dependencies": { "basic-auth": "^2.0.1", "chalk": "5.6.2", - "commander": "^14.0.2", + "commander": "^14.0.3", "compression": "^1.8.1", "consola": "^3.4.2", - "dayjs": "^1.11.19", + "dayjs": "^1.11.23", "express": "^5.2.1", "http-status-codes": "^2.3.0", "ip": "^2.0.1", - "lodash": "^4.17.21", - "p-limit": "^7.2.0", + "lodash": "^4.18.1", + "p-limit": "^7.3.1", "p-queue": "^8.0.1", - "pino": "^10.2.0", + "pino": "^10.3.1", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", "prom-client": "^14.2.0", "swagger-stats": "^0.99.7", "table": "^6.9.0", - "undici": "^7.18.2" + "undici": "^7.29.0" }, "devDependencies": { - "@biomejs/biome": "^2.3.11", - "@commitlint/cli": "^20.3.1", - "@commitlint/config-conventional": "^20.3.1", - "@tsconfig/node20": "^20.1.8", + "@biomejs/biome": "^2.5.11", + "@commitlint/cli": "^20.5.3", + "@commitlint/config-conventional": "^20.5.3", + "@tsconfig/node20": "^20.1.10", "@types/async": "^3.2.25", "@types/basic-auth": "^1.1.8", "@types/compression": "^1.8.1", "@types/ip": "^1.1.3", - "@types/lodash": "^4.17.23", - "@types/node": "^25.0.9", + "@types/lodash": "^4.17.25", + "@types/node": "^25.9.5", "@types/supertest": "^6.0.3", "@types/swagger-stats": "^0.95.11", - "@vitest/coverage-v8": "^4.0.17", - "commitizen": "^4.3.1", + "@vitest/coverage-v8": "^4.1.11", + "commitizen": "^4.3.2", "cz-conventional-changelog": "3.3.0", "husky": "^9.1.7", - "nock": "^14.0.10", - "np": "^10.2.0", + "nock": "^14.0.17", + "np": "^10.3.0", "shx": "^0.4.0", "standard-version": "^9.5.0", "supertest": "^7.2.2", "tmp-promise": "^3.0.3", "tsup": "^8.5.1", - "tsx": "^4.21.0", + "tsx": "^4.23.12", "typescript": "^5.9.3", - "vitest": "^4.0.17" + "vitest": "^4.1.11" }, "config": { "commitizen": { diff --git a/src/cli.spec.ts b/src/cli.spec.ts index 524cbea..f47a1ee 100644 --- a/src/cli.spec.ts +++ b/src/cli.spec.ts @@ -426,14 +426,12 @@ describe('External base URL validation', () => { expect(parseExternalBaseUrl(undefined)).toBeUndefined(); }); - test.each([ - 'ftp://proxy.example', - '//proxy.example', - 'not-a-url', - 'https://proxy.example/?x=1' - ])('should reject invalid external base URL %s', (value) => { - expect(() => parseExternalBaseUrl(value)).toThrow('Invalid externalBaseUrl'); - }); + test.each(['ftp://proxy.example', '//proxy.example', 'not-a-url', 'https://proxy.example/?x=1'])( + 'should reject invalid external base URL %s', + (value) => { + expect(() => parseExternalBaseUrl(value)).toThrow('Invalid externalBaseUrl'); + } + ); }); describe('Helper Functions - concatTokens', () => { @@ -657,4 +655,4 @@ describe('CLI flag combinations', () => { const versionOption = program.options.find((opt) => opt.long === '--version'); expect(versionOption?.short).toBe('-v'); }); -}); +}); \ No newline at end of file diff --git a/src/cli.ts b/src/cli.ts index 8f7aa29..136925e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -292,4 +292,4 @@ export function createCli(): Command { // parse arguments from command line if (import.meta.url === pathToFileURL(process.argv[1]).href) { createCli().parse(process.argv); -} +} \ No newline at end of file diff --git a/src/proxy-client.spec.ts b/src/proxy-client.spec.ts index c44bc11..c4a5288 100644 --- a/src/proxy-client.spec.ts +++ b/src/proxy-client.spec.ts @@ -786,4 +786,4 @@ function createMockRequestResponse( } as unknown as ServerResponse; return { req, res }; -} +} \ No newline at end of file diff --git a/src/proxy-client.ts b/src/proxy-client.ts index 5dc8312..4a1e51f 100644 --- a/src/proxy-client.ts +++ b/src/proxy-client.ts @@ -374,4 +374,4 @@ export class ProxyClient { signal.addEventListener('abort', onAbort, { once: true }); }); } -} +} \ No newline at end of file diff --git a/src/router.spec.ts b/src/router.spec.ts index 8a5199f..84d7fa2 100644 --- a/src/router.spec.ts +++ b/src/router.spec.ts @@ -725,66 +725,69 @@ describe('Middleware constructor and methods', () => { test.each([ ['completed', { headersSent: false, writableEnded: true, destroyed: false }], ['disconnected', { headersSent: false, writableEnded: false, destroyed: false }] - ])('should not write or destroy a %s response on active lifetime expiry', async (_name, state) => { - const middleware = new Middleware([FAKE_TOKEN], { - minRemaining: 0, - requestLifetimeTimeout: 250, - queueWaitTimeout: 1000 - }); - const worker = ( - middleware as unknown as { - workersByResource: { - core: Array<{ - remaining: number; - reset: number; - proxy: { - proxy: ( - req: Request, - res: Response, - options?: { abortController?: AbortController } - ) => Promise; - }; - }>; - }; - } - ).workersByResource.core[0]; - worker.remaining = 5000; - worker.reset = 0; - const requestResponse = createStateAwareRequestResponse({ - headersSent: false, - writableEnded: false, - destroyed: false - }); - let started!: () => void; - const startedPromise = new Promise((resolve) => { - started = resolve; - }); - const proxy = vi.spyOn(worker.proxy, 'proxy').mockImplementation((req, res, options) => { - if (_name === 'disconnected') req.aborted = true; - if (_name === 'completed') { - (res as unknown as { writableEnded: boolean }).writableEnded = true; - } - started(); - return new Promise((_resolve, reject) => { - options?.abortController?.signal.addEventListener( - 'abort', - () => reject(new DOMException('The operation was aborted', 'AbortError')), - { once: true } - ); + ])( + 'should not write or destroy a %s response on active lifetime expiry', + async (_name, state) => { + const middleware = new Middleware([FAKE_TOKEN], { + minRemaining: 0, + requestLifetimeTimeout: 250, + queueWaitTimeout: 1000 + }); + const worker = ( + middleware as unknown as { + workersByResource: { + core: Array<{ + remaining: number; + reset: number; + proxy: { + proxy: ( + req: Request, + res: Response, + options?: { abortController?: AbortController } + ) => Promise; + }; + }>; + }; + } + ).workersByResource.core[0]; + worker.remaining = 5000; + worker.reset = 0; + const requestResponse = createStateAwareRequestResponse({ + headersSent: false, + writableEnded: false, + destroyed: false + }); + let started!: () => void; + const startedPromise = new Promise((resolve) => { + started = resolve; + }); + const proxy = vi.spyOn(worker.proxy, 'proxy').mockImplementation((req, res, options) => { + if (_name === 'disconnected') req.aborted = true; + if (_name === 'completed') { + (res as unknown as { writableEnded: boolean }).writableEnded = true; + } + started(); + return new Promise((_resolve, reject) => { + options?.abortController?.signal.addEventListener( + 'abort', + () => reject(new DOMException('The operation was aborted', 'AbortError')), + { once: true } + ); + }); }); - }); - try { - await middleware.schedule(requestResponse.req, requestResponse.res); - await startedPromise; - await new Promise((resolve) => setTimeout(resolve, 300)); - expect(requestResponse.json).not.toHaveBeenCalled(); - expect(requestResponse.destroy).not.toHaveBeenCalled(); - } finally { - proxy.mockRestore(); - await middleware.destroy(); + try { + await middleware.schedule(requestResponse.req, requestResponse.res); + await startedPromise; + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(requestResponse.json).not.toHaveBeenCalled(); + expect(requestResponse.destroy).not.toHaveBeenCalled(); + } finally { + proxy.mockRestore(); + await middleware.destroy(); + } } - }); + ); test('should destroy a partial response when active lifetime expires', async () => { const middleware = new Middleware([FAKE_TOKEN]); @@ -1184,16 +1187,14 @@ describe('Middleware core', () => { }); }); - test.each([ - 'ftp://proxy.example', - '//proxy.example', - 'not-a-url', - 'https://proxy.example/?x=1' - ])('it should reject invalid external base URL %s', (externalBaseUrl) => { - expect(() => new Middleware([FAKE_TOKEN], { externalBaseUrl })).toThrow( - 'Invalid externalBaseUrl' - ); - }); + test.each(['ftp://proxy.example', '//proxy.example', 'not-a-url', 'https://proxy.example/?x=1'])( + 'it should reject invalid external base URL %s', + (externalBaseUrl) => { + expect(() => new Middleware([FAKE_TOKEN], { externalBaseUrl })).toThrow( + 'Invalid externalBaseUrl' + ); + } + ); test('it should reject requests when the shared resource queue is full', async () => { const limited = new Middleware([FAKE_TOKEN], { @@ -2043,4 +2044,4 @@ describe('Middleware core', () => { }); }); }); -}); +}); \ No newline at end of file diff --git a/src/router.ts b/src/router.ts index 6c97678..598fcae 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1536,4 +1536,4 @@ export default class ProxyRouter extends EventEmitter { return this.destroyPromise; } -} +} \ No newline at end of file diff --git a/src/server.spec.ts b/src/server.spec.ts index 2041051..cd6d071 100644 --- a/src/server.spec.ts +++ b/src/server.spec.ts @@ -304,4 +304,4 @@ describe('Test proxy authentication', () => { const app = createTestApp({ ...params, auth: undefined }); await request(app).get('/').expect(StatusCodes.OK); }); -}); +}); \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index a507764..2560b3b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -218,4 +218,4 @@ export function createProxyServer(options: CliOpts): ProxyServer { proxyApp.destroy = (): Promise => proxy.destroy(); return proxyApp; -} +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 6402a17..6007b0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10,20 +10,20 @@ "@babel/highlight" "^7.24.7" picocolors "^1.0.0" -"@babel/helper-string-parser@^7.27.1": - version "7.27.1" - resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz" - integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== +"@babel/helper-string-parser@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f" + integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw== "@babel/helper-validator-identifier@^7.24.7": version "7.24.7" resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz" integrity sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w== -"@babel/helper-validator-identifier@^7.28.5": - version "7.28.5" - resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz" - integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== "@babel/highlight@^7.24.7": version "7.24.7" @@ -35,108 +35,100 @@ js-tokens "^4.0.0" picocolors "^1.0.0" -"@babel/parser@^7.28.5": - version "7.28.6" - resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz" - integrity sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ== +"@babel/parser@^7.29.7": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.8.tgz#9653716a2f10c677b98fbc63d4bfb000c302cf17" + integrity sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA== dependencies: - "@babel/types" "^7.28.6" + "@babel/types" "^7.29.8" -"@babel/types@^7.28.5": - version "7.28.5" - resolved "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz" - integrity sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA== +"@babel/types@^7.29.7", "@babel/types@^7.29.8": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.8.tgz#1229eef31d85156d70fa3f4cd859376d0eaf6863" + integrity sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg== dependencies: - "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.28.5" - -"@babel/types@^7.28.6": - version "7.28.6" - resolved "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz" - integrity sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg== - dependencies: - "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.28.5" + "@babel/helper-string-parser" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" "@bcoe/v8-coverage@^1.0.2": version "1.0.2" resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz" integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== -"@biomejs/biome@^2.3.11": - version "2.3.11" - resolved "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.11.tgz" - integrity sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ== +"@biomejs/biome@^2.5.11": + version "2.5.11" + resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.5.11.tgz#2325a9f54a651d0420be1f1517380f56013f7ba1" + integrity sha512-Tj0dnkLPdW0ASjHfj2D/ZkkvPU2wrFmnE1jWTD2xzV1ycapV1DutbYXk4NDnR3rYTi1ZCbNFD4G2gRMEY65WaA== optionalDependencies: - "@biomejs/cli-darwin-arm64" "2.3.11" - "@biomejs/cli-darwin-x64" "2.3.11" - "@biomejs/cli-linux-arm64" "2.3.11" - "@biomejs/cli-linux-arm64-musl" "2.3.11" - "@biomejs/cli-linux-x64" "2.3.11" - "@biomejs/cli-linux-x64-musl" "2.3.11" - "@biomejs/cli-win32-arm64" "2.3.11" - "@biomejs/cli-win32-x64" "2.3.11" - -"@biomejs/cli-darwin-arm64@2.3.11": - version "2.3.11" - resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.11.tgz#3aa71d119e7216d66620282121673b95257fcc35" - integrity sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA== - -"@biomejs/cli-darwin-x64@2.3.11": - version "2.3.11" - resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.11.tgz#3003d7c24e4fbba9693fba98692c6386884fedcf" - integrity sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg== - -"@biomejs/cli-linux-arm64-musl@2.3.11": - version "2.3.11" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.11.tgz#a17fb411ba8146ba60f3592857b7c49c8f0fe936" - integrity sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg== - -"@biomejs/cli-linux-arm64@2.3.11": - version "2.3.11" - resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.11.tgz#643830d1e53071d594e16b919e88a46fbc5c0b55" - integrity sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g== - -"@biomejs/cli-linux-x64-musl@2.3.11": - version "2.3.11" - resolved "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.11.tgz" - integrity sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw== - -"@biomejs/cli-linux-x64@2.3.11": - version "2.3.11" - resolved "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.11.tgz" - integrity sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg== - -"@biomejs/cli-win32-arm64@2.3.11": - version "2.3.11" - resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.11.tgz#6769907655cd06938b00d040c6576c2fdf6c34a2" - integrity sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw== - -"@biomejs/cli-win32-x64@2.3.11": - version "2.3.11" - resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.11.tgz#71ba2fb5505b3b01dd3cf551ef329e0094636125" - integrity sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg== - -"@commitlint/cli@^20.3.1": - version "20.3.1" - resolved "https://registry.npmjs.org/@commitlint/cli/-/cli-20.3.1.tgz" - integrity sha512-NtInjSlyev/+SLPvx/ulz8hRE25Wf5S9dLNDcIwazq0JyB4/w1ROF/5nV0ObPTX8YpRaKYeKtXDYWqumBNHWsw== - dependencies: - "@commitlint/format" "^20.3.1" - "@commitlint/lint" "^20.3.1" - "@commitlint/load" "^20.3.1" - "@commitlint/read" "^20.3.1" - "@commitlint/types" "^20.3.1" + "@biomejs/cli-darwin-arm64" "2.5.11" + "@biomejs/cli-darwin-x64" "2.5.11" + "@biomejs/cli-linux-arm64" "2.5.11" + "@biomejs/cli-linux-arm64-musl" "2.5.11" + "@biomejs/cli-linux-x64" "2.5.11" + "@biomejs/cli-linux-x64-musl" "2.5.11" + "@biomejs/cli-win32-arm64" "2.5.11" + "@biomejs/cli-win32-x64" "2.5.11" + +"@biomejs/cli-darwin-arm64@2.5.11": + version "2.5.11" + resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.11.tgz#2b8af359fc43f10c81b88621884b25ec83130054" + integrity sha512-6SGZxoKbXvUjMn1t6A98HqWISPnGNbYs0R/Rt2JarmXBSev+lva4QxUMWEBX9lX1Wo1XTJ78uk5xVDtG58SRZg== + +"@biomejs/cli-darwin-x64@2.5.11": + version "2.5.11" + resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.11.tgz#4aedca314957186b0d9f9e93afcff95d1e142523" + integrity sha512-nYkXY7tLBEgnGbYapDKAyKzgt44ZEyG+AKalvTXtCWKYgepI9dw327q+cVgedxm+Udi1ZzHKUyZrIusHi/KQbw== + +"@biomejs/cli-linux-arm64-musl@2.5.11": + version "2.5.11" + resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.11.tgz#2938a40986262431d2d263c9ebd091ca90af6e05" + integrity sha512-qhyZUMyCbWYFV2bAwRNVvfMVZ+hv7WYl6mossGrxC+uiQQXhvsuWWU8zz6jYX0mChZd9MgQZbm4vozTmG/5iGw== + +"@biomejs/cli-linux-arm64@2.5.11": + version "2.5.11" + resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.11.tgz#ab7b951f313050c047294441333c7dbe2f9b622d" + integrity sha512-3PVLSTD9RR73rvVPt5G3T1gc+ycggWEGfTD7RvzzbtcDPD27NxgxBbAFfpm7DXJKW6VLHWE1lLMGvFt2Qxjcow== + +"@biomejs/cli-linux-x64-musl@2.5.11": + version "2.5.11" + resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.11.tgz#1c6b4eee8bba5d19fde0a9edecc3c865aa15e5c7" + integrity sha512-oRRlrchG5EfrEL/EmtT1qUjSNHk3/5LGeZhQqADBBAJF1b1ET6964xEKe7aGlGARzDfza8H/seEsFJl7S6Ql9w== + +"@biomejs/cli-linux-x64@2.5.11": + version "2.5.11" + resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.11.tgz#dc9e6824e38a34d9a95383e31d59a4f23ef60181" + integrity sha512-JOytptlsgM33B2MMFUg8iBrb4IKpbD5JnJrSeYiaFEeAj4vuXx0iQSQZ4qK7sqyMtfjZxxPdNdMZZVL4y/mFyA== + +"@biomejs/cli-win32-arm64@2.5.11": + version "2.5.11" + resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.11.tgz#7c9fabc7d27c3794122fa499dcc944a232a96edb" + integrity sha512-e49E6K9hzH/ohJNx8Y26mY8HaV4I4ZViIeoqhKsmoXLKHhQnMeBAVqCgsGf2Wa3lXlS7RkporDXMHHWkzvZzFw== + +"@biomejs/cli-win32-x64@2.5.11": + version "2.5.11" + resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.11.tgz#0040003958f3ba5d6a2558011ca02bdb608821f5" + integrity sha512-QSQr/KjOgXA7OzXJUWS+oguKyAZ3Q0l/lnlDGbu397eKo83atuWUjBPJrsqbKNF6CARGw8XXJLGzpHC8Ryhd4Q== + +"@commitlint/cli@^20.5.3": + version "20.5.3" + resolved "https://registry.yarnpkg.com/@commitlint/cli/-/cli-20.5.3.tgz#6b4bfcf19fc149b0a357725f551bad11a930f145" + integrity sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA== + dependencies: + "@commitlint/format" "^20.5.0" + "@commitlint/lint" "^20.5.3" + "@commitlint/load" "^20.5.3" + "@commitlint/read" "^20.5.0" + "@commitlint/types" "^20.5.0" tinyexec "^1.0.0" yargs "^17.0.0" -"@commitlint/config-conventional@^20.3.1": - version "20.3.1" - resolved "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.3.1.tgz" - integrity sha512-NCzwvxepstBZbmVXsvg49s+shCxlJDJPWxXqONVcAtJH9wWrOlkMQw/zyl+dJmt8lyVopt5mwQ3mR5M2N2rUWg== +"@commitlint/config-conventional@^20.5.3": + version "20.5.3" + resolved "https://registry.yarnpkg.com/@commitlint/config-conventional/-/config-conventional-20.5.3.tgz#ccf17ee4c695cd4b73784c6ce7fdb5b20d22be99" + integrity sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ== dependencies: - "@commitlint/types" "^20.3.1" - conventional-changelog-conventionalcommits "^7.0.2" + "@commitlint/types" "^20.5.0" + conventional-changelog-conventionalcommits "^9.2.0" "@commitlint/config-validator@^19.0.3": version "19.0.3" @@ -146,25 +138,21 @@ "@commitlint/types" "^19.0.3" ajv "^8.11.0" -"@commitlint/config-validator@^20.3.1": - version "20.3.1" - resolved "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.3.1.tgz" - integrity sha512-ErVLC/IsHhcvxCyh+FXo7jy12/nkQySjWXYgCoQbZLkFp4hysov8KS6CdxBB0cWjbZWjvNOKBMNoUVqkmGmahw== +"@commitlint/config-validator@^20.5.0": + version "20.5.0" + resolved "https://registry.yarnpkg.com/@commitlint/config-validator/-/config-validator-20.5.0.tgz#b0aad5fdd520b07ac52f9ad8d41844629e5fd40e" + integrity sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw== dependencies: - "@commitlint/types" "^20.3.1" + "@commitlint/types" "^20.5.0" ajv "^8.11.0" -"@commitlint/ensure@^20.3.1": - version "20.3.1" - resolved "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.3.1.tgz" - integrity sha512-h664FngOEd7bHAm0j8MEKq+qm2mH+V+hwJiIE2bWcw3pzJMlO0TPKtk0ATyRAtV6jQw+xviRYiIjjSjfajiB5w== +"@commitlint/ensure@^20.5.3": + version "20.5.3" + resolved "https://registry.yarnpkg.com/@commitlint/ensure/-/ensure-20.5.3.tgz#6a63f52dad60a2db9b87ea1b11c8d483ced24aa1" + integrity sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw== dependencies: - "@commitlint/types" "^20.3.1" - lodash.camelcase "^4.3.0" - lodash.kebabcase "^4.1.1" - lodash.snakecase "^4.1.1" - lodash.startcase "^4.4.0" - lodash.upperfirst "^4.3.1" + "@commitlint/types" "^20.5.0" + es-toolkit "^1.46.0" "@commitlint/execute-rule@^19.0.0": version "19.0.0" @@ -176,31 +164,31 @@ resolved "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-20.0.0.tgz" integrity sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw== -"@commitlint/format@^20.3.1": - version "20.3.1" - resolved "https://registry.npmjs.org/@commitlint/format/-/format-20.3.1.tgz" - integrity sha512-jfsjGPFTd2Yti2YHwUH4SPRPbWKAJAwrfa3eNa9bXEdrXBb9mCwbIrgYX38LdEJK9zLJ3AsLBP4/FLEtxyu2AA== +"@commitlint/format@^20.5.0": + version "20.5.0" + resolved "https://registry.yarnpkg.com/@commitlint/format/-/format-20.5.0.tgz#cd73c527ec60e70fc7ae45c1ce31a4f143c49202" + integrity sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q== dependencies: - "@commitlint/types" "^20.3.1" - chalk "^5.3.0" + "@commitlint/types" "^20.5.0" + picocolors "^1.1.1" -"@commitlint/is-ignored@^20.3.1": - version "20.3.1" - resolved "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.3.1.tgz" - integrity sha512-tWwAoh93QvAhxgp99CzCuHD86MgxE4NBtloKX+XxQxhfhSwHo7eloiar/yzx53YW9eqSLP95zgW2KDDk4/WX+A== +"@commitlint/is-ignored@^20.5.0": + version "20.5.0" + resolved "https://registry.yarnpkg.com/@commitlint/is-ignored/-/is-ignored-20.5.0.tgz#c90ac785b0673c4aa4e6bb1e3a15fcfb399727b0" + integrity sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg== dependencies: - "@commitlint/types" "^20.3.1" + "@commitlint/types" "^20.5.0" semver "^7.6.0" -"@commitlint/lint@^20.3.1": - version "20.3.1" - resolved "https://registry.npmjs.org/@commitlint/lint/-/lint-20.3.1.tgz" - integrity sha512-LaOtrQ24+6SfUaWg8A+a+Wc77bvLbO5RIr6iy9F7CI3/0iq1uPEWgGRCwqWTuLGHkZDAcwaq0gZ01zpwZ1jCGw== +"@commitlint/lint@^20.5.3": + version "20.5.3" + resolved "https://registry.yarnpkg.com/@commitlint/lint/-/lint-20.5.3.tgz#0848685798ea70045a1958dd3a758c01225e6cb4" + integrity sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw== dependencies: - "@commitlint/is-ignored" "^20.3.1" - "@commitlint/parse" "^20.3.1" - "@commitlint/rules" "^20.3.1" - "@commitlint/types" "^20.3.1" + "@commitlint/is-ignored" "^20.5.0" + "@commitlint/parse" "^20.5.0" + "@commitlint/rules" "^20.5.3" + "@commitlint/types" "^20.5.0" "@commitlint/load@>6.1.1": version "19.2.0" @@ -218,44 +206,43 @@ lodash.merge "^4.6.2" lodash.uniq "^4.5.0" -"@commitlint/load@^20.3.1": - version "20.3.1" - resolved "https://registry.npmjs.org/@commitlint/load/-/load-20.3.1.tgz" - integrity sha512-YDD9XA2XhgYgbjju8itZ/weIvOOobApDqwlPYCX5NLO/cPtw2UMO5Cmn44Ks8RQULUVI5fUT6roKvyxcoLbNmw== +"@commitlint/load@^20.5.3": + version "20.5.3" + resolved "https://registry.yarnpkg.com/@commitlint/load/-/load-20.5.3.tgz#06eabcb21941b70c3e5d6ea5c7fb64f7a2b27935" + integrity sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ== dependencies: - "@commitlint/config-validator" "^20.3.1" + "@commitlint/config-validator" "^20.5.0" "@commitlint/execute-rule" "^20.0.0" - "@commitlint/resolve-extends" "^20.3.1" - "@commitlint/types" "^20.3.1" - chalk "^5.3.0" - cosmiconfig "^9.0.0" + "@commitlint/resolve-extends" "^20.5.3" + "@commitlint/types" "^20.5.0" + cosmiconfig "^9.0.1" cosmiconfig-typescript-loader "^6.1.0" - lodash.isplainobject "^4.0.6" - lodash.merge "^4.6.2" - lodash.uniq "^4.5.0" + es-toolkit "^1.46.0" + is-plain-obj "^4.1.0" + picocolors "^1.1.1" -"@commitlint/message@^20.0.0": - version "20.0.0" - resolved "https://registry.npmjs.org/@commitlint/message/-/message-20.0.0.tgz" - integrity sha512-gLX4YmKnZqSwkmSB9OckQUrI5VyXEYiv3J5JKZRxIp8jOQsWjZgHSG/OgEfMQBK9ibdclEdAyIPYggwXoFGXjQ== - -"@commitlint/parse@^20.3.1": - version "20.3.1" - resolved "https://registry.npmjs.org/@commitlint/parse/-/parse-20.3.1.tgz" - integrity sha512-TuUTdbLpyUNLgDzLDYlI2BeTE6V/COZbf3f8WwsV0K6eq/2nSpNTMw7wHtXb+YxeY9wwxBp/Ldad4P+YIxHJoA== - dependencies: - "@commitlint/types" "^20.3.1" - conventional-changelog-angular "^7.0.0" - conventional-commits-parser "^5.0.0" - -"@commitlint/read@^20.3.1": - version "20.3.1" - resolved "https://registry.npmjs.org/@commitlint/read/-/read-20.3.1.tgz" - integrity sha512-nCmJAdIg3OdNVUpQW0Idk/eF/vfOo2W2xzmvRmNeptLrzFK7qhwwl/kIwy1Q1LZrKHUFNj7PGNpIT5INbgZWzA== - dependencies: - "@commitlint/top-level" "^20.0.0" - "@commitlint/types" "^20.3.1" - git-raw-commits "^4.0.0" +"@commitlint/message@^20.4.3": + version "20.4.3" + resolved "https://registry.yarnpkg.com/@commitlint/message/-/message-20.4.3.tgz#0c377fbbe1c72487612330a01d1e7dea991fa467" + integrity sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ== + +"@commitlint/parse@^20.5.0": + version "20.5.0" + resolved "https://registry.yarnpkg.com/@commitlint/parse/-/parse-20.5.0.tgz#196b75a7b870aa7cda311fac525f10fb6ccacd54" + integrity sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA== + dependencies: + "@commitlint/types" "^20.5.0" + conventional-changelog-angular "^8.2.0" + conventional-commits-parser "^6.3.0" + +"@commitlint/read@^20.5.0": + version "20.5.0" + resolved "https://registry.yarnpkg.com/@commitlint/read/-/read-20.5.0.tgz#59d4b0e98429d308be6ad0d3e5144a193638ee6b" + integrity sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w== + dependencies: + "@commitlint/top-level" "^20.4.3" + "@commitlint/types" "^20.5.0" + git-raw-commits "^5.0.0" minimist "^1.2.8" tinyexec "^1.0.0" @@ -271,39 +258,39 @@ lodash.mergewith "^4.6.2" resolve-from "^5.0.0" -"@commitlint/resolve-extends@^20.3.1": - version "20.3.1" - resolved "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.3.1.tgz" - integrity sha512-iGTGeyaoDyHDEZNjD8rKeosjSNs8zYanmuowY4ful7kFI0dnY4b5QilVYaFQJ6IM27S57LAeH5sKSsOHy4bw5w== +"@commitlint/resolve-extends@^20.5.3": + version "20.5.3" + resolved "https://registry.yarnpkg.com/@commitlint/resolve-extends/-/resolve-extends-20.5.3.tgz#3c46fa1fa243f12f1cb0a4ffc47b38351abd9c50" + integrity sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew== dependencies: - "@commitlint/config-validator" "^20.3.1" - "@commitlint/types" "^20.3.1" - global-directory "^4.0.1" + "@commitlint/config-validator" "^20.5.0" + "@commitlint/types" "^20.5.0" + es-toolkit "^1.46.0" + global-directory "^5.0.0" import-meta-resolve "^4.0.0" - lodash.mergewith "^4.6.2" resolve-from "^5.0.0" -"@commitlint/rules@^20.3.1": - version "20.3.1" - resolved "https://registry.npmjs.org/@commitlint/rules/-/rules-20.3.1.tgz" - integrity sha512-/uic4P+4jVNpqQxz02+Y6vvIC0A2J899DBztA1j6q3f3MOKwydlNrojSh0dQmGDxxT1bXByiRtDhgFnOFnM6Pg== +"@commitlint/rules@^20.5.3": + version "20.5.3" + resolved "https://registry.yarnpkg.com/@commitlint/rules/-/rules-20.5.3.tgz#95151b38e30b35ccf13d6d091e4cc61cd363e3ec" + integrity sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ== dependencies: - "@commitlint/ensure" "^20.3.1" - "@commitlint/message" "^20.0.0" + "@commitlint/ensure" "^20.5.3" + "@commitlint/message" "^20.4.3" "@commitlint/to-lines" "^20.0.0" - "@commitlint/types" "^20.3.1" + "@commitlint/types" "^20.5.0" "@commitlint/to-lines@^20.0.0": version "20.0.0" resolved "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-20.0.0.tgz" integrity sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw== -"@commitlint/top-level@^20.0.0": - version "20.0.0" - resolved "https://registry.npmjs.org/@commitlint/top-level/-/top-level-20.0.0.tgz" - integrity sha512-drXaPSP2EcopukrUXvUXmsQMu3Ey/FuJDc/5oiW4heoCfoE5BdLQyuc7veGeE3aoQaTVqZnh4D5WTWe2vefYKg== +"@commitlint/top-level@^20.4.3": + version "20.4.3" + resolved "https://registry.yarnpkg.com/@commitlint/top-level/-/top-level-20.4.3.tgz#6f94e558c1aa6ba3a1d6962e9adaa34f657efe81" + integrity sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ== dependencies: - find-up "^7.0.0" + escalade "^3.2.0" "@commitlint/types@^19.0.3": version "19.0.3" @@ -313,144 +300,283 @@ "@types/conventional-commits-parser" "^5.0.0" chalk "^5.3.0" -"@commitlint/types@^20.3.1": - version "20.3.1" - resolved "https://registry.npmjs.org/@commitlint/types/-/types-20.3.1.tgz" - integrity sha512-VmIFV/JkBRhDRRv7N5B7zEUkNZIx9Mp+8Pe65erz0rKycXLsi8Epcw0XJ+btSeRXgTzE7DyOyA9bkJ9mn/yqVQ== +"@commitlint/types@^20.5.0": + version "20.5.0" + resolved "https://registry.yarnpkg.com/@commitlint/types/-/types-20.5.0.tgz#65be36ca38183757563bd69451dbe465a5d001ef" + integrity sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA== dependencies: - "@types/conventional-commits-parser" "^5.0.0" - chalk "^5.3.0" + conventional-commits-parser "^6.3.0" + picocolors "^1.1.1" + +"@conventional-changelog/git-client@^2.6.0": + version "2.7.0" + resolved "https://registry.yarnpkg.com/@conventional-changelog/git-client/-/git-client-2.7.0.tgz#07ea8202fd822e71d32c54aaed08b2c5ae9cc7c2" + integrity sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw== + dependencies: + "@simple-libs/child-process-utils" "^1.0.0" + "@simple-libs/stream-utils" "^1.2.0" + semver "^7.5.2" "@esbuild/aix-ppc64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz#521cbd968dcf362094034947f76fa1b18d2d403c" integrity sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw== +"@esbuild/aix-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz#bf6e10303bcf2e7c686975fa52f937ec2728d8bc" + integrity sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ== + "@esbuild/android-arm64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz#61ea550962d8aa12a9b33194394e007657a6df57" integrity sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA== +"@esbuild/android-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz#0c6246bc8d2c4d172aac2db3fb1190d72bd65504" + integrity sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A== + "@esbuild/android-arm@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz#554887821e009dd6d853f972fde6c5143f1de142" integrity sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA== +"@esbuild/android-arm@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz#2d84ece6a4e2684d92be26ee13d42757d831c381" + integrity sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg== + "@esbuild/android-x64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz#a7ce9d0721825fc578f9292a76d9e53334480ba2" integrity sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A== +"@esbuild/android-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz#fc38d4d6358d8dc1cf53f09f7589fe436eb64801" + integrity sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q== + "@esbuild/darwin-arm64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz#2cb7659bd5d109803c593cfc414450d5430c8256" integrity sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg== +"@esbuild/darwin-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz#f83afeeac1d7dac01c7a2fd012b3e451a0591fcc" + integrity sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw== + "@esbuild/darwin-x64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz#e741fa6b1abb0cd0364126ba34ca17fd5e7bf509" integrity sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA== +"@esbuild/darwin-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz#510147c055a795588dbbe14fd6b1b8ad0a2f30de" + integrity sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw== + "@esbuild/freebsd-arm64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz#2b64e7116865ca172d4ce034114c21f3c93e397c" integrity sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g== +"@esbuild/freebsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz#093b9200ecf0b115ba4e5e248a7485c9c5f8bd5e" + integrity sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw== + "@esbuild/freebsd-x64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz#e5252551e66f499e4934efb611812f3820e990bb" integrity sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA== +"@esbuild/freebsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz#0be22b6df925d213e841ea87123af5df80b0faf7" + integrity sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg== + "@esbuild/linux-arm64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz#dc4acf235531cd6984f5d6c3b13dbfb7ddb303cb" integrity sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw== +"@esbuild/linux-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz#1bdbc651cda9ba9995c53ed9c71ceaa65094762d" + integrity sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug== + "@esbuild/linux-arm@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz#56a900e39240d7d5d1d273bc053daa295c92e322" integrity sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw== +"@esbuild/linux-arm@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz#beb12ad72b84f72d28488cc1b8ee9f7eb141d753" + integrity sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w== + "@esbuild/linux-ia32@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz#d4a36d473360f6870efcd19d52bbfff59a2ed1cc" integrity sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w== +"@esbuild/linux-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz#b81f9d55529b45c206a46a138214b1aa6879696b" + integrity sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ== + "@esbuild/linux-loong64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz#fcf0ab8c3eaaf45891d0195d4961cb18b579716a" integrity sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg== +"@esbuild/linux-loong64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz#598667241a04c99b76ed6ef940ac50038c419f98" + integrity sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ== + "@esbuild/linux-mips64el@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz#598b67d34048bb7ee1901cb12e2a0a434c381c10" integrity sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw== +"@esbuild/linux-mips64el@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz#1c51eb9cea903f53d97b5af3b1841db70f5596ca" + integrity sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA== + "@esbuild/linux-ppc64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz#3846c5df6b2016dab9bc95dde26c40f11e43b4c0" integrity sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ== +"@esbuild/linux-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz#63dd61f17ceb31a81227f413feac8a71bc2c51f2" + integrity sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ== + "@esbuild/linux-riscv64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz#173d4475b37c8d2c3e1707e068c174bb3f53d07d" integrity sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA== +"@esbuild/linux-riscv64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz#3763b08fde5cf25ab1facb8e7752edfe45fbfc27" + integrity sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA== + "@esbuild/linux-s390x@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz#f7a4790105edcab8a5a31df26fbfac1aa3dacfab" integrity sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w== +"@esbuild/linux-s390x@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz#1a137ff293a82906eb3176385bd7e8e0e5cfb7cb" + integrity sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg== + "@esbuild/linux-x64@0.27.2": version "0.27.2" resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz" integrity sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA== +"@esbuild/linux-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz#268b36211c146ca54f8fe12c578a8d6ef8979485" + integrity sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ== + "@esbuild/netbsd-arm64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz#e2863c2cd1501845995cb11adf26f7fe4be527b0" integrity sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw== +"@esbuild/netbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz#22571ad951d62bb6accc82d8d1fad5c8c1ac0ba1" + integrity sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw== + "@esbuild/netbsd-x64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz#93f7609e2885d1c0b5a1417885fba8d1fcc41272" integrity sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA== +"@esbuild/netbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz#42fcc57297eb0a0ca3f5fc475291f4c1a3f7c0de" + integrity sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw== + "@esbuild/openbsd-arm64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz#a1985604a203cdc325fd47542e106fafd698f02e" integrity sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA== +"@esbuild/openbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz#9eb32af104ac3dacf4edca01f596664aab0c73ef" + integrity sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ== + "@esbuild/openbsd-x64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz#8209e46c42f1ffbe6e4ef77a32e1f47d404ad42a" integrity sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg== +"@esbuild/openbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz#febed2402d6088225e91f20fb4ce2522ad0a4efd" + integrity sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw== + "@esbuild/openharmony-arm64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz#8fade4441893d9cc44cbd7dcf3776f508ab6fb2f" integrity sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag== +"@esbuild/openharmony-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz#85641c3d466428bfbccea5f21c26836663fef5ce" + integrity sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q== + "@esbuild/sunos-x64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz#980d4b9703a16f0f07016632424fc6d9a789dfc2" integrity sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg== +"@esbuild/sunos-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz#a736f9d8962481045fc4c3e54f5479f22c870fb4" + integrity sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g== + "@esbuild/win32-arm64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz#1c09a3633c949ead3d808ba37276883e71f6111a" integrity sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg== +"@esbuild/win32-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz#ee5ab40fad186201b652a33f8a5eb149e9e42532" + integrity sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ== + "@esbuild/win32-ia32@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz#1b1e3a63ad4bef82200fef4e369e0fff7009eee5" integrity sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ== +"@esbuild/win32-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz#c40d28a6d99a127da6711f2afd74b11cb63b06a7" + integrity sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA== + "@esbuild/win32-x64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b" integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ== +"@esbuild/win32-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz#b21affb804cc167c133d95f45b3a1dc1323b9a87" + integrity sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g== + "@fastify/ajv-compiler@^1.0.0": version "1.1.0" resolved "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-1.1.0.tgz" @@ -791,9 +917,9 @@ "@inquirer/type" "^3.0.10" yoctocolors-cjs "^2.1.3" -"@inquirer/external-editor@^1.0.3": +"@inquirer/external-editor@^1.0.0", "@inquirer/external-editor@^1.0.3": version "1.0.3" - resolved "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz" + resolved "https://registry.yarnpkg.com/@inquirer/external-editor/-/external-editor-1.0.3.tgz#c23988291ee676290fdab3fd306e64010a6d13b8" integrity sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA== dependencies: chardet "^2.1.1" @@ -937,10 +1063,10 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@mswjs/interceptors@^0.39.5": - version "0.39.8" - resolved "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.8.tgz" - integrity sha512-2+BzZbjRO7Ct61k8fMNHEtoKjeWI9pIlHFTqBwZ5icHpqszIgEZbjb1MW5Z0+bITTCTl3gk4PDBxs9tA/csXvA== +"@mswjs/interceptors@^0.41.0": + version "0.41.9" + resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.41.9.tgz#9d90bbd60d1ddc30dbcbb827a9bb2e470493530d" + integrity sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w== dependencies: "@open-draft/deferred-promise" "^2.2.0" "@open-draft/logger" "^0.3.0" @@ -993,6 +1119,11 @@ resolved "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz" integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== +"@oxc-project/types@=0.147.0": + version "0.147.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.147.0.tgz#512e43196053db4a99928e35b287549a3226268b" + integrity sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg== + "@paralleldrive/cuid2@^2.2.2": version "2.3.1" resolved "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz" @@ -1031,6 +1162,86 @@ "@pnpm/network.ca-file" "^1.0.1" config-chain "^1.1.11" +"@rolldown/binding-android-arm-eabi@1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz#81bc32b79902fcd3875dffdf3be2ccba3ef8d794" + integrity sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ== + +"@rolldown/binding-android-arm64@1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz#9b20ae8d8a5bb979ea6aa8fa4b7e11f58c53f775" + integrity sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q== + +"@rolldown/binding-darwin-arm64@1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz#1031ee06b51c9134ba5a6da3a7310388875f7dd8" + integrity sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA== + +"@rolldown/binding-darwin-x64@1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz#11cf31040b779dba8e61d932ae3c0ee81ef94ae0" + integrity sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q== + +"@rolldown/binding-freebsd-x64@1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz#72d805b459ba4a8bbfc112bca4a1c70740516ae9" + integrity sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA== + +"@rolldown/binding-linux-arm-gnueabihf@1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz#43b778dd021f50182a1efdb4149cd89cff86c942" + integrity sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w== + +"@rolldown/binding-linux-arm64-gnu@1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz#e9fa7a469999c1346d827b29154068d1c833c474" + integrity sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg== + +"@rolldown/binding-linux-arm64-musl@1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz#5e68dd6eb85797745f494dfe8c490c2bce4218a6" + integrity sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw== + +"@rolldown/binding-linux-ppc64-gnu@1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz#2a0efc15437a55dd2caf58f0318e129d11d98ee6" + integrity sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ== + +"@rolldown/binding-linux-s390x-gnu@1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz#bc2b0b6325d8aae0f1a1c06fb10d665c82258541" + integrity sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA== + +"@rolldown/binding-linux-x64-gnu@1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz#e6e5022f6138fdc0d14cefacc67dc7bc5dff6430" + integrity sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w== + +"@rolldown/binding-linux-x64-musl@1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz#c8b462609809237bbf6265491b1e78d5c260ed46" + integrity sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ== + +"@rolldown/binding-openharmony-arm64@1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz#aa208791647b3b0bd23b372d5ee6bd5ed5988118" + integrity sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg== + +"@rolldown/binding-win32-arm64-msvc@1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz#a2a5f1a84555d00afe0ac0ff387878bcf4ada52a" + integrity sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A== + +"@rolldown/binding-win32-x64-msvc@1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz#1162903e264ed10db4be8af40ba8cf13cd4a0b4a" + integrity sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ== + +"@rolldown/pluginutils@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz#e3fcee093fbb5ce765e1ad088ff4de2889f6f9be" + integrity sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw== + "@rollup/rollup-android-arm-eabi@4.55.1": version "4.55.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz#76e0fef6533b3ce313f969879e61e8f21f0eeb28" @@ -1180,20 +1391,32 @@ resolved "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz" integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== +"@simple-libs/child-process-utils@^1.0.0": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz#cb182d310c9bed3ace200b26258e090d898a1736" + integrity sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw== + dependencies: + "@simple-libs/stream-utils" "^1.2.0" + +"@simple-libs/stream-utils@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz#5af724b826f1ab4d7f2826d31d3efccec124102b" + integrity sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA== + "@sindresorhus/merge-streams@^2.1.0": version "2.3.0" resolved "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz" integrity sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg== -"@standard-schema/spec@^1.0.0": +"@standard-schema/spec@^1.1.0": version "1.1.0" - resolved "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== -"@tsconfig/node20@^20.1.8": - version "20.1.8" - resolved "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.8.tgz" - integrity sha512-Em+IdPfByIzWRRpqWL4Z7ArLHZGxmc36BxE3jCz9nBFSm+5aLaPMZyjwu4yetvyKXeogWcxik4L1jB5JTWfw7A== +"@tsconfig/node20@^20.1.10": + version "20.1.10" + resolved "https://registry.yarnpkg.com/@tsconfig/node20/-/node20-20.1.10.tgz#e41fb85a1f224b4f0704f7b33df71da8a55891b6" + integrity sha512-5OZgdnxbFuvwm05iVwPXMU5/7iPuVDDZzAnxPDLj+kjN7fwQZSSVgHQhKTNkBPOO6Qef2D5MxPAoPLCGqE2VuQ== "@types/async@^3.2.25": version "3.2.25" @@ -1292,10 +1515,10 @@ dependencies: "@types/node" "*" -"@types/lodash@^4.17.23": - version "4.17.23" - resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz" - integrity sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA== +"@types/lodash@^4.17.25": + version "4.17.25" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.25.tgz#69765ac7bcddb0eb072961cf292524a8f5b3c2c0" + integrity sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ== "@types/methods@^1.1.4": version "1.1.4" @@ -1312,13 +1535,20 @@ resolved "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz" integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag== -"@types/node@*", "@types/node@^25.0.9": +"@types/node@*": version "25.0.9" resolved "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz" integrity sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw== dependencies: undici-types "~7.16.0" +"@types/node@^25.9.5": + version "25.9.5" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.5.tgz#0fefc09e6e82e94cde291bacf43522e989eb01a4" + integrity sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg== + dependencies: + undici-types ">=7.24.0 <7.24.7" + "@types/normalize-package-data@^2.4.0", "@types/normalize-package-data@^2.4.3": version "2.4.4" resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz" @@ -1380,81 +1610,83 @@ joi "^17.7.0" prom-client ">=11.5.3" -"@vitest/coverage-v8@^4.0.17": - version "4.0.17" - resolved "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.17.tgz" - integrity sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw== +"@vitest/coverage-v8@^4.1.11": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz#6f0636abe7e23e86dd35127244554be2c60bc2a5" + integrity sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw== dependencies: "@bcoe/v8-coverage" "^1.0.2" - "@vitest/utils" "4.0.17" - ast-v8-to-istanbul "^0.3.10" + "@vitest/utils" "4.1.11" + ast-v8-to-istanbul "^1.0.0" istanbul-lib-coverage "^3.2.2" istanbul-lib-report "^3.0.1" istanbul-reports "^3.2.0" - magicast "^0.5.1" + magicast "^0.5.2" obug "^2.1.1" - std-env "^3.10.0" - tinyrainbow "^3.0.3" + std-env "^4.0.0-rc.1" + tinyrainbow "^3.1.0" -"@vitest/expect@4.0.17": - version "4.0.17" - resolved "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz" - integrity sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ== +"@vitest/expect@4.1.11": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.11.tgz#5f580d1f9cdbba314dbf23b2d911f8eb23878f5f" + integrity sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw== dependencies: - "@standard-schema/spec" "^1.0.0" + "@standard-schema/spec" "^1.1.0" "@types/chai" "^5.2.2" - "@vitest/spy" "4.0.17" - "@vitest/utils" "4.0.17" - chai "^6.2.1" - tinyrainbow "^3.0.3" + "@vitest/spy" "4.1.11" + "@vitest/utils" "4.1.11" + chai "^6.2.2" + tinyrainbow "^3.1.0" -"@vitest/mocker@4.0.17": - version "4.0.17" - resolved "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz" - integrity sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ== +"@vitest/mocker@4.1.11": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.11.tgz#8e2906361bc5dfa271757a858ae80643118fcbb4" + integrity sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ== dependencies: - "@vitest/spy" "4.0.17" + "@vitest/spy" "4.1.11" estree-walker "^3.0.3" magic-string "^0.30.21" -"@vitest/pretty-format@4.0.17": - version "4.0.17" - resolved "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz" - integrity sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw== +"@vitest/pretty-format@4.1.11": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.11.tgz#8b28eb8240771d6ea970e33beaeb41384b51868e" + integrity sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw== dependencies: - tinyrainbow "^3.0.3" + tinyrainbow "^3.1.0" -"@vitest/runner@4.0.17": - version "4.0.17" - resolved "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz" - integrity sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ== +"@vitest/runner@4.1.11": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.11.tgz#bfbad98c8d6c3f1fb4df12056ad569821ff77f21" + integrity sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw== dependencies: - "@vitest/utils" "4.0.17" + "@vitest/utils" "4.1.11" pathe "^2.0.3" -"@vitest/snapshot@4.0.17": - version "4.0.17" - resolved "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz" - integrity sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ== +"@vitest/snapshot@4.1.11": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.11.tgz#df461eb165924a3155986dde68e13360f53f3d4c" + integrity sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog== dependencies: - "@vitest/pretty-format" "4.0.17" + "@vitest/pretty-format" "4.1.11" + "@vitest/utils" "4.1.11" magic-string "^0.30.21" pathe "^2.0.3" -"@vitest/spy@4.0.17": - version "4.0.17" - resolved "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz" - integrity sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew== +"@vitest/spy@4.1.11": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.11.tgz#0add45cae953afed9c88f98e2f6fc9164558c32a" + integrity sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA== -"@vitest/utils@4.0.17": - version "4.0.17" - resolved "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz" - integrity sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w== +"@vitest/utils@4.1.11": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.11.tgz#9b27a4293b827942b223539bfab1bd9f7eada31b" + integrity sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ== dependencies: - "@vitest/pretty-format" "4.0.17" - tinyrainbow "^3.0.3" + "@vitest/pretty-format" "4.1.11" + convert-source-map "^2.0.0" + tinyrainbow "^3.1.0" -JSONStream@^1.0.4, JSONStream@^1.3.5: +JSONStream@^1.0.4: version "1.3.5" resolved "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz" integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ== @@ -1625,14 +1857,14 @@ assertion-error@^2.0.1: resolved "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz" integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== -ast-v8-to-istanbul@^0.3.10: - version "0.3.10" - resolved "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz" - integrity sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ== +ast-v8-to-istanbul@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz#708baeb6f5c879226d112a341ffa821c43881d2d" + integrity sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA== dependencies: "@jridgewell/trace-mapping" "^0.3.31" estree-walker "^3.0.3" - js-tokens "^9.0.1" + js-tokens "^10.0.0" astral-regex@^2.0.0: version "2.0.0" @@ -1805,6 +2037,11 @@ cachedir@2.3.0: resolved "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz" integrity sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw== +cachedir@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.4.0.tgz#7fef9cf7367233d7c88068fe6e34ed0d355a610d" + integrity sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ== + call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz" @@ -1856,9 +2093,9 @@ camelcase@^8.0.0: resolved "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz" integrity sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA== -chai@^6.2.1: +chai@^6.2.2: version "6.2.2" - resolved "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz" + resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== chalk-template@^1.1.0: @@ -2034,10 +2271,10 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" -commander@^14.0.2: - version "14.0.2" - resolved "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz" - integrity sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ== +commander@^14.0.3: + version "14.0.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.3.tgz#425d79b48f9af82fcd9e4fc1ea8af6c5ec07bbc2" + integrity sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw== commander@^4.0.0: version "4.1.1" @@ -2064,12 +2301,12 @@ commitizen@^4.0.3: strip-bom "4.0.0" strip-json-comments "3.1.1" -commitizen@^4.3.1: - version "4.3.1" - resolved "https://registry.npmjs.org/commitizen/-/commitizen-4.3.1.tgz" - integrity sha512-gwAPAVTy/j5YcOOebcCRIijn+mSjWJC+IYKivTu6aG8Ei/scoXgfsMRnuAk6b0GRste2J4NGxVdMN3ZpfNaVaw== +commitizen@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/commitizen/-/commitizen-4.3.2.tgz#c2288d5518673ba4deae54e0b6843f3e582406c8" + integrity sha512-1Zs37z9JPvAcuTSSricZZwBhOPVNNxJouuY4yDEt+eD70EoxT2TU9kViG8CuB/PmVg2G4XsAGQiK4YCst97aDQ== dependencies: - cachedir "2.3.0" + cachedir "2.4.0" cz-conventional-changelog "3.3.0" dedent "0.7.0" detect-indent "6.1.0" @@ -2077,10 +2314,10 @@ commitizen@^4.3.1: find-root "1.1.0" fs-extra "9.1.0" glob "7.2.3" - inquirer "8.2.5" + inquirer "8.2.7" is-utf8 "^0.2.1" - lodash "4.17.21" - minimist "1.2.7" + lodash "4.18.1" + minimist "1.2.8" strip-bom "4.0.0" strip-json-comments "3.1.1" @@ -2178,10 +2415,10 @@ conventional-changelog-angular@^5.0.12: compare-func "^2.0.0" q "^1.5.1" -conventional-changelog-angular@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz" - integrity sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ== +conventional-changelog-angular@^8.2.0: + version "8.3.1" + resolved "https://registry.yarnpkg.com/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz#0b015e25ca7f2766a8c5352ab6488dcb76a9d881" + integrity sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg== dependencies: compare-func "^2.0.0" @@ -2213,10 +2450,10 @@ conventional-changelog-conventionalcommits@4.6.3, conventional-changelog-convent lodash "^4.17.15" q "^1.5.1" -conventional-changelog-conventionalcommits@^7.0.2: - version "7.0.2" - resolved "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz" - integrity sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w== +conventional-changelog-conventionalcommits@^9.2.0: + version "9.3.1" + resolved "https://registry.yarnpkg.com/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz#14f2dd65ccc5de09322a7eb0159f3e0259d7399c" + integrity sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw== dependencies: compare-func "^2.0.0" @@ -2338,15 +2575,13 @@ conventional-commits-parser@^3.2.0: split2 "^3.0.0" through2 "^4.0.0" -conventional-commits-parser@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz" - integrity sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA== +conventional-commits-parser@^6.3.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz#8ac1c12ec467354ed4d73ec940efe380e1e83686" + integrity sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw== dependencies: - JSONStream "^1.3.5" - is-text-path "^2.0.0" - meow "^12.0.1" - split2 "^4.0.0" + "@simple-libs/stream-utils" "^1.2.0" + meow "^13.0.0" conventional-recommended-bump@6.1.0: version "6.1.0" @@ -2362,6 +2597,11 @@ conventional-recommended-bump@6.1.0: meow "^8.0.0" q "^1.5.1" +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + cookie-signature@^1.2.1, cookie-signature@^1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz" @@ -2429,6 +2669,16 @@ cosmiconfig@^9.0.0: js-yaml "^4.1.0" parse-json "^5.2.0" +cosmiconfig@^9.0.1: + version "9.0.2" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-9.0.2.tgz#9e5615163becf6a82211fb33d2f68947c25d0c5e" + integrity sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg== + dependencies: + env-paths "^2.2.1" + import-fresh "^3.3.0" + js-yaml "^4.1.0" + parse-json "^5.2.0" + cross-spawn@^6.0.0: version "6.0.6" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz" @@ -2468,11 +2718,6 @@ dargs@^7.0.0: resolved "https://registry.npmjs.org/dargs/-/dargs-7.0.0.tgz" integrity sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg== -dargs@^8.0.0: - version "8.1.0" - resolved "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz" - integrity sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw== - date-fns@^1.27.2: version "1.30.1" resolved "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz" @@ -2488,10 +2733,10 @@ dateformat@^4.6.3: resolved "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz" integrity sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA== -dayjs@^1.11.19: - version "1.11.19" - resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz" - integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw== +dayjs@^1.11.23: + version "1.11.23" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.23.tgz#b0a363506dde5f36cf5075e42ebe8115165a8c79" + integrity sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ== debug@2.6.9: version "2.6.9" @@ -2614,6 +2859,11 @@ detect-indent@6.1.0, detect-indent@^6.0.0: resolved "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz" integrity sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA== +detect-libc@^2.0.3: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + detect-newline@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz" @@ -2734,10 +2984,10 @@ es-errors@^1.3.0: resolved "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== -es-module-lexer@^1.7.0: - version "1.7.0" - resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz" - integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== +es-module-lexer@^2.0.0: + version "2.3.2" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.3.2.tgz#311fa4f40168c1975c505477c51b23234d41ad55" + integrity sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw== es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: version "1.1.1" @@ -2756,7 +3006,12 @@ es-set-tostringtag@^2.1.0: has-tostringtag "^1.0.2" hasown "^2.0.2" -esbuild@^0.27.0, esbuild@~0.27.0: +es-toolkit@^1.46.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.52.0.tgz#71eaf1a8b18834ef77637eccbb885ba4c03cd6dd" + integrity sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA== + +esbuild@^0.27.0: version "0.27.2" resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz" integrity sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw== @@ -2788,11 +3043,48 @@ esbuild@^0.27.0, esbuild@~0.27.0: "@esbuild/win32-ia32" "0.27.2" "@esbuild/win32-x64" "0.27.2" +esbuild@~0.28.0: + version "0.28.2" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.2.tgz#0f43bd1bad955b72d24e2261e3abe5957ccf0816" + integrity sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA== + optionalDependencies: + "@esbuild/aix-ppc64" "0.28.2" + "@esbuild/android-arm" "0.28.2" + "@esbuild/android-arm64" "0.28.2" + "@esbuild/android-x64" "0.28.2" + "@esbuild/darwin-arm64" "0.28.2" + "@esbuild/darwin-x64" "0.28.2" + "@esbuild/freebsd-arm64" "0.28.2" + "@esbuild/freebsd-x64" "0.28.2" + "@esbuild/linux-arm" "0.28.2" + "@esbuild/linux-arm64" "0.28.2" + "@esbuild/linux-ia32" "0.28.2" + "@esbuild/linux-loong64" "0.28.2" + "@esbuild/linux-mips64el" "0.28.2" + "@esbuild/linux-ppc64" "0.28.2" + "@esbuild/linux-riscv64" "0.28.2" + "@esbuild/linux-s390x" "0.28.2" + "@esbuild/linux-x64" "0.28.2" + "@esbuild/netbsd-arm64" "0.28.2" + "@esbuild/netbsd-x64" "0.28.2" + "@esbuild/openbsd-arm64" "0.28.2" + "@esbuild/openbsd-x64" "0.28.2" + "@esbuild/openharmony-arm64" "0.28.2" + "@esbuild/sunos-x64" "0.28.2" + "@esbuild/win32-arm64" "0.28.2" + "@esbuild/win32-ia32" "0.28.2" + "@esbuild/win32-x64" "0.28.2" + escalade@^3.1.1: version "3.1.2" resolved "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz" integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA== +escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + escape-goat@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz" @@ -2870,10 +3162,10 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" -expect-type@^1.2.2: - version "1.3.0" - resolved "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz" - integrity sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA== +expect-type@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.4.0.tgz#24edf7f0cc69a44d008567ba4594ab96f3c3a3d6" + integrity sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA== express@^5.2.1: version "5.2.1" @@ -3107,15 +3399,6 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" -find-up@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz" - integrity sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g== - dependencies: - locate-path "^7.2.0" - path-exists "^5.0.0" - unicorn-magic "^0.1.0" - findup-sync@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/findup-sync/-/findup-sync-4.0.0.tgz" @@ -3289,13 +3572,6 @@ get-stream@^8.0.1: resolved "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz" integrity sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA== -get-tsconfig@^4.7.5: - version "4.8.0" - resolved "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.8.0.tgz" - integrity sha512-Pgba6TExTZ0FJAn1qkJAjIeKoDJ3CsI2ChuLohJnZl/tTU8MVrq3b+2t5UOPfRa4RMsorClBjJALkJUMjG1PAw== - dependencies: - resolve-pkg-maps "^1.0.0" - git-raw-commits@^2.0.8: version "2.0.11" resolved "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-2.0.11.tgz" @@ -3307,14 +3583,13 @@ git-raw-commits@^2.0.8: split2 "^3.0.0" through2 "^4.0.0" -git-raw-commits@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz" - integrity sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ== +git-raw-commits@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/git-raw-commits/-/git-raw-commits-5.0.1.tgz#e91d8fd4e3a264142166956fe1a23d08c069657e" + integrity sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ== dependencies: - dargs "^8.0.0" - meow "^12.0.1" - split2 "^4.0.0" + "@conventional-changelog/git-client" "^2.6.0" + meow "^13.0.0" git-remote-origin-url@^2.0.0: version "2.0.0" @@ -3382,6 +3657,13 @@ global-directory@^4.0.1: dependencies: ini "4.1.1" +global-directory@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/global-directory/-/global-directory-5.0.0.tgz#0f66a94212acd0f81ee838d0a991e88d1c2836cf" + integrity sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w== + dependencies: + ini "6.0.0" + global-modules@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz" @@ -3671,6 +3953,11 @@ ini@4.1.1: resolved "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz" integrity sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g== +ini@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/ini/-/ini-6.0.0.tgz#efc7642b276f6a37d22fdf56ef50889d7146bf30" + integrity sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ== + ini@^1.3.2, ini@^1.3.4, ini@~1.3.0: version "1.3.8" resolved "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz" @@ -3706,6 +3993,27 @@ inquirer@8.2.5: through "^2.3.6" wrap-ansi "^7.0.0" +inquirer@8.2.7: + version "8.2.7" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.7.tgz#62f6b931a9b7f8735dc42db927316d8fb6f71de8" + integrity sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA== + dependencies: + "@inquirer/external-editor" "^1.0.0" + ansi-escapes "^4.2.1" + chalk "^4.1.1" + cli-cursor "^3.1.0" + cli-width "^3.0.0" + figures "^3.0.0" + lodash "^4.17.21" + mute-stream "0.0.8" + ora "^5.4.1" + run-async "^2.4.0" + rxjs "^7.5.5" + string-width "^4.1.0" + strip-ansi "^6.0.0" + through "^2.3.6" + wrap-ansi "^6.0.1" + inquirer@^12.3.2: version "12.11.1" resolved "https://registry.npmjs.org/inquirer/-/inquirer-12.11.1.tgz" @@ -3890,6 +4198,11 @@ is-plain-obj@^1.1.0: resolved "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz" integrity sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg== +is-plain-obj@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" + integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== + is-promise@^2.1.0: version "2.2.2" resolved "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz" @@ -3924,13 +4237,6 @@ is-text-path@^1.0.1: dependencies: text-extensions "^1.0.0" -is-text-path@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz" - integrity sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw== - dependencies: - text-extensions "^2.0.0" - is-unicode-supported@^0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz" @@ -4035,16 +4341,16 @@ joycon@^3.1.1: resolved "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz" integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== +js-tokens@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-10.0.0.tgz#dffe7599b4a8bb7fe30aff8d0235234dffb79831" + integrity sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-tokens@^9.0.1: - version "9.0.1" - resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz" - integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== - js-yaml@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz" @@ -4125,6 +4431,80 @@ light-my-request@^4.2.0: process-warning "^1.0.0" set-cookie-parser "^2.4.1" +lightningcss-android-arm64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz#9a6841f88ae50fc83502903892b41af41bc2b907" + integrity sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg== + +lightningcss-darwin-arm64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz#c0f2c31c0bfd19fa4dd3f18e957a1f1a152097d6" + integrity sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg== + +lightningcss-darwin-x64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz#cb0705965acb538c6683949ce6925fb3cdf7c361" + integrity sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ== + +lightningcss-freebsd-x64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz#763538828b26bab2680dadafcc84ee78b0eb502b" + integrity sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg== + +lightningcss-linux-arm-gnueabihf@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz#6862e3176a331aedbdec1ed352b4d7d0dd0784de" + integrity sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ== + +lightningcss-linux-arm64-gnu@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz#c6a3a2ed15141daf6bdc2628930f8e39bdf473aa" + integrity sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg== + +lightningcss-linux-arm64-musl@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz#7fa1334971fc82845f9827df6ef8a0b20914bac6" + integrity sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ== + +lightningcss-linux-x64-gnu@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz#8b927862ea8c2bbc6831a46509244b50d9936e55" + integrity sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg== + +lightningcss-linux-x64-musl@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz#0c525bb077dfd94404c059cfe42dad797e96aeaf" + integrity sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw== + +lightningcss-win32-arm64-msvc@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz#850ee1103dac989cfab50e3ac22d1a69e394e63d" + integrity sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA== + +lightningcss-win32-x64-msvc@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz#e343ae152eed3609dc6e11949d1a3bf39a1c946f" + integrity sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA== + +lightningcss@^1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.33.0.tgz#c08867d71a79385c6e190214fd72fef3e5f95f0b" + integrity sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA== + dependencies: + detect-libc "^2.0.3" + optionalDependencies: + lightningcss-android-arm64 "1.33.0" + lightningcss-darwin-arm64 "1.33.0" + lightningcss-darwin-x64 "1.33.0" + lightningcss-freebsd-x64 "1.33.0" + lightningcss-linux-arm-gnueabihf "1.33.0" + lightningcss-linux-arm64-gnu "1.33.0" + lightningcss-linux-arm64-musl "1.33.0" + lightningcss-linux-x64-gnu "1.33.0" + lightningcss-linux-x64-musl "1.33.0" + lightningcss-win32-arm64-msvc "1.33.0" + lightningcss-win32-x64-msvc "1.33.0" + lilconfig@^3.1.1: version "3.1.2" resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz" @@ -4234,18 +4614,6 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -locate-path@^7.2.0: - version "7.2.0" - resolved "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz" - integrity sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA== - dependencies: - p-locate "^6.0.0" - -lodash.camelcase@^4.3.0: - version "4.3.0" - resolved "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz" - integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== - lodash.ismatch@^4.4.0: version "4.4.0" resolved "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz" @@ -4256,11 +4624,6 @@ lodash.isplainobject@^4.0.6: resolved "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz" integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== -lodash.kebabcase@^4.1.1: - version "4.1.1" - resolved "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz" - integrity sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g== - lodash.map@^4.5.1: version "4.6.0" resolved "https://registry.npmjs.org/lodash.map/-/lodash.map-4.6.0.tgz" @@ -4276,16 +4639,6 @@ lodash.mergewith@^4.6.2: resolved "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz" integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ== -lodash.snakecase@^4.1.1: - version "4.1.1" - resolved "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz" - integrity sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw== - -lodash.startcase@^4.4.0: - version "4.4.0" - resolved "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz" - integrity sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg== - lodash.truncate@^4.4.2: version "4.4.2" resolved "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz" @@ -4296,11 +4649,6 @@ lodash.uniq@^4.5.0: resolved "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz" integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== -lodash.upperfirst@^4.3.1: - version "4.3.1" - resolved "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz" - integrity sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg== - lodash.zip@^4.2.0: version "4.2.0" resolved "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz" @@ -4311,6 +4659,11 @@ lodash@4.17.21, lodash@^4.17.12, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17. resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +lodash@4.18.1, lodash@^4.18.1: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== + log-symbols@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz" @@ -4372,13 +4725,13 @@ magic-string@^0.30.17, magic-string@^0.30.21: dependencies: "@jridgewell/sourcemap-codec" "^1.5.5" -magicast@^0.5.1: - version "0.5.1" - resolved "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz" - integrity sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw== +magicast@^0.5.2: + version "0.5.4" + resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.5.4.tgz#bbe38dfd6037670057f33abf22b8aa79d5e99d0a" + integrity sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w== dependencies: - "@babel/parser" "^7.28.5" - "@babel/types" "^7.28.5" + "@babel/parser" "^7.29.7" + "@babel/types" "^7.29.7" source-map-js "^1.2.1" make-dir@^4.0.0: @@ -4408,14 +4761,9 @@ media-typer@^1.1.0: resolved "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz" integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== -meow@^12.0.1: - version "12.1.1" - resolved "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz" - integrity sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw== - -meow@^13.2.0: +meow@^13.0.0, meow@^13.2.0: version "13.2.0" - resolved "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz" + resolved "https://registry.yarnpkg.com/meow/-/meow-13.2.0.tgz#6b7d63f913f984063b3cc261b6e8800c4cd3474f" integrity sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA== meow@^8.0.0: @@ -4563,7 +4911,7 @@ minimist@1.2.7: resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== -minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: +minimist@1.2.8, minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -4632,10 +4980,10 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nanoid@^3.3.11: - version "3.3.11" - resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz" - integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== +nanoid@^3.3.17: + version "3.3.18" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== negotiator@^1.0.0: version "1.0.0" @@ -4664,12 +5012,12 @@ nice-try@^1.0.4: resolved "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== -nock@^14.0.10: - version "14.0.10" - resolved "https://registry.npmjs.org/nock/-/nock-14.0.10.tgz" - integrity sha512-Q7HjkpyPeLa0ZVZC5qpxBt5EyLczFJ91MEewQiIi9taWuA0KB/MDJlUWtON+7dGouVdADTQsf9RA7TZk6D8VMw== +nock@^14.0.17: + version "14.0.17" + resolved "https://registry.yarnpkg.com/nock/-/nock-14.0.17.tgz#356d0ba8cc8ff48194abf93c7f569f10f07b05e7" + integrity sha512-EjRr1weMa4ALQX35AgZTEnP+weJJjlW1KGDiNM2IQC2069YDHas4f4B4UUYR+TTLyKWxJvOz2wObDKQs/LNreA== dependencies: - "@mswjs/interceptors" "^0.39.5" + "@mswjs/interceptors" "^0.41.0" json-stringify-safe "^5.0.1" propagate "^2.0.0" @@ -4702,10 +5050,10 @@ normalize-package-data@^6.0.0: semver "^7.3.5" validate-npm-package-license "^3.0.4" -np@^10.2.0: - version "10.2.0" - resolved "https://registry.npmjs.org/np/-/np-10.2.0.tgz" - integrity sha512-7Pwk8qcsks2c9ETS35aeJSON6uJAbOsx7TwTFzZNUGgH4djT+Yt/p9S7PZuqH5pkcpNUhasne3cDRBzaUtvetg== +np@^10.3.0: + version "10.3.0" + resolved "https://registry.yarnpkg.com/np/-/np-10.3.0.tgz#8c93d00a9e355efe3361b538480730e035eb4361" + integrity sha512-ERkEM70wpiWxRNwlN3YkpqyE3QGrgKZEiyVvv+Z4Im2mRE9nqCjnS1YFAXVdhGqVP5wpqG8cVc/A2bOJhEYFYQ== dependencies: chalk "^5.4.1" chalk-template "^1.1.0" @@ -4734,8 +5082,8 @@ np@^10.2.0: open "^10.0.4" p-memoize "^7.1.1" p-timeout "^6.1.4" + package-directory "^8.0.0" path-exists "^5.0.0" - pkg-dir "^8.0.0" read-package-up "^11.0.0" read-pkg "^9.0.1" rxjs "^7.8.1" @@ -4916,17 +5264,10 @@ p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" -p-limit@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz" - integrity sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ== - dependencies: - yocto-queue "^1.0.0" - -p-limit@^7.2.0: - version "7.2.0" - resolved "https://registry.npmjs.org/p-limit/-/p-limit-7.2.0.tgz" - integrity sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ== +p-limit@^7.3.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-7.3.1.tgz#ded48cbfa10b161a9928120261fa82c9a282eb3f" + integrity sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q== dependencies: yocto-queue "^1.2.1" @@ -4958,13 +5299,6 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" -p-locate@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz" - integrity sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw== - dependencies: - p-limit "^4.0.0" - p-map@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz" @@ -5011,6 +5345,13 @@ p-try@^2.0.0: resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +package-directory@^8.0.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/package-directory/-/package-directory-8.2.0.tgz#b4f9df2e56782beb1d805945e2c530d29a3806f9" + integrity sha512-qJSu5Mo6tHmRxCy2KCYYKYgcfBdUpy9dwReaZD/xwf608AUk/MoRtIOWzgDtUeGeC7n/55yC3MI1Q+MbSoektw== + dependencies: + find-up-simple "^1.0.0" + package-json-from-dist@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz" @@ -5170,6 +5511,11 @@ picomatch@^4.0.3: resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== +picomatch@^4.0.4, picomatch@^4.0.5: + version "4.0.7" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f" + integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA== + pify@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz" @@ -5226,7 +5572,7 @@ pino-std-serializers@^7.0.0: resolved "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz" integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA== -pino@^10.0.0, pino@^10.2.0: +pino@^10.0.0: version "10.2.0" resolved "https://registry.npmjs.org/pino/-/pino-10.2.0.tgz" integrity sha512-NFnZqUliT+OHkRXVSf8vdOr13N1wv31hRryVjqbreVh/SDCNaI6mnRDDq89HVRCbem1SAl7yj04OANeqP0nT6A== @@ -5243,6 +5589,23 @@ pino@^10.0.0, pino@^10.2.0: sonic-boom "^4.0.1" thread-stream "^4.0.0" +pino@^10.3.1: + version "10.3.1" + resolved "https://registry.yarnpkg.com/pino/-/pino-10.3.1.tgz#6552c8f8d8481844c9e452e7bf0be90bff1939ce" + integrity sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg== + dependencies: + "@pinojs/redact" "^0.4.0" + atomic-sleep "^1.0.0" + on-exit-leak-free "^2.1.0" + pino-abstract-transport "^3.0.0" + pino-std-serializers "^7.0.0" + process-warning "^5.0.0" + quick-format-unescaped "^4.0.3" + real-require "^0.2.0" + safe-stable-stringify "^2.3.1" + sonic-boom "^4.0.1" + thread-stream "^4.0.0" + pino@^6.13.0: version "6.14.0" resolved "https://registry.npmjs.org/pino/-/pino-6.14.0.tgz" @@ -5268,13 +5631,6 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -pkg-dir@^8.0.0: - version "8.0.0" - resolved "https://registry.npmjs.org/pkg-dir/-/pkg-dir-8.0.0.tgz" - integrity sha512-4peoBq4Wks0riS0z8741NVv+/8IiTvqnZAr8QGgtdifrtpdXbNw/FxRS1l6NFqm4EMzuS0EDqNNx4XGaz8cuyQ== - dependencies: - find-up-simple "^1.0.0" - pkg-types@^1.3.1: version "1.3.1" resolved "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz" @@ -5291,12 +5647,12 @@ postcss-load-config@^6.0.1: dependencies: lilconfig "^3.1.1" -postcss@^8.5.6: - version "8.5.6" - resolved "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz" - integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== +postcss@^8.5.26: + version "8.5.26" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.26.tgz#6e75135780c7e10df3433bf2266c552d35c8c620" + integrity sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ== dependencies: - nanoid "^3.3.11" + nanoid "^3.3.17" picocolors "^1.1.1" source-map-js "^1.2.1" @@ -5581,11 +5937,6 @@ resolve-from@^5.0.0: resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== -resolve-pkg-maps@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz" - integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== - resolve@^1.1.6, resolve@^1.10.0: version "1.22.8" resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz" @@ -5626,7 +5977,31 @@ rfdc@^1.1.4, rfdc@^1.2.0: resolved "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz" integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== -rollup@^4.34.8, rollup@^4.43.0: +rolldown@~1.2.4: + version "1.2.6" + resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.2.6.tgz#025f5c11975cc70129ea9c76c54f35dd35881786" + integrity sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA== + dependencies: + "@oxc-project/types" "=0.147.0" + "@rolldown/pluginutils" "^1.0.0" + optionalDependencies: + "@rolldown/binding-android-arm-eabi" "1.2.6" + "@rolldown/binding-android-arm64" "1.2.6" + "@rolldown/binding-darwin-arm64" "1.2.6" + "@rolldown/binding-darwin-x64" "1.2.6" + "@rolldown/binding-freebsd-x64" "1.2.6" + "@rolldown/binding-linux-arm-gnueabihf" "1.2.6" + "@rolldown/binding-linux-arm64-gnu" "1.2.6" + "@rolldown/binding-linux-arm64-musl" "1.2.6" + "@rolldown/binding-linux-ppc64-gnu" "1.2.6" + "@rolldown/binding-linux-s390x-gnu" "1.2.6" + "@rolldown/binding-linux-x64-gnu" "1.2.6" + "@rolldown/binding-linux-x64-musl" "1.2.6" + "@rolldown/binding-openharmony-arm64" "1.2.6" + "@rolldown/binding-win32-arm64-msvc" "1.2.6" + "@rolldown/binding-win32-x64-msvc" "1.2.6" + +rollup@^4.34.8: version "4.55.1" resolved "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz" integrity sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A== @@ -5776,6 +6151,11 @@ semver@^7.1.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.5.3, semve resolved "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz" integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== +semver@^7.5.2: + version "7.8.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" + integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== + semver@^7.6.3: version "7.7.3" resolved "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz" @@ -6085,10 +6465,10 @@ statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2: resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz" integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== -std-env@^3.10.0: - version "3.10.0" - resolved "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz" - integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== +std-env@^4.0.0-rc.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-4.2.0.tgz#8ebe0ec60485668ab47227b312f4254cdf80c9d3" + integrity sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw== strict-event-emitter@^0.5.1: version "0.5.1" @@ -6400,11 +6780,6 @@ text-extensions@^1.0.0: resolved "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz" integrity sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ== -text-extensions@^2.0.0: - version "2.4.0" - resolved "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz" - integrity sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g== - thenify-all@^1.0.0: version "1.6.0" resolved "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz" @@ -6474,10 +6849,18 @@ tinyglobby@^0.2.11, tinyglobby@^0.2.15: fdir "^6.5.0" picomatch "^4.0.3" -tinyrainbow@^3.0.3: - version "3.0.3" - resolved "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz" - integrity sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q== +tinyglobby@^0.2.17: + version "0.2.17" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631" + integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + +tinyrainbow@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-3.1.1.tgz#c0168387d3d8d70b6b3c2c0936de5fee738cea20" + integrity sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw== tmp-promise@^3.0.3: version "3.0.3" @@ -6563,13 +6946,12 @@ tsup@^8.5.1: tinyglobby "^0.2.11" tree-kill "^1.2.2" -tsx@^4.21.0: - version "4.21.0" - resolved "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz" - integrity sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw== +tsx@^4.23.12: + version "4.23.12" + resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.23.12.tgz#3a4919591cd9b9e00011b75e596c8ab8db23c09c" + integrity sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q== dependencies: - esbuild "~0.27.0" - get-tsconfig "^4.7.5" + esbuild "~0.28.0" optionalDependencies: fsevents "~2.3.3" @@ -6647,15 +7029,20 @@ uglify-js@^3.1.4: resolved "https://registry.npmjs.org/uglify-js/-/uglify-js-3.18.0.tgz" integrity sha512-SyVVbcNBCk0dzr9XL/R/ySrmYf0s372K6/hFklzgcp2lBFyXtw4I7BOdDjlLhE1aVqaI/SHWXWmYdlZxuyF38A== +"undici-types@>=7.24.0 <7.24.7": + version "7.24.6" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.24.6.tgz#61275b485d7fd4e9d269c7cf04ec2873c9cc0f91" + integrity sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg== + undici-types@~7.16.0: version "7.16.0" resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz" integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== -undici@^7.18.2: - version "7.18.2" - resolved "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz" - integrity sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw== +undici@^7.29.0: + version "7.29.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-7.29.0.tgz#ae0f6f62e06e057a9cbb7b2b5fde2bb74f791b8f" + integrity sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw== unicorn-magic@^0.1.0: version "0.1.0" @@ -6728,44 +7115,43 @@ vary@^1.1.2, vary@~1.1.2: resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== -"vite@^6.0.0 || ^7.0.0": - version "7.3.1" - resolved "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz" - integrity sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA== +"vite@^6.0.0 || ^7.0.0 || ^8.0.0": + version "8.2.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-8.2.2.tgz#399aefad3656145145be110d137a07ea5bb55014" + integrity sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q== dependencies: - esbuild "^0.27.0" - fdir "^6.5.0" - picomatch "^4.0.3" - postcss "^8.5.6" - rollup "^4.43.0" - tinyglobby "^0.2.15" + lightningcss "^1.33.0" + picomatch "^4.0.5" + postcss "^8.5.26" + rolldown "~1.2.4" + tinyglobby "^0.2.17" optionalDependencies: fsevents "~2.3.3" -vitest@^4.0.17: - version "4.0.17" - resolved "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz" - integrity sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg== - dependencies: - "@vitest/expect" "4.0.17" - "@vitest/mocker" "4.0.17" - "@vitest/pretty-format" "4.0.17" - "@vitest/runner" "4.0.17" - "@vitest/snapshot" "4.0.17" - "@vitest/spy" "4.0.17" - "@vitest/utils" "4.0.17" - es-module-lexer "^1.7.0" - expect-type "^1.2.2" +vitest@^4.1.11: + version "4.1.11" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.11.tgz#1653c1521ae917f960d9b21877797c47dfd8bf21" + integrity sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw== + dependencies: + "@vitest/expect" "4.1.11" + "@vitest/mocker" "4.1.11" + "@vitest/pretty-format" "4.1.11" + "@vitest/runner" "4.1.11" + "@vitest/snapshot" "4.1.11" + "@vitest/spy" "4.1.11" + "@vitest/utils" "4.1.11" + es-module-lexer "^2.0.0" + expect-type "^1.3.0" magic-string "^0.30.21" obug "^2.1.1" pathe "^2.0.3" picomatch "^4.0.3" - std-env "^3.10.0" + std-env "^4.0.0-rc.1" tinybench "^2.9.0" tinyexec "^1.0.2" tinyglobby "^0.2.15" - tinyrainbow "^3.0.3" - vite "^6.0.0 || ^7.0.0" + tinyrainbow "^3.1.0" + vite "^6.0.0 || ^7.0.0 || ^8.0.0" why-is-node-running "^2.3.0" wcwidth@^1.0.1: @@ -6836,9 +7222,9 @@ wrap-ansi@^3.0.1: string-width "^2.1.1" strip-ansi "^4.0.0" -wrap-ansi@^6.2.0: +wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: version "6.2.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== dependencies: ansi-styles "^4.0.0" @@ -6938,11 +7324,6 @@ yocto-queue@^0.1.0: resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -yocto-queue@^1.0.0: - version "1.1.1" - resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz" - integrity sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g== - yocto-queue@^1.2.1: version "1.2.2" resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz" From d53598809a2d966b6b7bf7ee9d2431148b47a591 Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Sat, 29 Aug 2026 09:36:57 -0400 Subject: [PATCH 15/26] chore: ignore deepwork state --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c6445ec..520497e 100644 --- a/.gitignore +++ b/.gitignore @@ -108,4 +108,5 @@ typings/ .AppleDouble .LSOverride bin/ -dist/ \ No newline at end of file +dist/ +.slim/deepwork/ From 3e2eddb23b09e86917c010eb6ad8e75b7d1ee2bc Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Sat, 29 Aug 2026 09:44:39 -0400 Subject: [PATCH 16/26] chore(deps): upgrade supertest types --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 25de176..5948cb5 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "@types/ip": "^1.1.3", "@types/lodash": "^4.17.25", "@types/node": "^25.9.5", - "@types/supertest": "^6.0.3", + "@types/supertest": "^7.2.1", "@types/swagger-stats": "^0.95.11", "@vitest/coverage-v8": "^4.1.11", "commitizen": "^4.3.2", diff --git a/yarn.lock b/yarn.lock index 6007b0c..e2d0525 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1590,10 +1590,10 @@ "@types/methods" "^1.1.4" "@types/node" "*" -"@types/supertest@^6.0.3": - version "6.0.3" - resolved "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz" - integrity sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w== +"@types/supertest@^7.2.1": + version "7.2.1" + resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-7.2.1.tgz#165e99f10fd652027cf1eaa74b55081b6daa0965" + integrity sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw== dependencies: "@types/methods" "^1.1.4" "@types/superagent" "^8.1.0" From 204192f0e18bbf8273d81659bec0b146186fcbcc Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Sat, 29 Aug 2026 10:23:04 -0400 Subject: [PATCH 17/26] test: migrate proxy mocks to undici Keep direct fetch tests deterministic across Undici versions. --- src/proxy-client.spec.ts | 226 ++++++++++++++++++++++----------------- src/proxy-client.ts | 4 +- src/router.spec.ts | 152 ++++++++++++++------------ src/router.ts | 5 +- src/server.spec.ts | 53 +++++---- 5 files changed, 248 insertions(+), 192 deletions(-) diff --git a/src/proxy-client.spec.ts b/src/proxy-client.spec.ts index c4a5288..fb2ef42 100644 --- a/src/proxy-client.spec.ts +++ b/src/proxy-client.spec.ts @@ -2,33 +2,55 @@ import EventEmitter from 'node:events'; import { IncomingMessage, ServerResponse } from 'node:http'; -import { Agent as HttpsAgent } from 'node:https'; import { StatusCodes } from 'http-status-codes'; -import nock from 'nock'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import { + Agent, + MockAgent, + type MockPool, + Headers as UndiciHeaders, + type Response as UndiciResponse, + fetch as undiciFetch +} from 'undici'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('undici', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetch: vi.fn(actual.fetch) }; +}); import { ProxyClient } from './proxy-client'; describe('ProxyClient', () => { let client: ProxyClient; - let scope: nock.Scope; + let mockAgent: MockAgent | undefined; + let mockPool!: MockPool; const TARGET = 'https://api.example.com'; const TIMEOUT = 5000; - beforeAll(() => { - nock.disableNetConnect(); - }); - - afterAll(() => { - nock.cleanAll(); - nock.restore(); - nock.enableNetConnect(); - }); - - afterEach(() => { - nock.cleanAll(); + function intercept(method: string, path: string, body?: unknown) { + return mockPool.intercept({ + method, + path, + ...(body === undefined + ? {} + : { + body: (value: string): boolean => + Buffer.from(value).toString() === + (typeof body === 'string' ? body : JSON.stringify(body)) + }) + }); + } + + function normalizeHeaders( + headers: UndiciHeaders | Record | undefined + ): Record { + return Object.fromEntries(new UndiciHeaders(headers).entries()); + } + + afterEach(async () => { + await mockAgent?.close(); }); describe('constructor', () => { @@ -41,29 +63,33 @@ describe('ProxyClient', () => { expect(proxyClient).toBeInstanceOf(ProxyClient); }); - test('should create client with custom agent', () => { - const agent = new HttpsAgent({ keepAlive: true }); + test('should create client with custom agent', async () => { + const agent = new Agent({ keepAliveTimeout: 60000 }); const proxyClient = new ProxyClient({ target: TARGET, timeout: TIMEOUT, - agent + dispatcher: agent }); expect(proxyClient).toBeInstanceOf(ProxyClient); + await agent.close(); }); }); describe('proxy method', () => { beforeEach(() => { + mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); + mockPool = mockAgent.get(TARGET); client = new ProxyClient({ target: TARGET, - timeout: TIMEOUT + timeout: TIMEOUT, + dispatcher: mockAgent }); - scope = nock(TARGET); }); test('should proxy GET request successfully', async () => { - scope.get('/test').reply(StatusCodes.OK, { success: true }); + intercept('GET', '/test').reply(StatusCodes.OK, { success: true }); const { req, res } = createMockRequestResponse('GET', '/test'); @@ -76,7 +102,7 @@ describe('ProxyClient', () => { test('should proxy POST request with body', async () => { const requestBody = { data: 'test' }; - scope.post('/test', requestBody).reply(StatusCodes.CREATED, { id: 123 }); + intercept('POST', '/test', requestBody).reply(StatusCodes.CREATED, { id: 123 }); const { req, res } = createMockRequestResponse('POST', '/test', requestBody); @@ -87,8 +113,13 @@ describe('ProxyClient', () => { }); test('should accept a request body exactly at the configured limit', async () => { - client = new ProxyClient({ target: TARGET, timeout: TIMEOUT, maxRequestBodyBytes: 7 }); - scope.post('/at-limit', '"12345"').reply(StatusCodes.OK, 'ok'); + client = new ProxyClient({ + target: TARGET, + timeout: TIMEOUT, + maxRequestBodyBytes: 7, + dispatcher: mockAgent + }); + intercept('POST', '/at-limit', '"12345"').reply(StatusCodes.OK, 'ok'); const { req, res } = createMockRequestResponse('POST', '/at-limit', '12345', { 'content-length': '7' }); @@ -133,9 +164,9 @@ describe('ProxyClient', () => { test('should copy request headers', async () => { let receivedHeaders: Record = {}; - scope.get('/test').reply(function () { - receivedHeaders = this.req.headers as Record; - return [StatusCodes.OK, { success: true }]; + intercept('GET', '/test').reply(({ headers }) => { + receivedHeaders = normalizeHeaders(headers); + return { statusCode: StatusCodes.OK, data: { success: true } }; }); const { req, res } = createMockRequestResponse('GET', '/test', undefined, { @@ -152,9 +183,9 @@ describe('ProxyClient', () => { test('should filter hop-by-hop request headers and connection tokens', async () => { let receivedHeaders: Record = {}; - scope.get('/hop-by-hop').reply(function () { - receivedHeaders = this.req.headers as Record; - return [StatusCodes.OK, 'ok']; + intercept('GET', '/hop-by-hop').reply(({ headers }) => { + receivedHeaders = normalizeHeaders(headers); + return { statusCode: StatusCodes.OK, data: 'ok' }; }); const { req, res } = createMockRequestResponse('GET', '/hop-by-hop', undefined, { @@ -177,9 +208,9 @@ describe('ProxyClient', () => { test('should preserve trusted forwarded headers named by inbound Connection', async () => { let receivedHeaders: Record = {}; - scope.get('/forwarded-connection').reply(function () { - receivedHeaders = this.req.headers as Record; - return [StatusCodes.OK, 'ok']; + intercept('GET', '/forwarded-connection').reply(({ headers }) => { + receivedHeaders = normalizeHeaders(headers); + return { statusCode: StatusCodes.OK, data: 'ok' }; }); const { req, res } = createMockRequestResponse('GET', '/forwarded-connection', undefined, { @@ -199,9 +230,9 @@ describe('ProxyClient', () => { test('should preserve modified authorization despite an inbound Connection token', async () => { let receivedHeaders: Record = {}; - scope.get('/authorization').reply(function () { - receivedHeaders = this.req.headers as Record; - return [StatusCodes.OK, 'ok']; + intercept('GET', '/authorization').reply(({ headers }) => { + receivedHeaders = normalizeHeaders(headers); + return { statusCode: StatusCodes.OK, data: 'ok' }; }); const { req, res } = createMockRequestResponse('GET', '/authorization', undefined, { @@ -218,9 +249,9 @@ describe('ProxyClient', () => { test('should add forwarded metadata from the immediate request', async () => { let receivedHeaders: Record = {}; - scope.get('/test').reply(function () { - receivedHeaders = this.req.headers as Record; - return [StatusCodes.OK, { success: true }]; + intercept('GET', '/test').reply(({ headers }) => { + receivedHeaders = normalizeHeaders(headers); + return { statusCode: StatusCodes.OK, data: { success: true } }; }); const { req, res } = createMockRequestResponse('GET', '/test', undefined, { @@ -237,9 +268,9 @@ describe('ProxyClient', () => { test('should derive HTTPS protocol from the request socket', async () => { let receivedHeaders: Record = {}; - scope.get('/test').reply(function () { - receivedHeaders = this.req.headers as Record; - return [StatusCodes.OK, { success: true }]; + intercept('GET', '/test').reply(({ headers }) => { + receivedHeaders = normalizeHeaders(headers); + return { statusCode: StatusCodes.OK, data: { success: true } }; }); const { req, res } = createMockRequestResponse( @@ -258,9 +289,9 @@ describe('ProxyClient', () => { test('should replace spoofed forwarded headers with immediate request metadata', async () => { let receivedHeaders: Record = {}; - scope.get('/test').reply(function () { - receivedHeaders = this.req.headers as Record; - return [StatusCodes.OK, { success: true }]; + intercept('GET', '/test').reply(({ headers }) => { + receivedHeaders = normalizeHeaders(headers); + return { statusCode: StatusCodes.OK, data: { success: true } }; }); const { req, res } = createMockRequestResponse('GET', '/test', undefined, { @@ -280,9 +311,9 @@ describe('ProxyClient', () => { test('should send an empty forwarded host when the request has no host', async () => { let receivedHeaders: Record = {}; - scope.get('/test').reply(function () { - receivedHeaders = this.req.headers as Record; - return [StatusCodes.OK, { success: true }]; + intercept('GET', '/test').reply(({ headers }) => { + receivedHeaders = normalizeHeaders(headers); + return { statusCode: StatusCodes.OK, data: { success: true } }; }); const { req, res } = createMockRequestResponse('GET', '/test'); @@ -296,9 +327,9 @@ describe('ProxyClient', () => { test('should modify headers via modifyHeaders callback', async () => { let receivedHeaders: Record = {}; - scope.get('/test').reply(function () { - receivedHeaders = this.req.headers as Record; - return [StatusCodes.OK, { success: true }]; + intercept('GET', '/test').reply(({ headers }) => { + receivedHeaders = normalizeHeaders(headers); + return { statusCode: StatusCodes.OK, data: { success: true } }; }); const { req, res } = createMockRequestResponse('GET', '/test'); @@ -314,7 +345,7 @@ describe('ProxyClient', () => { }); test('should call onResponse callback', async () => { - scope.get('/test').reply(StatusCodes.OK, { success: true }); + intercept('GET', '/test').reply(StatusCodes.OK, { success: true }); const { req, res } = createMockRequestResponse('GET', '/test'); @@ -333,7 +364,13 @@ describe('ProxyClient', () => { }); test('should allow header manipulation in onResponse', async () => { - scope.get('/test').reply(StatusCodes.OK, { success: true }, { 'x-rate-limit': '100' }); + intercept('GET', '/test').reply( + StatusCodes.OK, + { success: true }, + { + headers: { 'x-rate-limit': '100' } + } + ); const { req, res } = createMockRequestResponse('GET', '/test'); @@ -349,13 +386,10 @@ describe('ProxyClient', () => { }); test('should copy response headers', async () => { - scope.get('/test').reply( + intercept('GET', '/test').reply( StatusCodes.OK, { success: true }, - { - 'content-type': 'application/json', - 'x-custom-header': 'custom-value' - } + { headers: { 'content-type': 'application/json', 'x-custom-header': 'custom-value' } } ); const { req, res } = createMockRequestResponse('GET', '/test'); @@ -375,12 +409,12 @@ describe('ProxyClient', () => { Object.defineProperty(headers, 'getSetCookie', { value: () => ['first=1; Path=/', 'second=2; Path=/'] }); - const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + const fetch = vi.mocked(undiciFetch).mockResolvedValue({ status: StatusCodes.OK, statusText: 'OK', headers, body: null - } as unknown as globalThis.Response); + } as unknown as UndiciResponse); const { req, res } = createMockRequestResponse('GET', '/response-headers'); @@ -397,7 +431,7 @@ describe('ProxyClient', () => { }); test('should handle empty response body', async () => { - scope.get('/test').reply(StatusCodes.NO_CONTENT); + intercept('GET', '/test').reply(StatusCodes.NO_CONTENT); const { req, res } = createMockRequestResponse('GET', '/test'); @@ -409,7 +443,7 @@ describe('ProxyClient', () => { test('should handle large response bodies with streaming', async () => { const largeBody = 'x'.repeat(1024 * 1024); // 1MB - scope.get('/large').reply(StatusCodes.OK, largeBody); + intercept('GET', '/large').reply(StatusCodes.OK, largeBody); const { req, res } = createMockRequestResponse('GET', '/large'); @@ -423,13 +457,13 @@ describe('ProxyClient', () => { const shortTimeout = 100; const timeoutClient = new ProxyClient({ target: TARGET, - timeout: shortTimeout + timeout: shortTimeout, + dispatcher: mockAgent }); - scope - .get('/slow') - .delay(shortTimeout * 2) - .reply(StatusCodes.OK); + intercept('GET', '/slow') + .reply(StatusCodes.OK) + .delay(shortTimeout * 2); const { req, res } = createMockRequestResponse('GET', '/slow'); @@ -440,10 +474,9 @@ describe('ProxyClient', () => { test('should cancel an active upstream request with the caller controller', async () => { const controller = new AbortController(); - scope - .get('/cancel') - .delay(TIMEOUT * 2) - .reply(StatusCodes.OK); + intercept('GET', '/cancel') + .reply(StatusCodes.OK) + .delay(TIMEOUT * 2); const { req, res } = createMockRequestResponse('GET', '/cancel'); const proxy = client.proxy(req, res, { abortController: controller }); @@ -462,7 +495,7 @@ describe('ProxyClient', () => { const responseStarted = new Promise((resolve) => { onResponseStarted = resolve; }); - scope.get('/cancel-on-response').reply(StatusCodes.OK); + intercept('GET', '/cancel-on-response').reply(StatusCodes.OK); const { req, res } = createMockRequestResponse('GET', '/cancel-on-response'); const proxy = client.proxy(req, res, { @@ -487,12 +520,12 @@ describe('ProxyClient', () => { const read = vi.fn(() => new Promise>(() => undefined)); const cancel = vi.fn().mockResolvedValue(undefined); const releaseLock = vi.fn(); - const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + const fetch = vi.mocked(undiciFetch).mockResolvedValue({ status: StatusCodes.OK, statusText: 'OK', headers: new Headers(), body: { getReader: () => ({ read, cancel, releaseLock }) } - } as unknown as globalThis.Response); + } as unknown as UndiciResponse); const { req, res } = createMockRequestResponse('GET', '/stream-cancel'); try { @@ -521,12 +554,12 @@ describe('ProxyClient', () => { ); const cancel = vi.fn().mockResolvedValue(undefined); const releaseLock = vi.fn(); - const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + const fetch = vi.mocked(undiciFetch).mockResolvedValue({ status: StatusCodes.OK, statusText: 'OK', headers: new Headers(), body: { getReader: () => ({ read, cancel, releaseLock }) } - } as unknown as globalThis.Response); + } as unknown as UndiciResponse); const { req, res } = createMockRequestResponse('GET', '/backpressure-cancel'); const removeListener = vi.fn(); res.write = vi.fn(() => false); @@ -553,15 +586,17 @@ describe('ProxyClient', () => { }); test('should handle network errors', async () => { - scope.get('/error').replyWithError(new Error('Network error')); + intercept('GET', '/error').replyWithError(new Error('Network error')); const { req, res } = createMockRequestResponse('GET', '/error'); - await expect(client.proxy(req, res)).rejects.toThrow('Network error'); + await expect(client.proxy(req, res)).rejects.toMatchObject({ + cause: expect.objectContaining({ message: 'Network error' }) + }); }); test('should handle HTTP error responses', async () => { - scope.get('/not-found').reply(StatusCodes.NOT_FOUND, { error: 'Not Found' }); + intercept('GET', '/not-found').reply(StatusCodes.NOT_FOUND, { error: 'Not Found' }); const { req, res } = createMockRequestResponse('GET', '/not-found'); @@ -572,8 +607,8 @@ describe('ProxyClient', () => { }); test('should handle redirect responses with redirect: manual', async () => { - scope.get('/redirect').reply(StatusCodes.MOVED_PERMANENTLY, undefined, { - location: 'https://api.example.com/new-location' + intercept('GET', '/redirect').reply(StatusCodes.MOVED_PERMANENTLY, undefined, { + headers: { location: 'https://api.example.com/new-location' } }); const { req, res } = createMockRequestResponse('GET', '/redirect'); @@ -585,7 +620,7 @@ describe('ProxyClient', () => { }); test('should handle array headers', async () => { - scope.get('/test').reply(StatusCodes.OK, { success: true }); + intercept('GET', '/test').reply(StatusCodes.OK, { success: true }); const { req, res } = createMockRequestResponse('GET', '/test', undefined, { accept: ['application/json', 'text/html'] @@ -599,9 +634,9 @@ describe('ProxyClient', () => { test('should remove host header to avoid conflicts', async () => { let receivedHeaders: Record = {}; - scope.get('/test').reply(function () { - receivedHeaders = this.req.headers as Record; - return [StatusCodes.OK, { success: true }]; + intercept('GET', '/test').reply(({ headers }) => { + receivedHeaders = normalizeHeaders(headers); + return { statusCode: StatusCodes.OK, data: { success: true } }; }); const { req, res } = createMockRequestResponse('GET', '/test', undefined, { @@ -617,7 +652,7 @@ describe('ProxyClient', () => { test('should handle PUT requests', async () => { const requestBody = { updated: true }; - scope.put('/resource/123', requestBody).reply(StatusCodes.OK, { success: true }); + intercept('PUT', '/resource/123', requestBody).reply(StatusCodes.OK, { success: true }); const { req, res } = createMockRequestResponse('PUT', '/resource/123', requestBody); @@ -628,7 +663,7 @@ describe('ProxyClient', () => { }); test('should handle DELETE requests', async () => { - scope.delete('/resource/123').reply(StatusCodes.NO_CONTENT); + intercept('DELETE', '/resource/123').reply(StatusCodes.NO_CONTENT); const { req, res } = createMockRequestResponse('DELETE', '/resource/123'); @@ -641,7 +676,7 @@ describe('ProxyClient', () => { test('should handle PATCH requests', async () => { const requestBody = { field: 'new-value' }; - scope.patch('/resource/123', requestBody).reply(StatusCodes.OK, { success: true }); + intercept('PATCH', '/resource/123', requestBody).reply(StatusCodes.OK, { success: true }); const { req, res } = createMockRequestResponse('PATCH', '/resource/123', requestBody); @@ -652,7 +687,7 @@ describe('ProxyClient', () => { }); test('should handle streaming errors and release reader lock', async () => { - scope.get('/streaming-error').reply(StatusCodes.OK, 'test response'); + intercept('GET', '/streaming-error').reply(StatusCodes.OK, 'test response'); const { req, res } = createMockRequestResponse('GET', '/streaming-error'); @@ -665,13 +700,10 @@ describe('ProxyClient', () => { }); test('should allow header mutation in onResponse callback', async () => { - scope.get('/test').reply( + intercept('GET', '/test').reply( StatusCodes.OK, { success: true }, - { - 'x-rate-limit': '100', - 'x-scope': 'repo' - } + { headers: { 'x-rate-limit': '100', 'x-scope': 'repo' } } ); const { req, res } = createMockRequestResponse('GET', '/test'); @@ -693,8 +725,8 @@ describe('ProxyClient', () => { test('should remove content-encoding and content-length headers', async () => { // Mock a response that simulates having encoding headers // (fetch will have already decompressed, but headers remain) - scope.get('/api-response').reply(StatusCodes.OK, JSON.stringify({ success: true }), { - 'content-type': 'application/json' + intercept('GET', '/api-response').reply(StatusCodes.OK, JSON.stringify({ success: true }), { + headers: { 'content-type': 'application/json' } }); const { req, res } = createMockRequestResponse('GET', '/api-response'); diff --git a/src/proxy-client.ts b/src/proxy-client.ts index 4a1e51f..d880098 100644 --- a/src/proxy-client.ts +++ b/src/proxy-client.ts @@ -1,7 +1,7 @@ /* Author: Hudson S. Borges */ import type { IncomingMessage, ServerResponse } from 'node:http'; -import type { Dispatcher } from 'undici'; +import { type Dispatcher, fetch as undiciFetch } from 'undici'; export type ProxyHeaderValue = string | string[]; export type ProxyResponseHeaders = Record; @@ -115,7 +115,7 @@ export class ProxyClient { } // Make the fetch request - const response = await fetch(targetUrl.toString(), { + const response = await undiciFetch(targetUrl.toString(), { method: req.method, headers: requestHeaders, body: body, diff --git a/src/router.spec.ts b/src/router.spec.ts index 84d7fa2..3b5b507 100644 --- a/src/router.spec.ts +++ b/src/router.spec.ts @@ -6,6 +6,7 @@ import repeat from 'lodash/repeat.js'; import times from 'lodash/times.js'; import nock from 'nock'; import request from 'supertest'; +import { MockAgent, type MockPool } from 'undici'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { PayloadTooLargeError } from './proxy-client'; @@ -1129,15 +1130,34 @@ function createStateAwareRequestResponse(state: { } describe('Middleware core', () => { - let scope: nock.Scope; + let mockAgent: MockAgent; + let mockPool: MockPool; let middleware: Middleware; const requestTimeout = 1000; + function proxyReply( + method: string, + path: string, + status: number, + data?: Record | string, + headers: Record = {} + ) { + return mockPool.intercept({ path, method }).reply(status, data, { + headers: { + 'x-oauth-scopes': 'public_repo, read:org, read:user, user:emai', + 'x-ratelimit-remaining': '4999', + 'x-ratelimit-limit': '5000', + 'x-ratelimit-reset': `${Math.floor((Date.now() + 60 * 60 * 1000) / 1000)}`, + ...headers + } + }); + } + beforeEach(async () => { if (!nock.isActive()) nock.activate(); - scope = nock('https://api.github.com', { allowUnmocked: false }) + nock('https://api.github.com', { allowUnmocked: false }) .get('/rate_limit') .reply(StatusCodes.OK, { resources: { @@ -1149,12 +1169,17 @@ describe('Middleware core', () => { }) .persist(); + mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); + mockPool = mockAgent.get('https://api.github.com'); + app = express(); middleware = new Middleware([FAKE_TOKEN], { requestTimeout, minRemaining: 0, - overrideAuthorization: false + overrideAuthorization: false, + dispatcher: mockAgent }); await new Promise((resolve) => middleware.on('ready', resolve)); @@ -1163,10 +1188,10 @@ describe('Middleware core', () => { }); afterEach(async () => { + await middleware.destroy(); + await mockAgent.close(); nock.cleanAll(); nock.restore(); - - await middleware.destroy(); }); afterAll(() => { @@ -1175,11 +1200,13 @@ describe('Middleware core', () => { describe('GitHub API is down or not reachable', () => { beforeEach(() => { - scope.get(/.*/).replyWithError({ + const connectionError = new Error('connect failed'); + Object.assign(connectionError, { code: 'ECONNREFUSED', errno: 'ECONNREFUSED', syscall: 'getaddrinfo' }); + mockPool.intercept({ path: '/', method: 'GET' }).replyWithError(connectionError); }); test(`it should respond with Bad Gateway (${StatusCodes.BAD_GATEWAY})`, async () => { @@ -1761,33 +1788,15 @@ describe('Middleware core', () => { }); describe('GitHub API is online', () => { - let scope: nock.Scope; - - beforeEach(async () => { - scope = nock('https://api.github.com') - .persist() - .defaultReplyHeaders({ - 'access-control-expose-headers': - 'ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, Deprecation, Sunset', - 'x-oauth-scopes': 'public_repo, read:org, read:user, user:emai', - 'x-ratelimit-remaining': '4999', - 'x-ratelimit-limit': '5000', - 'x-ratelimit-reset': `${Math.floor((Date.now() + 60 * 60 * 1000) / 1000)}` - }); - }); - test('it should wait if no requests available', async () => { const reset = Date.now() + 1000; // must be greather or equal to 1 - scope - .get('/reset') - .reply(StatusCodes.OK, '', { - 'x-ratelimit-remaining': '0', - 'x-ratelimit-limit': '5000', - 'x-ratelimit-reset': `${Math.floor(reset / 1000)}` - }) - .get('/') - .reply(StatusCodes.OK); + proxyReply('GET', '/reset', StatusCodes.OK, '', { + 'x-ratelimit-remaining': '0', + 'x-ratelimit-limit': '5000', + 'x-ratelimit-reset': `${Math.floor(reset / 1000)}` + }); + proxyReply('GET', '/', StatusCodes.OK); await request(app).get('/reset').expect(StatusCodes.OK); @@ -1796,44 +1805,41 @@ describe('Middleware core', () => { }); test('it should forward responses received from GitHub', async () => { - scope.get('/').reply(200); + proxyReply('GET', '/', 200).persist(); await request(app) .get('/') .then(({ status }) => expect(status).toEqual(200)); - scope.get('/300').reply(300); + proxyReply('GET', '/300', 300); await request(app) .get('/300') .catch(({ response }) => expect(response.status).toEqual(300)); - scope.get('/400').reply(400); + proxyReply('GET', '/400', 400); await request(app) .get('/400') .catch(({ response }) => expect(response.status).toEqual(400)); - scope.get('/500').reply(500); + proxyReply('GET', '/500', 500); await request(app) .get('/500') .catch(({ response }) => expect(response.status).toEqual(500)); }); test('it should interrupt long requests', async () => { - scope - .get('/') - .delay(requestTimeout * 2) - .reply(StatusCodes.OK); + proxyReply('GET', '/', StatusCodes.OK).delay(requestTimeout * 2); return request(app).get('/').expect(StatusCodes.BAD_GATEWAY); }); test('it should respond to broken connections', async () => { - scope.get('/').replyWithError(new Error('Server Error')); + mockPool.intercept({ path: '/', method: 'GET' }).replyWithError(new Error('Server Error')); return request(app).get('/').expect(StatusCodes.BAD_GATEWAY); }); test('it should not break proxy when client disconnect', async () => { - scope.get('/').delay(500).reply(StatusCodes.OK); + proxyReply('GET', '/', StatusCodes.OK).persist().delay(500); await Promise.all( times(25, () => @@ -1848,7 +1854,7 @@ describe('Middleware core', () => { }); test('it should balance the use of the tokens', async () => { - scope.get('/').delay(250).reply(200); + proxyReply('GET', '/', 200).persist().delay(250); const tokens = times(5, (n) => `${repeat('t', 39)}${n}`) .concat(FAKE_TOKEN) @@ -1873,7 +1879,7 @@ describe('Middleware core', () => { }); test('it should not forward ratelimit and scope information', async () => { - scope.get('/').delay(250).reply(200); + proxyReply('GET', '/', 200).persist().delay(250); return request(app) .get('/') @@ -1884,21 +1890,22 @@ describe('Middleware core', () => { }); test('it should handle unauthorized requests to API', async () => { - scope - .defaultReplyHeaders({ - 'x-ratelimit-remaining': '59', - 'x-ratelimit-reset': `${Math.floor((Date.now() + 60 * 60 * 1000) / 1000)}` - }) - .get('/user') - .matchHeader('authorization', `token ${repeat('i', 40)}`) - .reply(401, '', { 'x-ratelimit-limit': '60' }) - .get('/user') - .matchHeader('authorization', `token ${repeat('j', 40)}`) - .reply(401, '') - .intercept('/user', 'get') - .reply(200) - .intercept('/', 'get') - .reply(200); + const tokenI = `token ${repeat('i', 40)}`; + const tokenJ = `token ${repeat('j', 40)}`; + mockPool + .intercept({ path: '/user', method: 'GET', headers: { authorization: tokenI } }) + .reply(401, '', { + headers: { + 'x-ratelimit-remaining': '59', + 'x-ratelimit-reset': `${Math.floor((Date.now() + 60 * 60 * 1000) / 1000)}`, + 'x-ratelimit-limit': '60' + } + }); + mockPool + .intercept({ path: '/user', method: 'GET', headers: { authorization: tokenJ } }) + .reply(401, ''); + proxyReply('GET', '/user', 200); + proxyReply('GET', '/', 200).persist(); await request(app).get('/').expect(200); await request(app).get('/user').expect(200); @@ -1932,12 +1939,11 @@ describe('Middleware core', () => { }); test('it should not update limits when "x-ratelimit-remaining" is not on header', async () => { - scope - .defaultReplyHeaders({ + mockPool.intercept({ path: '/', method: 'GET' }).reply(401, '', { + headers: { 'x-ratelimit-reset': `${Math.floor((Date.now() + 60 * 60 * 1000) / 1000)}` - }) - .get('/') - .reply(401); + } + }); await request(app).get('/').expect(401); }); @@ -1946,7 +1952,10 @@ describe('Middleware core', () => { const token = repeat('i', 40); const tokenStr = `token ${token}`; - scope.get('/').matchHeader('authorization', tokenStr).reply(401).get('/').reply(200); + mockPool + .intercept({ path: '/', method: 'GET', headers: { authorization: tokenStr } }) + .reply(401); + proxyReply('GET', '/', 200).persist(); await request(app).get('/').set('Authorization', tokenStr).expect(401); await request(app).get('/').expect(200); @@ -1955,7 +1964,8 @@ describe('Middleware core', () => { middleware = new Middleware([FAKE_TOKEN], { requestTimeout, minRemaining: 0, - overrideAuthorization: true + overrideAuthorization: true, + dispatcher: mockAgent }); await request(app).get('/').set('Authorization', tokenStr).expect(200); @@ -1965,7 +1975,7 @@ describe('Middleware core', () => { const linkStr = '; rel="next", ; rel="last"'; - scope.get('/').reply(200, {}, { link: linkStr }); + proxyReply('GET', '/', 200, {}, { link: linkStr }); await request(app) .get('/') @@ -1983,17 +1993,18 @@ describe('Middleware core', () => { middleware = new Middleware([FAKE_TOKEN], { requestTimeout, minRemaining: 0, - externalBaseUrl: baseUrl + externalBaseUrl: baseUrl, + dispatcher: mockAgent }); await new Promise((resolve) => middleware.once('ready', resolve)); const linkStr = '; rel="next", ; rel="other"'; - scope.get('/redirect').reply(StatusCodes.MOVED_TEMPORARILY, '', { + proxyReply('GET', '/redirect', StatusCodes.MOVED_TEMPORARILY, '', { location: 'https://api.github.com/repos/example', link: linkStr }); - scope.get('/unrelated-location').reply(StatusCodes.MOVED_TEMPORARILY, '', { + proxyReply('GET', '/unrelated-location', StatusCodes.MOVED_TEMPORARILY, '', { location: 'https://other.example/redirect?next=https://api.github.com/repos/example', link: linkStr }); @@ -2026,11 +2037,12 @@ describe('Middleware core', () => { middleware = new Middleware([FAKE_TOKEN], { requestTimeout, minRemaining: 0, - externalBaseUrl: 'https://proxy.example//edge' + externalBaseUrl: 'https://proxy.example//edge', + dispatcher: mockAgent }); await new Promise((resolve) => middleware.once('ready', resolve)); - scope.get('/double-slash').reply(StatusCodes.MOVED_TEMPORARILY, '', { + proxyReply('GET', '/double-slash', StatusCodes.MOVED_TEMPORARILY, '', { location: 'https://api.github.com//repos/example', link: '; rel="next"' }); diff --git a/src/router.ts b/src/router.ts index 598fcae..d728d0e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -4,7 +4,7 @@ import EventEmitter from 'node:events'; import type { Request, Response } from 'express'; import { StatusCodes } from 'http-status-codes'; import PQueue from 'p-queue'; -import { Agent } from 'undici'; +import { Agent, type Dispatcher } from 'undici'; import { PayloadTooLargeError, ProxyClient, type ProxyHeaderValue } from './proxy-client.js'; @@ -18,6 +18,7 @@ export type ProxyRouterOpts = { overrideAuthorization?: boolean; timeBudgetMultiplier?: number; externalBaseUrl?: string; + dispatcher?: Dispatcher; }; type ExtendedRequest = Request & { @@ -492,7 +493,7 @@ class ProxyWorker extends EventEmitter { target: 'https://api.github.com', timeout: opts.requestTimeout, maxRequestBodyBytes: opts.maxRequestBodyBytes, - dispatcher: this.agent + dispatcher: opts.dispatcher ?? this.agent }); let maxConcurrent = 1; diff --git a/src/server.spec.ts b/src/server.spec.ts index cd6d071..d17e57b 100644 --- a/src/server.spec.ts +++ b/src/server.spec.ts @@ -6,11 +6,37 @@ import times from 'lodash/times.js'; import nock from 'nock'; import request from 'supertest'; import { withFile } from 'tmp-promise'; -import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest'; +import { MockAgent, type MockPool } from 'undici'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest'; import { type CliOpts, createProxyServer, parseTokens, readTokensFile } from './server.js'; const createdApps: Array> = []; +let mockAgent: MockAgent; +let mockPool: MockPool; + +beforeAll(() => { + mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); + mockPool = mockAgent.get('https://api.github.com'); + mockPool.intercept({ path: '/', method: 'GET' }).reply(StatusCodes.OK).persist(); + mockPool + .intercept({ + path: '/user', + method: 'GET', + headers: { authorization: `token ${repeat('i', 40)}` } + }) + .reply(StatusCodes.UNAUTHORIZED) + .persist(); + mockPool.intercept({ path: '/user', method: 'GET' }).reply(StatusCodes.OK).persist(); + mockPool.intercept({ path: '/graphql', method: 'POST' }).reply(StatusCodes.OK).persist(); +}); + +afterAll(async () => { + await mockAgent.close(); + nock.cleanAll(); + nock.restore(); +}); function createTestApp(options: CliOpts): ReturnType { const app = createProxyServer(options); @@ -80,20 +106,7 @@ describe('Test create proxy server', () => { graphql: { limit: 5000, remaining: 5000, reset: Date.now() + 60 * 60 } } }) - .persist() - .get('/user') - .matchHeader('authorization', `token ${repeat('i', 40)}`) - .reply(StatusCodes.UNAUTHORIZED) - .post('/graphql') - .reply(200) - .intercept(/.*/, 'get') - .reply(200) - .intercept(/.*/, 'post') - .reply(600) - .intercept(/.*/, 'put') - .reply(600) - .intercept(/.*/, 'delete') - .reply(600); + .persist(); }); beforeEach(async () => { @@ -101,7 +114,8 @@ describe('Test create proxy server', () => { tokens: [repeat('0', 40)], minRemaining: 0, requestTimeout: 500, - silent: true + silent: true, + dispatcher: mockAgent }; }); @@ -222,11 +236,7 @@ describe('Test proxy authentication', () => { graphql: { limit: 5000, remaining: 5000, reset: Date.now() + 60 * 60 } } }) - .persist() - .intercept(/.*/, 'get') - .reply(200) - .post('/graphql') - .reply(200); + .persist(); }); beforeEach(() => { @@ -235,6 +245,7 @@ describe('Test proxy authentication', () => { minRemaining: 0, requestTimeout: 500, silent: true, + dispatcher: mockAgent, auth: { username: 'testuser', password: 'testpass' From 516667468b510f935c22ac73d04119cdd219c71c Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Sat, 29 Aug 2026 10:26:57 -0400 Subject: [PATCH 18/26] chore(deps): upgrade node types --- package.json | 2 +- yarn.lock | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 5948cb5..4191b4c 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "@types/compression": "^1.8.1", "@types/ip": "^1.1.3", "@types/lodash": "^4.17.25", - "@types/node": "^25.9.5", + "@types/node": "^26.4.0", "@types/supertest": "^7.2.1", "@types/swagger-stats": "^0.95.11", "@vitest/coverage-v8": "^4.1.11", diff --git a/yarn.lock b/yarn.lock index e2d0525..62a7d98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1542,12 +1542,12 @@ dependencies: undici-types "~7.16.0" -"@types/node@^25.9.5": - version "25.9.5" - resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.5.tgz#0fefc09e6e82e94cde291bacf43522e989eb01a4" - integrity sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg== +"@types/node@^26.4.0": + version "26.4.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.4.0.tgz#4c4ca071c42241fe602741d02999f16b4e64c468" + integrity sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ== dependencies: - undici-types ">=7.24.0 <7.24.7" + undici-types "~8.3.0" "@types/normalize-package-data@^2.4.0", "@types/normalize-package-data@^2.4.3": version "2.4.4" @@ -7029,16 +7029,16 @@ uglify-js@^3.1.4: resolved "https://registry.npmjs.org/uglify-js/-/uglify-js-3.18.0.tgz" integrity sha512-SyVVbcNBCk0dzr9XL/R/ySrmYf0s372K6/hFklzgcp2lBFyXtw4I7BOdDjlLhE1aVqaI/SHWXWmYdlZxuyF38A== -"undici-types@>=7.24.0 <7.24.7": - version "7.24.6" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.24.6.tgz#61275b485d7fd4e9d269c7cf04ec2873c9cc0f91" - integrity sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg== - undici-types@~7.16.0: version "7.16.0" resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz" integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== +undici-types@~8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.3.0.tgz#44e9fc9f3244648cdea35e4f9bb2d681e9410809" + integrity sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ== + undici@^7.29.0: version "7.29.0" resolved "https://registry.yarnpkg.com/undici/-/undici-7.29.0.tgz#ae0f6f62e06e057a9cbb7b2b5fde2bb74f791b8f" From 41d820f9f9acef31558df4bf1adc98981472e6e3 Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Sat, 29 Aug 2026 10:30:00 -0400 Subject: [PATCH 19/26] chore(deps): upgrade chalk --- package.json | 2 +- yarn.lock | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 4191b4c..e26338d 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ ], "dependencies": { "basic-auth": "^2.0.1", - "chalk": "5.6.2", + "chalk": "6.0.0", "commander": "^14.0.3", "compression": "^1.8.1", "consola": "^3.4.2", diff --git a/yarn.lock b/yarn.lock index 62a7d98..69797ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2105,10 +2105,10 @@ chalk-template@^1.1.0: dependencies: chalk "^5.2.0" -chalk@5.6.2, chalk@^5.4.1: - version "5.6.2" - resolved "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz" - integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== +chalk@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-6.0.0.tgz#a3bae843bc8454f41ef66ef3fd584dcf34dd805d" + integrity sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg== chalk@^1.0.0, chalk@^1.1.3: version "1.1.3" @@ -2143,6 +2143,11 @@ chalk@^5.2.0, chalk@^5.3.0: resolved "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz" integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== +chalk@^5.4.1: + version "5.6.2" + resolved "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz" + integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== + chardet@^0.7.0: version "0.7.0" resolved "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz" From 3cd7fa1699f216bd28babe627198c167e9081840 Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Sat, 29 Aug 2026 10:38:18 -0400 Subject: [PATCH 20/26] chore(deps): upgrade commander --- package.json | 2 +- src/cli.spec.ts | 33 +++++++++++++++++++++++++++++++++ yarn.lock | 8 ++++---- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index e26338d..5b59118 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "dependencies": { "basic-auth": "^2.0.1", "chalk": "6.0.0", - "commander": "^14.0.3", + "commander": "^15.0.0", "compression": "^1.8.1", "consola": "^3.4.2", "dayjs": "^1.11.23", diff --git a/src/cli.spec.ts b/src/cli.spec.ts index f47a1ee..1af18c9 100644 --- a/src/cli.spec.ts +++ b/src/cli.spec.ts @@ -22,6 +22,24 @@ import { } from './router.js'; import { concatTokens, parseTokens, readTokensFile } from './server.js'; +type BooleanCliOptions = { + overrideAuthorization: boolean; + statusMonitor: boolean; +}; + +async function parseBooleanOptions(args: string[]): Promise { + const program = createCli(); + let options: BooleanCliOptions | undefined; + program.action((parsedOptions: BooleanCliOptions) => { + options = parsedOptions; + }); + + await program.parseAsync(['node', 'test', ...args]); + + if (!options) throw new Error('CLI options were not parsed'); + return options; +} + export type CliCmdResult = { code: number; error?: Error | null; @@ -278,6 +296,21 @@ describe('createCli command structure', () => { expect(statusMonitorOption).toBeDefined(); }); + test.each([ + [[], true, true], + [['--no-override-authorization'], false, true], + [['--no-status-monitor'], true, false], + [['--no-override-authorization', '--no-status-monitor'], false, false] + ])( + 'should parse boolean defaults and explicit negative values %#', + async (args, overrideAuthorization, statusMonitor) => { + await expect(parseBooleanOptions(args)).resolves.toMatchObject({ + overrideAuthorization, + statusMonitor + }); + } + ); + test('should have trusted external base URL option', () => { const externalBaseUrlOption = program.options.find((opt) => opt.long === '--external-base-url'); expect(externalBaseUrlOption).toBeDefined(); diff --git a/yarn.lock b/yarn.lock index 69797ed..0c38474 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2276,10 +2276,10 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" -commander@^14.0.3: - version "14.0.3" - resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.3.tgz#425d79b48f9af82fcd9e4fc1ea8af6c5ec07bbc2" - integrity sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw== +commander@^15.0.0: + version "15.0.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-15.0.0.tgz#96f3961f12adac1799ef3fbd8bc61d40572d1b11" + integrity sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg== commander@^4.0.0: version "4.1.1" From 96d5e1338c956304f54aad9124be501283c892b4 Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Sat, 29 Aug 2026 10:45:26 -0400 Subject: [PATCH 21/26] chore(deps): upgrade basic auth --- package.json | 2 +- src/server.spec.ts | 11 +++++++++++ src/server.ts | 6 ++++-- yarn.lock | 5 +++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 5b59118..720aa9f 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "architecture.png" ], "dependencies": { - "basic-auth": "^2.0.1", + "basic-auth": "^3.0.0", "chalk": "6.0.0", "commander": "^15.0.0", "compression": "^1.8.1", diff --git a/src/server.spec.ts b/src/server.spec.ts index d17e57b..9439103 100644 --- a/src/server.spec.ts +++ b/src/server.spec.ts @@ -274,6 +274,17 @@ describe('Test proxy authentication', () => { await request(app).get('/').auth('testuser', 'wrongpass').expect(StatusCodes.UNAUTHORIZED); }); + test.each(['Bearer not-basic', 'Basic not-base64'])( + 'it should reject malformed authorization header %s', + async (authorization) => { + const app = createTestApp(params); + await request(app) + .get('/') + .set('Authorization', authorization) + .expect(StatusCodes.UNAUTHORIZED); + } + ); + test('it should return WWW-Authenticate header on unauthorized', async () => { const app = createTestApp(params); const response = await request(app).get('/').expect(StatusCodes.UNAUTHORIZED); diff --git a/src/server.ts b/src/server.ts index 2560b3b..a7b7ec8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,7 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import basicAuth from 'basic-auth'; +import { parse as parseBasicAuth } from 'basic-auth'; import chalk from 'chalk'; import compression from 'compression'; import dayjs from 'dayjs'; @@ -140,7 +140,9 @@ export function createProxyServer(options: CliOpts): ProxyServer { app.use((req: Request, res: Response, next) => { if (req.path === '/status' || req.path === '/status/') return next(); - const credentials = basicAuth(req); + const credentials = req.headers.authorization + ? parseBasicAuth(req.headers.authorization) + : undefined; if ( !credentials || diff --git a/yarn.lock b/yarn.lock index 0c38474..c6faf83 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1930,6 +1930,11 @@ basic-auth@^2.0.1: dependencies: safe-buffer "5.1.2" +basic-auth@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-3.0.0.tgz#18665a504dc0cdf22e9f83d51b14dc1d226d4e21" + integrity sha512-B63UKsSJ2atgiSuCJvzAjpfAdFY6mYT3C0vZFlLV/81Rw2/VCs6Tp29UzkpKETB3qA2cHwuU1LjBnhUQ0WkPKw== + bintrees@1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz" From 28d104e1aa453bf13c40dd9288416d29db8101f3 Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Sat, 29 Aug 2026 10:51:39 -0400 Subject: [PATCH 22/26] chore(deps): upgrade p-queue --- package.json | 2 +- yarn.lock | 27 ++++++++++++++++----------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 720aa9f..73ffadc 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "ip": "^2.0.1", "lodash": "^4.18.1", "p-limit": "^7.3.1", - "p-queue": "^8.0.1", + "p-queue": "^9.3.3", "pino": "^10.3.1", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", diff --git a/yarn.lock b/yarn.lock index c6faf83..56b5bba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3127,10 +3127,10 @@ etag@^1.8.1, etag@~1.8.1: resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz" integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== -eventemitter3@^5.0.1: - version "5.0.1" - resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz" - integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== +eventemitter3@^5.0.4: + version "5.0.4" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" + integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== execa@^1.0.0: version "1.0.0" @@ -5332,19 +5332,24 @@ p-memoize@^7.1.1: mimic-fn "^4.0.0" type-fest "^3.0.0" -p-queue@^8.0.1: - version "8.1.1" - resolved "https://registry.npmjs.org/p-queue/-/p-queue-8.1.1.tgz" - integrity sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ== +p-queue@^9.3.3: + version "9.3.3" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-9.3.3.tgz#c5e528c7eb361b7ba8eb5a9406902f7f2e75fc8f" + integrity sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA== dependencies: - eventemitter3 "^5.0.1" - p-timeout "^6.1.2" + eventemitter3 "^5.0.4" + p-timeout "^7.0.0" -p-timeout@^6.1.2, p-timeout@^6.1.4: +p-timeout@^6.1.4: version "6.1.4" resolved "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz" integrity sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg== +p-timeout@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-7.0.1.tgz#95680a6aa693c530f14ac337b8bd32d4ec6ae4f0" + integrity sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg== + p-try@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz" From 37c30107690d5f4855d065fd2f35e00776706e85 Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Sat, 29 Aug 2026 10:59:08 -0400 Subject: [PATCH 23/26] chore(deps): upgrade undici --- package.json | 2 +- src/router.ts | 1 + yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 73ffadc..88aa2dd 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "prom-client": "^14.2.0", "swagger-stats": "^0.99.7", "table": "^6.9.0", - "undici": "^7.29.0" + "undici": "^8.10.0" }, "devDependencies": { "@biomejs/biome": "^2.5.11", diff --git a/src/router.ts b/src/router.ts index d728d0e..6d09341 100644 --- a/src/router.ts +++ b/src/router.ts @@ -483,6 +483,7 @@ class ProxyWorker extends EventEmitter { this.agent = new Agent({ connections: 20, pipelining: 1, + allowH2: false, keepAliveTimeout: 60000, keepAliveMaxTimeout: 600000, headersTimeout: opts.requestTimeout, diff --git a/yarn.lock b/yarn.lock index 56b5bba..10f2984 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7054,10 +7054,10 @@ undici-types@~8.3.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.3.0.tgz#44e9fc9f3244648cdea35e4f9bb2d681e9410809" integrity sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ== -undici@^7.29.0: - version "7.29.0" - resolved "https://registry.yarnpkg.com/undici/-/undici-7.29.0.tgz#ae0f6f62e06e057a9cbb7b2b5fde2bb74f791b8f" - integrity sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw== +undici@^8.10.0: + version "8.10.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-8.10.0.tgz#67ed7c4087f0f40fba7bef3a46f2be80572f2473" + integrity sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ== unicorn-magic@^0.1.0: version "0.1.0" From 3aaf5314da2841e778c2cdbc21bc3ffbd9296fa4 Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Sat, 29 Aug 2026 11:06:38 -0400 Subject: [PATCH 24/26] chore(deps): upgrade np --- package.json | 4 +- yarn.lock | 985 +++++++++++++++++++++++++++++++-------------------- 2 files changed, 611 insertions(+), 378 deletions(-) diff --git a/package.json b/package.json index 88aa2dd..ec3e86c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "build": "shx rm -rf dist && tsup-node src/cli.ts --format esm --sourcemap --minify", "prepare": "husky", "release": "standard-version", - "np": "np --no-publish --yarn --contents dist" + "np": "np --no-publish --contents dist" }, "bin": { "github-proxy-server": "./dist/cli.js" @@ -75,7 +75,7 @@ "cz-conventional-changelog": "3.3.0", "husky": "^9.1.7", "nock": "^14.0.17", - "np": "^10.3.0", + "np": "^12.0.1", "shx": "^0.4.0", "standard-version": "^9.5.0", "supertest": "^7.2.2", diff --git a/yarn.lock b/yarn.lock index 10f2984..4dbea80 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,7 +2,7 @@ # yarn lockfile v1 -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.22.13": +"@babel/code-frame@^7.0.0": version "7.24.7" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz" integrity sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA== @@ -10,6 +10,15 @@ "@babel/highlight" "^7.24.7" picocolors "^1.0.0" +"@babel/code-frame@^7.26.2": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" + integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw== + dependencies: + "@babel/helper-validator-identifier" "^7.29.7" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/helper-string-parser@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f" @@ -861,63 +870,60 @@ resolved "https://registry.npmjs.org/@hutson/parse-repository-url/-/parse-repository-url-3.0.2.tgz" integrity sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q== -"@inquirer/ansi@^1.0.2": - version "1.0.2" - resolved "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz" - integrity sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ== +"@inquirer/ansi@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@inquirer/ansi/-/ansi-2.0.7.tgz#86de22810cac3ed406ec10f8d66016815b8226b4" + integrity sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q== -"@inquirer/checkbox@^4.3.2": - version "4.3.2" - resolved "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz" - integrity sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA== - dependencies: - "@inquirer/ansi" "^1.0.2" - "@inquirer/core" "^10.3.2" - "@inquirer/figures" "^1.0.15" - "@inquirer/type" "^3.0.10" - yoctocolors-cjs "^2.1.3" - -"@inquirer/confirm@^5.1.21": - version "5.1.21" - resolved "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz" - integrity sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ== - dependencies: - "@inquirer/core" "^10.3.2" - "@inquirer/type" "^3.0.10" - -"@inquirer/core@^10.3.2": - version "10.3.2" - resolved "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz" - integrity sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A== - dependencies: - "@inquirer/ansi" "^1.0.2" - "@inquirer/figures" "^1.0.15" - "@inquirer/type" "^3.0.10" +"@inquirer/checkbox@^5.2.3": + version "5.2.3" + resolved "https://registry.yarnpkg.com/@inquirer/checkbox/-/checkbox-5.2.3.tgz#3c2b2199c3e3d884a843a2c1280b253f958d5f8a" + integrity sha512-XEYX2WA8SBkLPczL6/yXPHLPCvDoptmh9v56Cy05BSV1Smk1vWy19bTC4qJBuIffw7+6l4CcaYYzGqG60RfW1g== + dependencies: + "@inquirer/ansi" "^2.0.7" + "@inquirer/core" "^12.0.1" + "@inquirer/figures" "^2.0.8" + "@inquirer/type" "^4.1.0" + +"@inquirer/confirm@^6.3.0": + version "6.3.0" + resolved "https://registry.yarnpkg.com/@inquirer/confirm/-/confirm-6.3.0.tgz#5c32d1bba3a0551dcbddc52628a22daef3f5a8cd" + integrity sha512-pZHXJImFtERmSNMBHcjwuz8Ck5vEFEYNUZnwbb8aJpjHv/TwGuFErNxF2Hp8+V+pNJs2EYPMlyWscvFEqO9jOQ== + dependencies: + "@inquirer/core" "^12.0.1" + "@inquirer/type" "^4.1.0" + +"@inquirer/core@^12.0.1": + version "12.0.1" + resolved "https://registry.yarnpkg.com/@inquirer/core/-/core-12.0.1.tgz#a59c69f48c9a67d60ac821b6f12e667cb116ffb4" + integrity sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw== + dependencies: + "@inquirer/ansi" "^2.0.7" + "@inquirer/figures" "^2.0.8" + "@inquirer/type" "^4.1.0" cli-width "^4.1.0" - mute-stream "^2.0.0" + fast-wrap-ansi "^0.2.0" + mute-stream "^3.0.0" signal-exit "^4.1.0" - wrap-ansi "^6.2.0" - yoctocolors-cjs "^2.1.3" -"@inquirer/editor@^4.2.23": - version "4.2.23" - resolved "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz" - integrity sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ== +"@inquirer/editor@^5.3.1": + version "5.3.1" + resolved "https://registry.yarnpkg.com/@inquirer/editor/-/editor-5.3.1.tgz#a035364e93ecd03753129813b99f4961650844a2" + integrity sha512-y43COoyVUjPWIobn2Qep/uI1drPS78aaZZZ9kVi94Tyu/GuW2N8d8Q4rifJXGAXCEAXCPTTMjD8gC1HyvM5ukA== dependencies: - "@inquirer/core" "^10.3.2" - "@inquirer/external-editor" "^1.0.3" - "@inquirer/type" "^3.0.10" + "@inquirer/core" "^12.0.1" + "@inquirer/external-editor" "^3.0.4" + "@inquirer/type" "^4.1.0" -"@inquirer/expand@^4.0.23": - version "4.0.23" - resolved "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz" - integrity sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew== +"@inquirer/expand@^5.1.3": + version "5.1.3" + resolved "https://registry.yarnpkg.com/@inquirer/expand/-/expand-5.1.3.tgz#f587260168acb6733643665882195bbe4d2e3dc3" + integrity sha512-3NQJiXNJ/aj9wiAsr7pECdp5Qe9J0X9YUJCKsaFXS+ddOxfL6J4AIl3w3T4Gq3kK0WsQY5GMoDokK5X94m6lHw== dependencies: - "@inquirer/core" "^10.3.2" - "@inquirer/type" "^3.0.10" - yoctocolors-cjs "^2.1.3" + "@inquirer/core" "^12.0.1" + "@inquirer/type" "^4.1.0" -"@inquirer/external-editor@^1.0.0", "@inquirer/external-editor@^1.0.3": +"@inquirer/external-editor@^1.0.0": version "1.0.3" resolved "https://registry.yarnpkg.com/@inquirer/external-editor/-/external-editor-1.0.3.tgz#c23988291ee676290fdab3fd306e64010a6d13b8" integrity sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA== @@ -925,86 +931,91 @@ chardet "^2.1.1" iconv-lite "^0.7.0" -"@inquirer/figures@^1.0.15": - version "1.0.15" - resolved "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz" - integrity sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g== +"@inquirer/external-editor@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@inquirer/external-editor/-/external-editor-3.0.4.tgz#6f61cfbdcc578530e6d322abe30921eba3e4e73a" + integrity sha512-tZbbaK2ovq6vlrRBNQvjrypmrED/p5x2ncIHQ79cD55tei3dD96v5glMMA+6tiq7K104i/25DVYKWVPJuV6ptA== + dependencies: + chardet "^2.1.1" + iconv-lite "^0.7.2" -"@inquirer/input@^4.3.1": - version "4.3.1" - resolved "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz" - integrity sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g== - dependencies: - "@inquirer/core" "^10.3.2" - "@inquirer/type" "^3.0.10" - -"@inquirer/number@^3.0.23": - version "3.0.23" - resolved "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz" - integrity sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg== - dependencies: - "@inquirer/core" "^10.3.2" - "@inquirer/type" "^3.0.10" - -"@inquirer/password@^4.0.23": - version "4.0.23" - resolved "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz" - integrity sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA== - dependencies: - "@inquirer/ansi" "^1.0.2" - "@inquirer/core" "^10.3.2" - "@inquirer/type" "^3.0.10" - -"@inquirer/prompts@^7.10.1": - version "7.10.1" - resolved "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz" - integrity sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg== - dependencies: - "@inquirer/checkbox" "^4.3.2" - "@inquirer/confirm" "^5.1.21" - "@inquirer/editor" "^4.2.23" - "@inquirer/expand" "^4.0.23" - "@inquirer/input" "^4.3.1" - "@inquirer/number" "^3.0.23" - "@inquirer/password" "^4.0.23" - "@inquirer/rawlist" "^4.1.11" - "@inquirer/search" "^3.2.2" - "@inquirer/select" "^4.4.2" - -"@inquirer/rawlist@^4.1.11": - version "4.1.11" - resolved "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz" - integrity sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw== +"@inquirer/figures@^2.0.8": + version "2.0.8" + resolved "https://registry.yarnpkg.com/@inquirer/figures/-/figures-2.0.8.tgz#d10c3a8a28896679797d9da5944f3093476aa0cd" + integrity sha512-tApbon79GM9ry56ja/Ud3SY2CL4TQsao9fIwDQbgTeNY55025GdMzQ2+UdegV/lx51VNGUB59M0v0nMpybYY4Q== + +"@inquirer/input@^5.1.4": + version "5.1.4" + resolved "https://registry.yarnpkg.com/@inquirer/input/-/input-5.1.4.tgz#4aadb5f4f800e6b9f7a9ef79d42f78fb0ee1d42f" + integrity sha512-3xQkQrOvgOzpSN2ciTVdRDlg1FWMCA8l+0KfB6SNlILoTCGzJTzO/gc0Rwjcb3usuGyKdaGtI6OiyMdeMeLWkg== dependencies: - "@inquirer/core" "^10.3.2" - "@inquirer/type" "^3.0.10" - yoctocolors-cjs "^2.1.3" + "@inquirer/core" "^12.0.1" + "@inquirer/type" "^4.1.0" -"@inquirer/search@^3.2.2": - version "3.2.2" - resolved "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz" - integrity sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA== +"@inquirer/number@^4.2.1": + version "4.2.1" + resolved "https://registry.yarnpkg.com/@inquirer/number/-/number-4.2.1.tgz#34c302d434f78571cf6be54027d4016400d2e5bb" + integrity sha512-5KaqwZNLRpUuWcoCrYghPP9TMaXL5v2Sk4xqePM7RCVegcJStoXdWibio60YIC1bec+z1fCyb67N6XPJIkZtGA== dependencies: - "@inquirer/core" "^10.3.2" - "@inquirer/figures" "^1.0.15" - "@inquirer/type" "^3.0.10" - yoctocolors-cjs "^2.1.3" + "@inquirer/core" "^12.0.1" + "@inquirer/type" "^4.1.0" -"@inquirer/select@^4.4.2": - version "4.4.2" - resolved "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz" - integrity sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w== +"@inquirer/password@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@inquirer/password/-/password-5.2.0.tgz#002db941c99ab3fa10b624d18decc3ee3400461c" + integrity sha512-CvVcW09emkBESEOW+4R8CjLNkP3fB3XrjeL8CDvfpjgrJN+V9oerXmJAXXM3l+4xqYPD5Yaujzy/Ph0PLOdDuA== + dependencies: + "@inquirer/ansi" "^2.0.7" + "@inquirer/core" "^12.0.1" + "@inquirer/type" "^4.1.0" + +"@inquirer/prompts@^8.7.0": + version "8.7.0" + resolved "https://registry.yarnpkg.com/@inquirer/prompts/-/prompts-8.7.0.tgz#6bdf61e32b8cb64e7e39a187842d5887cb224701" + integrity sha512-yQwBMYvpJ6jqrXtKiOwRD5XezjJoyt3VQvIyjsr5Arqb519nfIohOQymWVJ8/vEgg8xtZerrCsqlSaqt/LPC9A== + dependencies: + "@inquirer/checkbox" "^5.2.3" + "@inquirer/confirm" "^6.3.0" + "@inquirer/editor" "^5.3.1" + "@inquirer/expand" "^5.1.3" + "@inquirer/input" "^5.1.4" + "@inquirer/number" "^4.2.1" + "@inquirer/password" "^5.2.0" + "@inquirer/rawlist" "^5.3.3" + "@inquirer/search" "^4.3.1" + "@inquirer/select" "^5.2.3" + +"@inquirer/rawlist@^5.3.3": + version "5.3.3" + resolved "https://registry.yarnpkg.com/@inquirer/rawlist/-/rawlist-5.3.3.tgz#efac1bd316bbed4bd58572dee4e2826bfc3336d0" + integrity sha512-Mu7WrtmDLaXBDEyrRLS70SZgX9ZSm4Up1w0ZxiH8C1OOp9oaVCn2k8q3QGgmlnhsKYUhuaU3zFWhAP6wxkVIMA== + dependencies: + "@inquirer/core" "^12.0.1" + "@inquirer/type" "^4.1.0" + +"@inquirer/search@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@inquirer/search/-/search-4.3.1.tgz#e4c96e9e65ea6adcc78ed629c6a363085689968d" + integrity sha512-0VWOvsHWI0rPj6CG70MoP4oXNCB6adcyN8bVFZXnh11eLDdPIK2f2XCmva78acPPDfJisfab7qNakwBh7hdBXw== + dependencies: + "@inquirer/core" "^12.0.1" + "@inquirer/figures" "^2.0.8" + "@inquirer/type" "^4.1.0" + +"@inquirer/select@^5.2.3": + version "5.2.3" + resolved "https://registry.yarnpkg.com/@inquirer/select/-/select-5.2.3.tgz#527f4290568c2851c9e0a7a03513e90b26ab1e80" + integrity sha512-KuRTodDa6xBXX2noIpjuitpX/QT7Sfav7dIZ/OfUY54Hxg95nrGoshSzxx6Ey7qqLbImKdiGkSDt7KjPXjQgmA== dependencies: - "@inquirer/ansi" "^1.0.2" - "@inquirer/core" "^10.3.2" - "@inquirer/figures" "^1.0.15" - "@inquirer/type" "^3.0.10" - yoctocolors-cjs "^2.1.3" + "@inquirer/ansi" "^2.0.7" + "@inquirer/core" "^12.0.1" + "@inquirer/figures" "^2.0.8" + "@inquirer/type" "^4.1.0" -"@inquirer/type@^3.0.10": - version "3.0.10" - resolved "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz" - integrity sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA== +"@inquirer/type@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@inquirer/type/-/type-4.1.0.tgz#98f607ae62c4c5b333ae1b45b629af62a3be6472" + integrity sha512-FMiJpuHUG3Dk0ex+UIXkre7i+i4OcwHWk9YdcVtZHFwb/r2rnrU2ipTCNAB7A+QOP0ryzIcqOfy76fRyyvOEAw== "@isaacs/cliui@^8.0.2": version "8.0.2" @@ -1374,6 +1385,11 @@ dependencies: any-observable "^0.3.0" +"@sec-ant/readable-stream@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz#60de891bb126abfdc5410fdc6166aca065f10a0c" + integrity sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg== + "@sideway/address@^4.1.5": version "4.1.5" resolved "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz" @@ -1408,6 +1424,11 @@ resolved "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz" integrity sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg== +"@sindresorhus/merge-streams@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz#abb11d99aeb6d27f1b563c38147a72d50058e339" + integrity sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ== + "@standard-schema/spec@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" @@ -1549,9 +1570,9 @@ dependencies: undici-types "~8.3.0" -"@types/normalize-package-data@^2.4.0", "@types/normalize-package-data@^2.4.3": +"@types/normalize-package-data@^2.4.0", "@types/normalize-package-data@^2.4.4": version "2.4.4" - resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz" + resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA== "@types/qs@*": @@ -1756,12 +1777,12 @@ ansi-escapes@^4.2.1: dependencies: type-fest "^0.21.3" -ansi-escapes@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-5.0.0.tgz" - integrity sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA== +ansi-escapes@^7.0.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz#5395bb74b2150a4a1d6e3c2565f4aeca78d28627" + integrity sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg== dependencies: - type-fest "^1.0.2" + environment "^1.0.0" ansi-regex@^2.0.0: version "2.1.1" @@ -1918,6 +1939,11 @@ balanced-match@^1.0.0: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +balanced-match@^4.0.2: + version "4.0.4" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" + integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== + base64-js@^1.3.1: version "1.5.1" resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" @@ -1993,6 +2019,13 @@ brace-expansion@^2.0.1: dependencies: balanced-match "^1.0.0" +brace-expansion@^5.0.8: + version "5.0.9" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf" + integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg== + dependencies: + balanced-match "^4.0.2" + braces@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz" @@ -2103,10 +2136,10 @@ chai@^6.2.2: resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== -chalk-template@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/chalk-template/-/chalk-template-1.1.0.tgz" - integrity sha512-T2VJbcDuZQ0Tb2EWwSotMPJjgpy1/tGee1BTpUNsGZ/qgNjV2t7Mvu+d4600U564nbLesN1x2dPL+xii174Ekg== +chalk-template@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/chalk-template/-/chalk-template-1.1.2.tgz#88ff13e75a333d232304e13abc48c5b5be15f1ce" + integrity sha512-2bxTP2yUH7AJj/VAXfcA+4IcWGdQ87HwBANLt5XxGTeomo8yG0y95N1um9i5StvhT/Bl0/2cARA5v1PpPXUxUA== dependencies: chalk "^5.2.0" @@ -2148,9 +2181,9 @@ chalk@^5.2.0, chalk@^5.3.0: resolved "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz" integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== -chalk@^5.4.1: +chalk@^5.6.2: version "5.6.2" - resolved "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== chardet@^0.7.0: @@ -2217,6 +2250,25 @@ cli-width@^4.1.0: resolved "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz" integrity sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ== +clipboard-image@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/clipboard-image/-/clipboard-image-0.1.0.tgz#e067250ddfebaff3e4379f9d9290c98786375bb1" + integrity sha512-SWk7FgaXLNFld19peQ/rTe0n97lwR1WbkqxV6JKCAOh7U52AKV/PeMFCyt/8IhBdqyDA8rdyewQMKZqvWT5Akg== + dependencies: + run-jxa "^3.0.0" + +clipboardy@^5.3.1: + version "5.3.2" + resolved "https://registry.yarnpkg.com/clipboardy/-/clipboardy-5.3.2.tgz#40fcdc0c41a4bd37cfd109e438a46352813bf5bb" + integrity sha512-R35PENCHFCw6lsd5SjYPuAVV3Zawr74mKc7ogFNzoDPoQsmWDoJgUNNnWCk/czeqdZGZs8Y0M8zkOlVoySfHEQ== + dependencies: + clipboard-image "^0.1.0" + execa "^9.6.1" + is-wayland "^0.1.0" + is-wsl "^3.1.0" + is64bit "^2.0.0" + powershell-utils "^0.2.0" + cliui@^7.0.2: version "7.0.4" resolved "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz" @@ -2659,16 +2711,6 @@ cosmiconfig-typescript-loader@^6.1.0: dependencies: jiti "^2.6.1" -cosmiconfig@^8.3.6: - version "8.3.6" - resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz" - integrity sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA== - dependencies: - import-fresh "^3.3.0" - js-yaml "^4.1.0" - parse-json "^5.2.0" - path-type "^4.0.0" - cosmiconfig@^9.0.0: version "9.0.0" resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz" @@ -2709,6 +2751,22 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +crypto-random-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-4.0.0.tgz#5a3cc53d7dd86183df5da0312816ceeeb5bb1fc2" + integrity sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA== + dependencies: + type-fest "^1.0.1" + cz-conventional-changelog@3.3.0: version "3.3.0" resolved "https://registry.npmjs.org/cz-conventional-changelog/-/cz-conventional-changelog-3.3.0.tgz" @@ -2802,10 +2860,10 @@ default-browser-id@^5.0.0: resolved "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz" integrity sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA== -default-browser@^5.2.1: - version "5.2.1" - resolved "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz" - integrity sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg== +default-browser@^5.5.1: + version "5.5.1" + resolved "https://registry.yarnpkg.com/default-browser/-/default-browser-5.5.1.tgz#1790affc52680fbb11e17cab2752d69aa2a37d2c" + integrity sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw== dependencies: bundle-name "^4.1.0" default-browser-id "^5.0.0" @@ -2831,9 +2889,9 @@ define-lazy-prop@^3.0.0: resolved "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz" integrity sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg== -del@^8.0.0: +del@^8.0.1: version "8.0.1" - resolved "https://registry.npmjs.org/del/-/del-8.0.1.tgz" + resolved "https://registry.yarnpkg.com/del/-/del-8.0.1.tgz#86772fe3a8c9f91bad0d7c6f79e04c6f310e50ac" integrity sha512-gPqh0mKTPvaUZGAuHbrBUYKZWBNAeHG7TU3QH5EhVwPMyKvmfJaNXhcD2jTcXsJRRcffuho4vaYweu80dRrMGA== dependencies: globby "^14.0.2" @@ -2970,6 +3028,11 @@ env-paths@^2.2.1: resolved "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz" integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== +environment@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/environment/-/environment-1.1.0.tgz#8e86c66b180f363c7ab311787e0259665f45a9f1" + integrity sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz" @@ -3145,25 +3208,43 @@ execa@^1.0.0: signal-exit "^3.0.0" strip-eof "^1.0.0" -execa@^8.0.1: - version "8.0.1" - resolved "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz" - integrity sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg== +execa@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" + integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== dependencies: cross-spawn "^7.0.3" - get-stream "^8.0.1" - human-signals "^5.0.0" - is-stream "^3.0.0" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" merge-stream "^2.0.0" - npm-run-path "^5.1.0" - onetime "^6.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + +execa@^9.6.1: + version "9.6.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-9.6.1.tgz#5b90acedc6bdc0fa9b9a6ddf8f9cbb0c75a7c471" + integrity sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA== + dependencies: + "@sindresorhus/merge-streams" "^4.0.0" + cross-spawn "^7.0.6" + figures "^6.1.0" + get-stream "^9.0.0" + human-signals "^8.0.1" + is-plain-obj "^4.1.0" + is-stream "^4.0.1" + npm-run-path "^6.0.0" + pretty-ms "^9.2.0" signal-exit "^4.1.0" - strip-final-newline "^3.0.0" + strip-final-newline "^4.0.0" + yoctocolors "^2.1.1" -exit-hook@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/exit-hook/-/exit-hook-4.0.0.tgz" - integrity sha512-Fqs7ChZm72y40wKjOFXBKg7nJZvQJmewP5/7LtePDdnah/+FH9Hp5sgMujSCMPXlxOAW2//1jrW9pnsY7o20vQ== +exit-hook@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-5.1.0.tgz#f59338d192e150c6997d0af02d72d572d3d635d7" + integrity sha512-INjr2xyxHo7bhAqf5ong++GZPPnpcuBcaXUKt03yf7Fie9yWD7FapL4teOU0+awQazGs5ucBh7xWs/AD+6nhog== expand-tilde@^2.0.0, expand-tilde@^2.0.2: version "2.0.2" @@ -3276,6 +3357,25 @@ fast-safe-stringify@^2.0.8, fast-safe-stringify@^2.1.1: resolved "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz" integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== +fast-string-truncated-width@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz#23afe0da67d752ca0727538f1e6967759728ce49" + integrity sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g== + +fast-string-width@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/fast-string-width/-/fast-string-width-3.0.2.tgz#16dbabb491ce5585b5ecb675b65c165d71688eeb" + integrity sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg== + dependencies: + fast-string-truncated-width "^3.0.2" + +fast-wrap-ansi@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz#95e952a0145bce3f59ad56e179f84c48d4072935" + integrity sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q== + dependencies: + fast-string-width "^3.0.2" + fastify@^3.0.0: version "3.29.5" resolved "https://registry.npmjs.org/fastify/-/fastify-3.29.5.tgz" @@ -3332,6 +3432,13 @@ figures@^3.0.0, figures@^3.1.0: dependencies: escape-string-regexp "^1.0.5" +figures@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-6.1.0.tgz#935479f51865fa7479f6fa94fc6fc7ac14e62c4a" + integrity sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg== + dependencies: + is-unicode-supported "^2.0.0" + fill-range@^7.1.1: version "7.1.1" resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz" @@ -3379,6 +3486,11 @@ find-up-simple@^1.0.0: resolved "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.0.tgz" integrity sha512-q7Us7kcjj2VMePAa02hDAF6d+MzsdsAWEwYyOpwUtlerRBkOEPBCRZrAV4XfcSN8fHAgaD0hP7miwoay6DCprw== +find-up-simple@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/find-up-simple/-/find-up-simple-1.0.1.tgz#18fb90ad49e45252c4d7fca56baade04fa3fca1e" + integrity sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ== + find-up@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz" @@ -3577,10 +3689,18 @@ get-stream@^4.0.0: dependencies: pump "^3.0.0" -get-stream@^8.0.1: - version "8.0.1" - resolved "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz" - integrity sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA== +get-stream@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + +get-stream@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-9.0.1.tgz#95157d21df8eb90d1647102b63039b1df60ebd27" + integrity sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA== + dependencies: + "@sec-ant/readable-stream" "^0.4.1" + is-stream "^4.0.1" git-raw-commits@^2.0.8: version "2.0.11" @@ -3762,6 +3882,11 @@ has-flag@^4.0.0: resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== +has-flag@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-5.0.1.tgz#5483db2ae02a472d1d0691462fc587d1843cd940" + integrity sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA== + has-property-descriptors@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz" @@ -3822,19 +3947,12 @@ hosted-git-info@^4.0.0, hosted-git-info@^4.0.1: dependencies: lru-cache "^6.0.0" -hosted-git-info@^7.0.0: - version "7.0.2" - resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz" - integrity sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w== +hosted-git-info@^9.0.0, hosted-git-info@^9.0.2: + version "9.0.3" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-9.0.3.tgz#637b511ce62a28e4261a92b8da0a4d6be3522cd4" + integrity sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg== dependencies: - lru-cache "^10.0.1" - -hosted-git-info@^8.0.2: - version "8.1.0" - resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz" - integrity sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw== - dependencies: - lru-cache "^10.0.1" + lru-cache "^11.1.0" html-escaper@^2.0.0: version "2.0.2" @@ -3868,10 +3986,15 @@ http-status-codes@^2.3.0: resolved "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz" integrity sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA== -human-signals@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz" - integrity sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ== +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + +human-signals@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-8.0.1.tgz#f08bb593b6d1db353933d06156cedec90abe51fb" + integrity sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ== husky@^9.1.7: version "9.1.7" @@ -3892,17 +4015,24 @@ iconv-lite@^0.7.0, iconv-lite@~0.7.0: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" +iconv-lite@^0.7.2: + version "0.7.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.3.tgz#84ee12f963e7de50bc01a13e160a078b3b0f415f" + integrity sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + ieee754@^1.1.13: version "1.2.1" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== -ignore-walk@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/ignore-walk/-/ignore-walk-7.0.0.tgz" - integrity sha512-T4gbf83A4NH95zvhVYZc+qWocBBGlpzUXLPGurJggw/WIOwicfXJChLDP/iBZnN5WqROSu5Bm3hhle4z8a8YGQ== +ignore-walk@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-8.0.0.tgz#380c173badc3a18c57ff33440753f0052f572b14" + integrity sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A== dependencies: - minimatch "^9.0.0" + minimatch "^10.0.3" ignore@^7.0.3: version "7.0.5" @@ -3940,10 +4070,10 @@ indent-string@^4.0.0: resolved "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz" integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== -index-to-position@^0.1.2: - version "0.1.2" - resolved "https://registry.npmjs.org/index-to-position/-/index-to-position-0.1.2.tgz" - integrity sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g== +index-to-position@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/index-to-position/-/index-to-position-1.2.0.tgz#c800eb34dacf4dbf96b9b06c7eb78d5f704138b4" + integrity sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw== inflight@^1.0.4: version "1.0.6" @@ -4024,18 +4154,17 @@ inquirer@8.2.7: through "^2.3.6" wrap-ansi "^6.0.1" -inquirer@^12.3.2: - version "12.11.1" - resolved "https://registry.npmjs.org/inquirer/-/inquirer-12.11.1.tgz" - integrity sha512-9VF7mrY+3OmsAfjH3yKz/pLbJ5z22E23hENKw3/LNSaA/sAt3v49bDRY+Ygct1xwuKT+U+cBfTzjCPySna69Qw== - dependencies: - "@inquirer/ansi" "^1.0.2" - "@inquirer/core" "^10.3.2" - "@inquirer/prompts" "^7.10.1" - "@inquirer/type" "^3.0.10" - mute-stream "^2.0.0" +inquirer@^14.0.2: + version "14.2.0" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-14.2.0.tgz#3c471cecda0e327d469316105b1f0330af59a020" + integrity sha512-OUSQNF7um7AvgPyBiK6ENfjqEG434oeVkRG97U8foLftOfcJfKcTi5pMtagJe5ytANPH5TlbfVgEokAl6RSj6g== + dependencies: + "@inquirer/ansi" "^2.0.7" + "@inquirer/core" "^12.0.1" + "@inquirer/prompts" "^8.7.0" + "@inquirer/type" "^4.1.0" + mute-stream "^3.0.0" run-async "^4.0.6" - rxjs "^7.8.2" inquirer@^6.2.1: version "6.5.2" @@ -4141,6 +4270,11 @@ is-in-ci@^1.0.0: resolved "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz" integrity sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg== +is-in-ssh@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-in-ssh/-/is-in-ssh-1.0.0.tgz#8eb73c1cabba77748d389588eeea132a63057622" + integrity sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw== + is-inside-container@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz" @@ -4235,10 +4369,15 @@ is-stream@^1.1.0: resolved "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz" integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== -is-stream@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz" - integrity sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA== +is-stream@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" + integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== + +is-stream@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-4.0.1.tgz#375cf891e16d2e4baec250b85926cffc14720d9b" + integrity sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A== is-text-path@^1.0.1: version "1.0.1" @@ -4267,6 +4406,11 @@ is-utf8@^0.2.1: resolved "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz" integrity sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q== +is-wayland@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-wayland/-/is-wayland-0.1.0.tgz#ed966c54a608af5ba3c407922589859a0d424fe5" + integrity sha512-QkbMsWkIfkrzOPxenwye0h56iAXirZYHG9eHVPb22fO9y+wPbaX/CHacOWBa/I++4ohTcByimhM1/nyCsH8KNA== + is-windows@^1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz" @@ -4279,6 +4423,13 @@ is-wsl@^3.1.0: dependencies: is-inside-container "^1.0.0" +is64bit@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is64bit/-/is64bit-2.0.0.tgz#198c627cbcb198bbec402251f88e5e1a51236c07" + integrity sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw== + dependencies: + system-architecture "^0.1.0" + isarray@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" @@ -4689,9 +4840,9 @@ log-symbols@^4.1.0: chalk "^4.1.0" is-unicode-supported "^0.1.0" -log-symbols@^7.0.0: +log-symbols@^7.0.1: version "7.0.1" - resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-7.0.1.tgz#f52e68037d96f589fc572ff2193dc424d48c195b" integrity sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg== dependencies: is-unicode-supported "^2.0.0" @@ -4711,16 +4862,16 @@ longest@^2.0.1: resolved "https://registry.npmjs.org/longest/-/longest-2.0.1.tgz" integrity sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q== -lru-cache@^10.0.1: - version "10.3.1" - resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.3.1.tgz" - integrity sha512-9/8QXrtbGeMB6LxwQd4x1tIMnsmUxMvIH/qWGsccz6bt9Uln3S+sgAaqfQNhbGA8ufzs2fHuP/yqapGgP9Hh2g== - lru-cache@^10.2.0: version "10.4.3" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== +lru-cache@^11.1.0: + version "11.5.2" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.5.2.tgz#00e16665c90c620fba14a3c368732a976493f760" + integrity sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g== + lru-cache@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz" @@ -4728,6 +4879,13 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +macos-version@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/macos-version/-/macos-version-6.0.0.tgz#86d901610895eb1252f132d846e92970b5d7d77c" + integrity sha512-O2S8voA+pMfCHhBn/TIYDXzJ1qNHpPDU32oFxglKnVdJABiYYITt45oLkV9yhwA3E2FDwn3tQqUFrTsr1p3sBQ== + dependencies: + semver "^7.3.5" + magic-string@^0.30.17, magic-string@^0.30.21: version "0.30.21" resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz" @@ -4771,11 +4929,16 @@ media-typer@^1.1.0: resolved "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz" integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== -meow@^13.0.0, meow@^13.2.0: +meow@^13.0.0: version "13.2.0" resolved "https://registry.yarnpkg.com/meow/-/meow-13.2.0.tgz#6b7d63f913f984063b3cc261b6e8800c4cd3474f" integrity sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA== +meow@^14.1.0: + version "14.1.0" + resolved "https://registry.yarnpkg.com/meow/-/meow-14.1.0.tgz#3cd2d16ad534829ab12fcb5010fc2fdb89facd31" + integrity sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw== + meow@^8.0.0: version "8.1.2" resolved "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz" @@ -4878,14 +5041,9 @@ mimic-fn@^2.1.0: resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -mimic-fn@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz" - integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw== - -mimic-function@^5.0.0: +mimic-function@^5.0.1: version "5.0.1" - resolved "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz" + resolved "https://registry.yarnpkg.com/mimic-function/-/mimic-function-5.0.1.tgz#acbe2b3349f99b9deaca7fb70e48b83e94e67076" integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA== min-indent@^1.0.0: @@ -4893,6 +5051,13 @@ min-indent@^1.0.0: resolved "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz" integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== +minimatch@^10.0.3: + version "10.2.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef" + integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A== + dependencies: + brace-expansion "^5.0.8" + minimatch@^3.0.4, minimatch@^3.1.1: version "3.1.2" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" @@ -4900,7 +5065,7 @@ minimatch@^3.0.4, minimatch@^3.1.1: dependencies: brace-expansion "^1.1.7" -minimatch@^9.0.0, minimatch@^9.0.4: +minimatch@^9.0.4: version "9.0.5" resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz" integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== @@ -4976,10 +5141,10 @@ mute-stream@0.0.8: resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== -mute-stream@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz" - integrity sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA== +mute-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-3.0.0.tgz#cd8014dd2acb72e1e91bb67c74f0019e620ba2d1" + integrity sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw== mz@^2.7.0: version "2.7.0" @@ -5051,67 +5216,68 @@ normalize-package-data@^3.0.0: semver "^7.3.4" validate-npm-package-license "^3.0.1" -normalize-package-data@^6.0.0: - version "6.0.2" - resolved "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz" - integrity sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g== +normalize-package-data@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-8.0.0.tgz#bdce7ff2d6ba891b853e179e45a5337766e304a7" + integrity sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ== dependencies: - hosted-git-info "^7.0.0" + hosted-git-info "^9.0.0" semver "^7.3.5" validate-npm-package-license "^3.0.4" -np@^10.3.0: - version "10.3.0" - resolved "https://registry.yarnpkg.com/np/-/np-10.3.0.tgz#8c93d00a9e355efe3361b538480730e035eb4361" - integrity sha512-ERkEM70wpiWxRNwlN3YkpqyE3QGrgKZEiyVvv+Z4Im2mRE9nqCjnS1YFAXVdhGqVP5wpqG8cVc/A2bOJhEYFYQ== +np@^12.0.1: + version "12.0.1" + resolved "https://registry.yarnpkg.com/np/-/np-12.0.1.tgz#7de1c73cfe49044e8f95889630ad3206a97b1997" + integrity sha512-BRT4/T8hOfY0+0ZlXC2JO+gPjPYoo9mQVw4D51+L6Aq3PX3aHwgD5lfNpbAUBHa3fZUbkhAUR3RlS00cLubRTw== dependencies: - chalk "^5.4.1" - chalk-template "^1.1.0" - cosmiconfig "^8.3.6" - del "^8.0.0" + chalk "^5.6.2" + chalk-template "^1.1.2" + clipboardy "^5.3.1" + cosmiconfig "^9.0.1" + del "^8.0.1" escape-goat "^4.0.0" escape-string-regexp "^5.0.0" - execa "^8.0.1" - exit-hook "^4.0.0" + execa "^9.6.1" + exit-hook "^5.1.0" github-url-from-git "^1.5.0" - hosted-git-info "^8.0.2" - ignore-walk "^7.0.0" + hosted-git-info "^9.0.2" + ignore-walk "^8.0.0" import-local "^3.2.0" - inquirer "^12.3.2" + inquirer "^14.0.2" is-installed-globally "^1.0.0" is-interactive "^2.0.0" is-scoped "^3.0.0" issue-regex "^4.3.0" listr "^0.14.3" listr-input "^0.2.1" - log-symbols "^7.0.0" - meow "^13.2.0" + log-symbols "^7.0.1" + meow "^14.1.0" new-github-release-url "^2.0.0" - npm-name "^8.0.0" - onetime "^7.0.0" - open "^10.0.4" - p-memoize "^7.1.1" - p-timeout "^6.1.4" - package-directory "^8.0.0" + npm-name "^8.1.0" + onetime "^8.0.0" + open "^11.0.0" + p-memoize "^8.0.0" + package-directory "^8.2.0" path-exists "^5.0.0" - read-package-up "^11.0.0" - read-pkg "^9.0.1" - rxjs "^7.8.1" - semver "^7.6.0" + read-package-up "^12.0.0" + read-pkg "^10.1.0" + rxjs "^7.8.2" + semver "^7.7.4" symbol-observable "^4.0.0" - terminal-link "^3.0.0" + terminal-link "^5.0.0" update-notifier "^7.3.1" -npm-name@^8.0.0: - version "8.0.0" - resolved "https://registry.npmjs.org/npm-name/-/npm-name-8.0.0.tgz" - integrity sha512-DIuCGcKYYhASAZW6Xh/tiaGMko8IHOHe0n3zOA7SzTi0Yvy00x8L7sa5yNiZ75Ny58O/KeRtNouy8Ut6gPbKiw== +npm-name@^8.1.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/npm-name/-/npm-name-8.1.1.tgz#ce7b11ecedf912d5ddbfc82bc3e457e30f9fe352" + integrity sha512-dPKFWBATFnnAuLYCM1h0OmSnYuOWx6R3gEZp1Aj+cnBu3dvGbMKGkLcSCBZHD2Df03bBY/44U0b5AbRVWtkt5g== dependencies: is-scoped "^3.0.0" is-url-superb "^6.1.0" ky "^1.2.0" lodash.zip "^4.2.0" org-regex "^1.0.0" + p-limit "^6.2.0" p-map "^7.0.1" registry-auth-token "^5.0.2" registry-url "^6.0.1" @@ -5124,12 +5290,20 @@ npm-run-path@^2.0.0: dependencies: path-key "^2.0.0" -npm-run-path@^5.1.0: - version "5.3.0" - resolved "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz" - integrity sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ== +npm-run-path@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + +npm-run-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-6.0.0.tgz#25cfdc4eae04976f3349c0b1afc089052c362537" + integrity sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA== dependencies: path-key "^4.0.0" + unicorn-magic "^0.3.0" number-is-nan@^1.0.0: version "1.0.1" @@ -5187,36 +5361,31 @@ onetime@^2.0.0: dependencies: mimic-fn "^1.0.0" -onetime@^5.1.0: +onetime@^5.1.0, onetime@^5.1.2: version "5.1.2" resolved "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz" integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== dependencies: mimic-fn "^2.1.0" -onetime@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz" - integrity sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ== - dependencies: - mimic-fn "^4.0.0" - -onetime@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz" - integrity sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ== +onetime@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-8.0.0.tgz#ce03ca837d5089fa7e637c22d9f4188ffff2ada6" + integrity sha512-ODz2CnF+9043eG3exS38Efor4ekggmKvIydHaXWr1n9mXA5Afn8BdSsvVpa5AhBWricxhdBMfyeG3Yo3SB+VWA== dependencies: - mimic-function "^5.0.0" + mimic-function "^5.0.1" -open@^10.0.4: - version "10.1.0" - resolved "https://registry.npmjs.org/open/-/open-10.1.0.tgz" - integrity sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw== +open@^11.0.0: + version "11.0.2" + resolved "https://registry.yarnpkg.com/open/-/open-11.0.2.tgz#146069263c50839eebbac6e5810f7da9ac80ab2a" + integrity sha512-RWqF+pBSkqecEvCKOn8QYhaNdRMJDZRIrlS/7rTDdLHaPcfXGCZ/h8zb413NfvdeAV0MR7T1yJcA34/q+CSm1Q== dependencies: - default-browser "^5.2.1" + default-browser "^5.5.1" define-lazy-prop "^3.0.0" + is-in-ssh "^1.0.0" is-inside-container "^1.0.0" - is-wsl "^3.1.0" + powershell-utils "^0.2.1" + wsl-utils "^1.0.0" ora@^5.4.1: version "5.4.1" @@ -5274,6 +5443,13 @@ p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" +p-limit@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-6.2.0.tgz#c254d22ba6aeef441a3564c5e6c2f2da59268a0f" + integrity sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA== + dependencies: + yocto-queue "^1.1.1" + p-limit@^7.3.1: version "7.3.1" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-7.3.1.tgz#ded48cbfa10b161a9928120261fa82c9a282eb3f" @@ -5324,13 +5500,13 @@ p-map@^7.0.2: resolved "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz" integrity sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ== -p-memoize@^7.1.1: - version "7.1.1" - resolved "https://registry.npmjs.org/p-memoize/-/p-memoize-7.1.1.tgz" - integrity sha512-DZ/bONJILHkQ721hSr/E9wMz5Am/OTJ9P6LhLFo2Tu+jL8044tgc9LwHO8g4PiaYePnlVVRAJcKmgy8J9MVFrA== +p-memoize@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/p-memoize/-/p-memoize-8.0.0.tgz#1312d5a1ff5e902ef2d77b6ccadf00c67f127b6f" + integrity sha512-jdZ10MCxavHoIHwJ5oweOtYy6ElPixEHaMkz0AuaEMovR1MRpVvYFzIEHRxgMEpXYzNpRVByFAniAzwmd1/uug== dependencies: - mimic-fn "^4.0.0" - type-fest "^3.0.0" + mimic-function "^5.0.1" + type-fest "^4.41.0" p-queue@^9.3.3: version "9.3.3" @@ -5340,11 +5516,6 @@ p-queue@^9.3.3: eventemitter3 "^5.0.4" p-timeout "^7.0.0" -p-timeout@^6.1.4: - version "6.1.4" - resolved "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz" - integrity sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg== - p-timeout@^7.0.0: version "7.0.1" resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-7.0.1.tgz#95680a6aa693c530f14ac337b8bd32d4ec6ae4f0" @@ -5360,7 +5531,7 @@ p-try@^2.0.0: resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== -package-directory@^8.0.0: +package-directory@^8.2.0: version "8.2.0" resolved "https://registry.yarnpkg.com/package-directory/-/package-directory-8.2.0.tgz#b4f9df2e56782beb1d805945e2c530d29a3806f9" integrity sha512-qJSu5Mo6tHmRxCy2KCYYKYgcfBdUpy9dwReaZD/xwf608AUk/MoRtIOWzgDtUeGeC7n/55yC3MI1Q+MbSoektw== @@ -5407,14 +5578,19 @@ parse-json@^5.0.0, parse-json@^5.2.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" -parse-json@^8.0.0: - version "8.1.0" - resolved "https://registry.npmjs.org/parse-json/-/parse-json-8.1.0.tgz" - integrity sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA== +parse-json@^8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-8.3.0.tgz#88a195a2157025139a2317a4f2f9252b61304ed5" + integrity sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ== dependencies: - "@babel/code-frame" "^7.22.13" - index-to-position "^0.1.2" - type-fest "^4.7.1" + "@babel/code-frame" "^7.26.2" + index-to-position "^1.1.0" + type-fest "^4.39.1" + +parse-ms@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-4.0.0.tgz#c0c058edd47c2a590151a718990533fd62803df4" + integrity sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw== parse-passwd@^1.0.0: version "1.0.0" @@ -5451,7 +5627,7 @@ path-key@^2.0.0, path-key@^2.0.1: resolved "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz" integrity sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw== -path-key@^3.1.0: +path-key@^3.0.0, path-key@^3.1.0: version "3.1.1" resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== @@ -5491,11 +5667,6 @@ path-type@^3.0.0: dependencies: pify "^3.0.0" -path-type@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz" - integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== - path-type@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz" @@ -5671,11 +5842,28 @@ postcss@^8.5.26: picocolors "^1.1.1" source-map-js "^1.2.1" +powershell-utils@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/powershell-utils/-/powershell-utils-0.1.0.tgz#5a42c9a824fb4f2f251ccb41aaae73314f5d6ac2" + integrity sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A== + +powershell-utils@^0.2.0, powershell-utils@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/powershell-utils/-/powershell-utils-0.2.1.tgz#0853016f5b3c12d52b9571c8fe8059677d9ddbe2" + integrity sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A== + presentable-error@^0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/presentable-error/-/presentable-error-0.0.1.tgz" integrity sha512-E6rsNU1QNJgB3sjj7OANinGncFKuK+164sLXw1/CqBjj/EkXSoSdHCtWQGBNlREIGLnL7IEUEGa08YFVUbrhVg== +pretty-ms@^9.2.0: + version "9.3.1" + resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-9.3.1.tgz#7b58d8fbf376f01602ff05230358e8fa5e0f072b" + integrity sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA== + dependencies: + parse-ms "^4.0.0" + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz" @@ -5800,14 +5988,14 @@ rc@1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" -read-package-up@^11.0.0: - version "11.0.0" - resolved "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz" - integrity sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ== +read-package-up@^12.0.0: + version "12.0.0" + resolved "https://registry.yarnpkg.com/read-package-up/-/read-package-up-12.0.0.tgz#7ae889586f397b7a291ca59ce08caf7e9f68a61c" + integrity sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw== dependencies: - find-up-simple "^1.0.0" - read-pkg "^9.0.0" - type-fest "^4.6.0" + find-up-simple "^1.0.1" + read-pkg "^10.0.0" + type-fest "^5.2.0" read-pkg-up@^3.0.0: version "3.0.0" @@ -5826,6 +6014,17 @@ read-pkg-up@^7.0.1: read-pkg "^5.2.0" type-fest "^0.8.1" +read-pkg@^10.0.0, read-pkg@^10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-10.1.0.tgz#eff31c7e505a4995a85c5af017b3dc413745431c" + integrity sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg== + dependencies: + "@types/normalize-package-data" "^2.4.4" + normalize-package-data "^8.0.0" + parse-json "^8.3.0" + type-fest "^5.4.4" + unicorn-magic "^0.4.0" + read-pkg@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz" @@ -5845,17 +6044,6 @@ read-pkg@^5.2.0: parse-json "^5.0.0" type-fest "^0.6.0" -read-pkg@^9.0.0, read-pkg@^9.0.1: - version "9.0.1" - resolved "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz" - integrity sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA== - dependencies: - "@types/normalize-package-data" "^2.4.3" - normalize-package-data "^6.0.0" - parse-json "^8.0.0" - type-fest "^4.6.0" - unicorn-magic "^0.1.0" - readable-stream@3, readable-stream@^3.0.0, readable-stream@^3.0.2, readable-stream@^3.4.0: version "3.6.2" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz" @@ -6076,6 +6264,16 @@ run-async@^4.0.6: resolved "https://registry.npmjs.org/run-async/-/run-async-4.0.6.tgz" integrity sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ== +run-jxa@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/run-jxa/-/run-jxa-3.0.0.tgz#7d7a0fb183cef4b7132237bcfeb58cb705aecbfc" + integrity sha512-4f2CrY7H+sXkKXJn/cE6qRA3z+NMVO7zvlZ/nUV0e62yWftpiLAfw5eV9ZdomzWd2TXWwEIiGjAT57+lWIzzvA== + dependencies: + execa "^5.1.1" + macos-version "^6.0.0" + subsume "^4.0.0" + type-fest "^2.0.0" + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz" @@ -6090,7 +6288,7 @@ rxjs@^6.3.3, rxjs@^6.4.0, rxjs@^6.5.3, rxjs@^6.6.0: dependencies: tslib "^1.9.0" -rxjs@^7.5.5, rxjs@^7.8.1: +rxjs@^7.5.5: version "7.8.1" resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz" integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== @@ -6166,7 +6364,7 @@ semver@^7.1.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.5.3, semve resolved "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz" integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== -semver@^7.5.2: +semver@^7.5.2, semver@^7.7.4: version "7.8.5" resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== @@ -6341,7 +6539,7 @@ siginfo@^2.0.0: resolved "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz" integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== -signal-exit@^3.0.0, signal-exit@^3.0.2: +signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3: version "3.0.7" resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== @@ -6631,10 +6829,15 @@ strip-eof@^1.0.0: resolved "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz" integrity sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q== -strip-final-newline@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz" - integrity sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw== +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + +strip-final-newline@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz#35a369ec2ac43df356e3edd5dcebb6429aa1fa5c" + integrity sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw== strip-indent@^3.0.0: version "3.0.0" @@ -6670,6 +6873,14 @@ stubborn-utils@^1.0.1: resolved "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz" integrity sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg== +subsume@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/subsume/-/subsume-4.0.0.tgz#506c72bfb76596ebc6f96cbdccceadda41ab0849" + integrity sha512-BWnYJElmHbYZ/zKevy+TG+SsyoFCmRPDHJbR1MzLxkPOv1Jp/4hGhVUtP98s+wZBsBsHwCXvPTP0x287/WMjGg== + dependencies: + escape-string-regexp "^5.0.0" + unique-string "^3.0.0" + sucrase@^3.35.0: version "3.35.0" resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz" @@ -6707,6 +6918,11 @@ supertest@^7.2.2: methods "^1.1.2" superagent "^10.3.0" +supports-color@^10.2.2: + version "10.2.2" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-10.2.2.tgz#466c2978cc5cd0052d542a0b576461c2b802ebb4" + integrity sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g== + supports-color@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz" @@ -6719,20 +6935,20 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -supports-color@^7.0.0, supports-color@^7.1.0: +supports-color@^7.1.0: version "7.2.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== dependencies: has-flag "^4.0.0" -supports-hyperlinks@^2.2.0: - version "2.3.0" - resolved "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz" - integrity sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA== +supports-hyperlinks@^4.1.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-4.5.0.tgz#5e5cd74542a31ae6fdfbae75e1362b7b009c74cd" + integrity sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w== dependencies: - has-flag "^4.0.0" - supports-color "^7.0.0" + has-flag "^5.0.1" + supports-color "^10.2.2" supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" @@ -6764,6 +6980,11 @@ symbol-observable@^4.0.0: resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz" integrity sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ== +system-architecture@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/system-architecture/-/system-architecture-0.1.0.tgz#71012b3ac141427d97c67c56bc7921af6bff122d" + integrity sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA== + table@^6.9.0: version "6.9.0" resolved "https://registry.npmjs.org/table/-/table-6.9.0.tgz" @@ -6775,6 +6996,11 @@ table@^6.9.0: string-width "^4.2.3" strip-ansi "^6.0.1" +tagged-tag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/tagged-tag/-/tagged-tag-1.0.0.tgz#a0b5917c2864cba54841495abfa3f6b13edcf4d6" + integrity sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng== + tdigest@^0.1.1: version "0.1.2" resolved "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz" @@ -6782,13 +7008,13 @@ tdigest@^0.1.1: dependencies: bintrees "1.0.2" -terminal-link@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/terminal-link/-/terminal-link-3.0.0.tgz" - integrity sha512-flFL3m4wuixmf6IfhFJd1YPiLiMuxEc8uHRM1buzIeZPm22Au2pDqBJQgdo7n1WfPU1ONFGv7YDwpFBmHGF6lg== +terminal-link@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-5.0.0.tgz#f0447c8940418ab49b9b9bbc47a4ad2fa8ba81e7" + integrity sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA== dependencies: - ansi-escapes "^5.0.0" - supports-hyperlinks "^2.2.0" + ansi-escapes "^7.0.0" + supports-hyperlinks "^4.1.0" text-extensions@^1.0.0: version "1.9.0" @@ -6990,30 +7216,27 @@ type-fest@^0.8.1: resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -type-fest@^1.0.2: +type-fest@^1.0.1: version "1.4.0" - resolved "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== -type-fest@^2.5.1: +type-fest@^2.0.0, type-fest@^2.5.1: version "2.19.0" resolved "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz" integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== -type-fest@^3.0.0: - version "3.13.1" - resolved "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz" - integrity sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g== - -type-fest@^4.18.2, type-fest@^4.21.0: +type-fest@^4.18.2, type-fest@^4.21.0, type-fest@^4.39.1, type-fest@^4.41.0: version "4.41.0" resolved "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz" integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== -type-fest@^4.6.0, type-fest@^4.7.1: - version "4.21.0" - resolved "https://registry.npmjs.org/type-fest/-/type-fest-4.21.0.tgz" - integrity sha512-ADn2w7hVPcK6w1I0uWnM//y1rLXZhzB9mr0a3OirzclKF1Wp6VzevUmzz/NRAWunOT6E8HrnpGY7xOfc6K57fA== +type-fest@^5.2.0, type-fest@^5.4.4: + version "5.8.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-5.8.0.tgz#6d517998257c33159db4d4da6f18efa33bd47df3" + integrity sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA== + dependencies: + tagged-tag "^1.0.0" type-is@^2.0.1: version "2.0.1" @@ -7059,16 +7282,23 @@ undici@^8.10.0: resolved "https://registry.yarnpkg.com/undici/-/undici-8.10.0.tgz#67ed7c4087f0f40fba7bef3a46f2be80572f2473" integrity sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ== -unicorn-magic@^0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz" - integrity sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ== - unicorn-magic@^0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz" integrity sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA== +unicorn-magic@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.4.0.tgz#78c6a090fd6d07abd2468b83b385603e00dfdb24" + integrity sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw== + +unique-string@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-3.0.0.tgz#84a1c377aff5fd7a8bc6b55d8244b2bd90d75b9a" + integrity sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ== + dependencies: + crypto-random-string "^4.0.0" + universalify@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz" @@ -7237,7 +7467,7 @@ wrap-ansi@^3.0.1: string-width "^2.1.1" strip-ansi "^4.0.0" -wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: +wrap-ansi@^6.0.1: version "6.2.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== @@ -7278,6 +7508,14 @@ wrappy@1: resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== +wsl-utils@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wsl-utils/-/wsl-utils-1.0.0.tgz#e113d9964e766657c53f5d570da1bbbdfee7f837" + integrity sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA== + dependencies: + is-wsl "^3.1.0" + powershell-utils "^0.1.0" + xdg-basedir@^5.1.0: version "5.1.0" resolved "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz" @@ -7339,16 +7577,11 @@ yocto-queue@^0.1.0: resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -yocto-queue@^1.2.1: +yocto-queue@^1.1.1, yocto-queue@^1.2.1: version "1.2.2" resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz" integrity sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ== -yoctocolors-cjs@^2.1.3: - version "2.1.3" - resolved "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz" - integrity sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw== - yoctocolors@^2.1.1: version "2.1.2" resolved "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz" From 084e5435dc72f915d4d97e66c0a7522533a8d377 Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Sat, 29 Aug 2026 11:14:50 -0400 Subject: [PATCH 25/26] chore(deps): upgrade typescript --- package.json | 2 +- yarn.lock | 129 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index ec3e86c..eb980f4 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "tmp-promise": "^3.0.3", "tsup": "^8.5.1", "tsx": "^4.23.12", - "typescript": "^5.9.3", + "typescript": "7.0.2", "vitest": "^4.1.11" }, "config": { diff --git a/yarn.lock b/yarn.lock index 4dbea80..606fa28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1631,6 +1631,106 @@ joi "^17.7.0" prom-client ">=11.5.3" +"@typescript/typescript-aix-ppc64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz#cdc7ce81d60f1e09034960ddfb1fb880d7a776b6" + integrity sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ== + +"@typescript/typescript-darwin-arm64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz#a55fdfcfa58df58d27db2237cde6a5c1e35a7235" + integrity sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA== + +"@typescript/typescript-darwin-x64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz#38d1c9172800a91d707bec64d2a370a016634db4" + integrity sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA== + +"@typescript/typescript-freebsd-arm64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz#f1ff8810030b35d2b5be0db6a2dc650460ea94fa" + integrity sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ== + +"@typescript/typescript-freebsd-x64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz#3d86b03f353c5b1ba95162eb6ce35533bfc294bd" + integrity sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw== + +"@typescript/typescript-linux-arm64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz#d9334d96d6dac6ff85da9c865588948de939e91f" + integrity sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ== + +"@typescript/typescript-linux-arm@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz#ad94b41e1aee2a4dcc6a298c7b67c43345fde32e" + integrity sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ== + +"@typescript/typescript-linux-loong64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz#2965aee4fc873360139d893daafe6397a29138ad" + integrity sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ== + +"@typescript/typescript-linux-mips64el@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz#1a887a311bed3a833f80bfd4a9ed37c271936cf0" + integrity sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA== + +"@typescript/typescript-linux-ppc64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz#8b63c9b2f445b393eb4e43ec21da225dade3577d" + integrity sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA== + +"@typescript/typescript-linux-riscv64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz#b6e8a35c289b3ea97a92a41d461aaeed0d3b36e1" + integrity sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ== + +"@typescript/typescript-linux-s390x@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz#2ef96693be4861f6d17965427e5b009cbbed1a3e" + integrity sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw== + +"@typescript/typescript-linux-x64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz#73269cb0baba50aea0ca060445a6b88e583f1ce2" + integrity sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A== + +"@typescript/typescript-netbsd-arm64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz#3a3649f97fafa210b4e6e3798c15e06605c8a901" + integrity sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA== + +"@typescript/typescript-netbsd-x64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz#47ec59491a40c470d2807dc4d2b825528fd979ab" + integrity sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA== + +"@typescript/typescript-openbsd-arm64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz#796be8da0bd989d8a3fb96f2801e38a8365b4baf" + integrity sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ== + +"@typescript/typescript-openbsd-x64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz#d37fe2a729eb942c076c454ee7f1815faf7d560f" + integrity sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg== + +"@typescript/typescript-sunos-x64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz#aba8d3464c3565a7044789baba96916bd4ab2c88" + integrity sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g== + +"@typescript/typescript-win32-arm64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz#b9de50a17196383f62620b5f9d0a2f34ad3b60d7" + integrity sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ== + +"@typescript/typescript-win32-x64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz#cf3b7b0d6ce5635daca4c8e01c189cdcde47ec3c" + integrity sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g== + "@vitest/coverage-v8@^4.1.11": version "4.1.11" resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz#6f0636abe7e23e86dd35127244554be2c60bc2a5" @@ -7252,10 +7352,31 @@ typedarray@^0.0.6: resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz" integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== -typescript@^5.9.3: - version "5.9.3" - resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz" - integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== +typescript@7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-7.0.2.tgz#9ec773d7954a8c182c17cc5bbd575aa28bc51582" + integrity sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA== + optionalDependencies: + "@typescript/typescript-aix-ppc64" "7.0.2" + "@typescript/typescript-darwin-arm64" "7.0.2" + "@typescript/typescript-darwin-x64" "7.0.2" + "@typescript/typescript-freebsd-arm64" "7.0.2" + "@typescript/typescript-freebsd-x64" "7.0.2" + "@typescript/typescript-linux-arm" "7.0.2" + "@typescript/typescript-linux-arm64" "7.0.2" + "@typescript/typescript-linux-loong64" "7.0.2" + "@typescript/typescript-linux-mips64el" "7.0.2" + "@typescript/typescript-linux-ppc64" "7.0.2" + "@typescript/typescript-linux-riscv64" "7.0.2" + "@typescript/typescript-linux-s390x" "7.0.2" + "@typescript/typescript-linux-x64" "7.0.2" + "@typescript/typescript-netbsd-arm64" "7.0.2" + "@typescript/typescript-netbsd-x64" "7.0.2" + "@typescript/typescript-openbsd-arm64" "7.0.2" + "@typescript/typescript-openbsd-x64" "7.0.2" + "@typescript/typescript-sunos-x64" "7.0.2" + "@typescript/typescript-win32-arm64" "7.0.2" + "@typescript/typescript-win32-x64" "7.0.2" ufo@^1.6.1: version "1.6.3" From c378ccc63752ea34eb6d92e0eb4e3bcf0c0db287 Mon Sep 17 00:00:00 2001 From: "Hudson S. Borges" Date: Sat, 29 Aug 2026 12:27:54 -0400 Subject: [PATCH 26/26] docs: remove enhancement dossier --- docs/enhancements/01-redact-invalid-tokens.md | 59 -------- .../enhancements/02-partial-authentication.md | 68 --------- .../enhancements/03-exact-status-exception.md | 61 -------- ...4-standard-unsupported-method-responses.md | 57 ------- docs/enhancements/05-forwarded-metadata.md | 63 -------- .../06-package-manager-audit-path.md | 81 ---------- .../07-worker-router-lifecycle-leaks.md | 68 --------- .../08-numeric-token-configuration.md | 66 -------- docs/enhancements/09-rate-limit-refresh.md | 68 --------- ...0-state-aware-proxy-errors-cancellation.md | 71 --------- .../11-swagger-stats-monitoring.md | 66 -------- .../12-http-forwarding-semantics.md | 70 --------- .../13-body-queue-request-lifetime.md | 71 --------- .../14-event-driven-dispatcher.md | 89 ----------- .../15-documentation-developer-experience.md | 55 ------- docs/enhancements/AGENT-ROADMAP.md | 142 ------------------ docs/enhancements/README.md | 46 ------ 17 files changed, 1201 deletions(-) delete mode 100644 docs/enhancements/01-redact-invalid-tokens.md delete mode 100644 docs/enhancements/02-partial-authentication.md delete mode 100644 docs/enhancements/03-exact-status-exception.md delete mode 100644 docs/enhancements/04-standard-unsupported-method-responses.md delete mode 100644 docs/enhancements/05-forwarded-metadata.md delete mode 100644 docs/enhancements/06-package-manager-audit-path.md delete mode 100644 docs/enhancements/07-worker-router-lifecycle-leaks.md delete mode 100644 docs/enhancements/08-numeric-token-configuration.md delete mode 100644 docs/enhancements/09-rate-limit-refresh.md delete mode 100644 docs/enhancements/10-state-aware-proxy-errors-cancellation.md delete mode 100644 docs/enhancements/11-swagger-stats-monitoring.md delete mode 100644 docs/enhancements/12-http-forwarding-semantics.md delete mode 100644 docs/enhancements/13-body-queue-request-lifetime.md delete mode 100644 docs/enhancements/14-event-driven-dispatcher.md delete mode 100644 docs/enhancements/15-documentation-developer-experience.md delete mode 100644 docs/enhancements/AGENT-ROADMAP.md delete mode 100644 docs/enhancements/README.md diff --git a/docs/enhancements/01-redact-invalid-tokens.md b/docs/enhancements/01-redact-invalid-tokens.md deleted file mode 100644 index 08ca83f..0000000 --- a/docs/enhancements/01-redact-invalid-tokens.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -id: 01 -title: Redact invalid tokens from errors -status: verified -risk: very-low -urgency: urgent -scope: error reporting and token validation ---- - -**Status:** Verified; implementation and validation complete. - -## Problem - -An invalid GitHub token is included in an emitted error message, allowing secret material to reach -logs and error listeners. - -## Evidence - -- The full token is interpolated in `src/router.ts:223-227`. -- The error is forwarded by `src/router.ts:399-401` and `src/server.ts:169-170`. -- CLI error handling exposes the event at `src/cli.ts:116-119`. -- A safer last-four-character logging pattern already exists at `src/router.ts:249-259`. - -## Expected benefit - -Invalid credentials can be diagnosed without exposing the token in logs, events, or CLI output. - -## Dependencies/decisions - -Use the existing last-four pattern or an equivalent fixed redaction. Decide whether tests should -assert that the complete token never occurs in emitted errors. - -## Implementation notes - -Replace the full-token interpolation with a redacted representation and preserve the invalid-token -signal and existing error propagation. Do not change token values used for authentication. - -## Validation plan - -Add a regression test for invalid-token error emission that checks the full token is absent and the -diagnostic redaction remains useful. Run the required project checks in the roadmap. - -## Definition of done - -- No invalid-token error contains the full token. -- Existing error forwarding and invalid-token behavior remain intact. -- Regression coverage and validation evidence are reported. - -## Verification evidence - -- `src/router.ts` now emits only the token's last four characters in the diagnostic while preserving - the full token as the event argument used for token removal. -- `src/router.spec.ts` verifies that invalid-token errors omit the complete token and retain the - redacted suffix. -- `npx vitest run src/router.spec.ts`: passed (16 tests). -- `npm run lint`: passed. -- `npx tsc --noEmit`: passed. -- `npm test`: passed (111 tests). -- `npm run build`: passed. diff --git a/docs/enhancements/02-partial-authentication.md b/docs/enhancements/02-partial-authentication.md deleted file mode 100644 index d56339e..0000000 --- a/docs/enhancements/02-partial-authentication.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -id: 02 -title: Fail closed for partial authentication -status: verified -risk: very-low -urgency: urgent -scope: CLI authentication configuration ---- - -**Status:** Verified; implementation, review, and authoritative project validation are complete. - -## Problem - -Authentication is only configured when both username and password are present, so a partial -configuration can silently leave the proxy unauthenticated. - -## Evidence - -- Authentication configuration is constructed in `src/cli.ts` and previously omitted when only one - credential was supplied. -- The server middleware consumes that optional object at `src/server.ts:122-125`. -- `src/cli.ts` now rejects partial authentication before creating or listening on the proxy server, - using a stable error that contains no credential values. -- `src/cli.spec.ts` covers username-only and password-only startup rejection, neither/both - configuration, and credential redaction in the configuration error. -- Existing protected-request coverage in `src/server.spec.ts` confirms valid complete credentials - continue to authenticate, while no-auth coverage confirms neither credential keeps authentication - disabled. - -## Expected benefit - -Misconfigured deployments fail closed instead of unexpectedly exposing proxy endpoints. - -## Dependencies/decisions - -Define whether exactly one credential is a startup error and what message/exit behavior should be -used. Coordinate the decision with the status-exception item and deployment documentation. - -## Implementation notes - -Detect a username/password mismatch during startup, reject the configuration, and retain the -current successful path when both credentials are supplied. Do not log credential values. - -## Validation plan - -Focused tests cover username-only, password-only, neither, and both credentials. The proxy does not -start for partial configuration, and existing protected-request tests cover valid authentication. - -Focused evidence: `npx vitest run src/cli.spec.ts src/server.spec.ts` — 77 tests passed. - -## Definition of done - -- Partial authentication configuration is rejected before serving traffic. -- Complete authentication configuration behaves as intended. -- Tests and command evidence are reported. - -## Verification evidence - -- `src/cli.ts` rejects username-only and password-only configuration before creating or listening on - the proxy server, without logging credential values. -- `src/cli.spec.ts` covers partial-auth startup failures, credential redaction, and neither/both - configuration paths. -- `npx vitest run src/cli.spec.ts src/server.spec.ts`: passed (77 tests). -- `npm run lint`: passed. -- `npx tsc --noEmit`: passed. -- `npm test`: passed (117 tests). -- `npm run build`: passed. -- `git diff --check`: passed. diff --git a/docs/enhancements/03-exact-status-exception.md b/docs/enhancements/03-exact-status-exception.md deleted file mode 100644 index 6afaab3..0000000 --- a/docs/enhancements/03-exact-status-exception.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -id: 03 -title: Tighten the exact status exception -status: verified -risk: very-low -urgency: urgent -scope: status endpoint authentication and deployment guidance ---- - -**Status:** Verified; review and the parent orchestrator's validation gate are complete. - -## Problem - -The authentication bypass uses a broad path prefix, which can expose routes beyond the intended -status endpoint namespace. - -## Evidence - -- The broad `req.path.startsWith('/status')` bypass is at `src/server.ts:122-125`. -- The server binds to `0.0.0.0` at `src/cli.ts:121`. -- HTTP startup and usage examples appear in `README.md:84-104`. - -## Expected benefit - -Only the deliberately public status surface is exempted from authentication, reducing accidental -exposure when the service is reachable on a network interface. - -## Dependencies/decisions - -The selected public surface is `/status` and the nested `/status/*` namespace. This preserves the -swagger-stats redirect from `/status` to `/status/` while excluding lookalike paths such as -`/status-other`. HTTP requires TLS termination at a trusted boundary when credentials or traffic -cross an untrusted network. - -## Implementation notes - -Implement the selected route matcher rather than a general prefix check. Keep the status behavior -needed by health checks and update deployment examples to reflect the chosen boundary. - -## Implementation evidence - -- `src/server.ts` now exempts only `/status` or paths beginning with the explicit `/status/` route - boundary. -- `src/server.spec.ts` covers unauthenticated `/status` and `/status/` health access, while - `/status-other` and `/status-metrics` remain protected with configured authentication. -- `README.md` documents the public status namespace, the all-interface plain-HTTP CLI listener, - and the requirement for trusted HTTPS/TLS termination across untrusted networks. -- Focused validation: `npx vitest run src/server.spec.ts` — 22 tests passed. -- Final validation: Yarn lint, TypeScript, all 120 tests, production build, Docker image build, and - the built-container health check passed. - -## Validation plan - -Test `/status`, intended nested status paths, and lookalike paths such as `/status-other` under -authentication. Verify the Docker health check and documented HTTP deployment behavior. - -## Definition of done - -- The exact status exception is documented and enforced. -- Lookalike paths require authentication. -- Health behavior and TLS-boundary guidance are validated and reported. diff --git a/docs/enhancements/04-standard-unsupported-method-responses.md b/docs/enhancements/04-standard-unsupported-method-responses.md deleted file mode 100644 index bc10b6b..0000000 --- a/docs/enhancements/04-standard-unsupported-method-responses.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -id: 04 -title: Correct standard unsupported-method responses -status: verified -risk: low -urgency: normal -scope: HTTP method routing and response status handling ---- - -**Status:** Verified; review and the parent orchestrator's validation gate are complete. - -## Problem - -Unsupported methods return a non-standard status code, making the proxy harder for clients and -intermediaries to interpret. - -## Evidence - -- `ProxyRouterResponse.PROXY_ERROR` is status 600 at `src/router.ts:317-319`. -- Unsupported method routing is defined at `src/server.ts:176-187`. - -## Expected benefit - -Clients receive a standard HTTP response for unsupported operations while intentional write-method -rejection remains explicit. - -## Dependencies/decisions - -Use `405 Method Not Allowed` with the existing `{ message: 'Endpoint not supported' }` response -body. Preserve the intentional rejection of write methods rather than turning them into proxied -writes. - -## Implementation notes - -Change only the unsupported-method response path and any associated response type/name. Keep GET and -GraphQL POST routing unchanged unless tests demonstrate a directly related defect. - -## Implementation evidence - -- `ProxyRouterResponse.PROXY_ERROR` now resolves to `StatusCodes.METHOD_NOT_ALLOWED` (`405`) without - changing the existing route declarations or response message. -- Route integration coverage asserts the `405` status and response body for unsupported POST, PATCH, - PUT, and DELETE requests while retaining the supported GET and `/graphql` POST checks. -- Focused validation: `npx vitest run src/server.spec.ts` — 22 tests passed. -- Final validation: Yarn lint, TypeScript, all 120 tests, production build, Docker image build, and - the built-container health check passed. - -## Validation plan - -Add route tests for DELETE, PATCH, PUT, and unsupported POST paths, asserting the selected standard -status and message. Confirm supported GET and `/graphql` POST behavior remains unchanged. - -## Definition of done - -- Unsupported methods return the selected standard status. -- Intentional write-method rejection is preserved. -- Regression tests and validation evidence are reported. diff --git a/docs/enhancements/05-forwarded-metadata.md b/docs/enhancements/05-forwarded-metadata.md deleted file mode 100644 index 36d4a7d..0000000 --- a/docs/enhancements/05-forwarded-metadata.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -id: 05 -title: Correct forwarded metadata -status: verified -risk: low -urgency: normal -scope: proxy request headers and upstream metadata ---- - -**Status:** Verified; review and the parent orchestrator's validation gate are complete. - -## Problem - -Forwarded metadata was derived after the host header had been deleted, and protocol detection checked -the wrong request-socket property. Upstream requests therefore received incorrect host/protocol data. - -## Evidence - -- Host deletion and forwarded-header ordering, including the protocol check, are at - `src/proxy-client.ts:56-66`. - -## Expected benefit - -GitHub and downstream consumers receive consistent client, host, and protocol metadata at the proxy -boundary. - -## Dependencies/decisions - -The default policy does not trust inbound `x-forwarded-for`, `x-forwarded-host`, or -`x-forwarded-proto` values. Existing forwarded values, including proxy-chain entries, are discarded -and replaced with metadata from the immediate connection: `remoteAddress`, `socket.encrypted`, and -the inbound `Host` captured before it is removed. A missing host produces an empty -`x-forwarded-host` value. Generated values are written after `modifyHeaders`, so an inbound value -cannot be retained accidentally by the normal proxy path. - -This is intentionally a fail-safe policy for deployments where clients can reach this boundary -directly. Future item 12 must define any opt-in trusted-proxy chain policy and its external base URL -semantics; it must not infer trust from the presence of forwarded headers. Item 12 is not implemented -here. - -## Implementation notes - -Capture the inbound host before deleting it, use `req.socket.encrypted` for protocol detection, and -overwrite spoofable forwarded headers with immediate-connection metadata by default. - -## Validation plan - -Proxy-client tests inspect outgoing host/protocol/forwarded headers for representative HTTP and HTTPS -requests, absent host data, and spoofed forwarded values. Existing authorization/header behavior -remains covered by the proxy-client suite. - -## Validation evidence - -- `npx vitest run src/proxy-client.spec.ts` -- `git diff --check` -- Final validation: Yarn lint, TypeScript, all 120 tests, production build, Docker image build, and - the built-container health check passed. - -## Definition of done - -- Forwarded host and protocol values are derived in the intended order. -- The selected trust policy is documented and tested. -- No unrelated proxy header behavior changes. diff --git a/docs/enhancements/06-package-manager-audit-path.md b/docs/enhancements/06-package-manager-audit-path.md deleted file mode 100644 index 3fe9847..0000000 --- a/docs/enhancements/06-package-manager-audit-path.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -id: 06 -title: Establish one reproducible package-manager and dependency-audit path -status: verified -risk: low/moderate -urgency: normal -scope: dependency manifests, lockfiles, CI, and container builds ---- - -**Status:** Verified; parent review and validation are complete. - -## Problem - -Local, CI, and Docker dependency installation paths are inconsistent, making installs and advisory -triage less reproducible. - -## Evidence - -- The tracked Yarn lockfile is excluded by `.dockerignore:6`. -- CI uses Yarn at `.github/workflows/ci.yml:37`, `54`, and `71`. -- Docker forces npm in `.dockerignore`/`Dockerfile:4-8`, `23-25`. -- Direct dependencies are listed in `package.json:46-58`. -- `dotenv-override-true` and `https-proxy-agent` are likely unused; `ip` is used only for host - display (`package.json:49`, `src/cli.ts:9`, `127`). -- `swagger-stats@0.99.7` requires the runtime peer `prom-client`; `prom-client@^14.2.0` is now a - direct production dependency so the Yarn Classic production image includes it. - -## Expected benefit - -Fresh installs, CI, containers, and security audits use the same dependency resolution and produce -actionable results. - -## Dependencies/decisions - -Yarn Classic is authoritative because the repository already tracks `yarn.lock`, CI already uses -Yarn, and the existing developer instructions use Yarn. `package.json` pins the package manager to -`yarn@1.22.22`; installs use the lockfile without rewriting it. Docker and CI enable Corepack before -running the same frozen install. - -Triage confirmed that `dotenv-override-true` and `https-proxy-agent` have no source imports, so they -were removed from the manifest and lockfile. `ip` remains because `src/cli.ts` uses `ip.address()` -to display the listening host. Yarn still reports the known `ip` advisory: it has no patched release, -and this application does not call the affected `isPublic` API. No source change was required. - -## Implementation notes - -Aligned the manifest, Yarn lockfile, Docker build context/install commands, and all CI install steps -with Yarn Classic. The Docker context now includes `yarn.lock`; dependency and release stages both -use `yarn install --frozen-lockfile`, with production dependencies selected in the release stage. -CI uses the same frozen install in each job and retains setup-node's Yarn cache. - -The dependency-only Yarn audit completed with 182 packages and reported 68 advisories (8 low, -35 moderate, 24 high, and 1 critical). The remaining findings are primarily transitive packages -used by `swagger-stats` and the existing direct `lodash`, `undici`, and `ip` dependencies. This is -an audit baseline and triage record, not a claim that all upstream advisories are fixed; Yarn Classic -reports advisories but does not provide a general automatic remediation path. `npm audit` remains -unsupported because no npm lockfile is authoritative. - -## Validation plan - -Run a clean/frozen install with Yarn, CI-equivalent lint/build/test commands, container build checks, -and `yarn audit --groups dependencies`. Record the Yarn advisory caveat and do not claim npm audit -support without an npm lockfile. - -Implementation checks: - -- `yarn install --ignore-scripts`: passed and regenerated the lockfile after removing the two unused - dependencies. -- `yarn install --frozen-lockfile --ignore-scripts`: passed. -- `yarn audit --groups dependencies`: completed with exit code 30 because of the non-zero advisory - result above. -- `npm audit`: not run; npm has no authoritative lockfile in this repository. -- `git diff --check`: passed. -- Parent validation: Yarn lint, TypeScript, all 120 tests, production build, Docker image build, and - the built-container health check passed. - -## Definition of done - -- One package manager and lockfile are authoritative across local, CI, and Docker paths. -- Reachable production advisories are triaged with evidence. -- Dependency usage decisions and reproducible install/audit results are reported. diff --git a/docs/enhancements/07-worker-router-lifecycle-leaks.md b/docs/enhancements/07-worker-router-lifecycle-leaks.md deleted file mode 100644 index dcb6e63..0000000 --- a/docs/enhancements/07-worker-router-lifecycle-leaks.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -id: 07 -title: Fix worker and router lifecycle leaks -status: verified -risk: low/moderate -urgency: normal -scope: worker timers, queues, agents, listeners, and shutdown ---- - -**Status:** Verified; review and final validation are complete. - -## Problem - -Worker and router lifecycle paths can leave timers, queues, agents, or listeners alive, and router -destruction mutates the collection being traversed. - -## Evidence - -- Router destruction mutates `clients` during `forEach` at `src/router.ts:461-463`. -- Refresh intervals are created and discarded at `src/router.ts:404-406`. -- Worker cleanup is at `src/router.ts:289-297`. -- Each worker creates its own Agent at `src/router.ts:101-112`. -- The global listener limit is raised at `src/cli.ts:90`. -- CLI shutdown currently closes only the server at `src/cli.ts:152-161`. - -## Implementation evidence - -- `src/router.ts` now owns token records, resource queues, worker wiring, refresh timer handles, and - cached asynchronous destruction; workers explicitly settle scheduled tasks, pause and clear - queues, clear timers, destroy their Undici Agents, detach references, and terminate responses. -- Concurrent token removal and router destruction are composed, while refresh and error forwarding - paths remain contained for routers without error listeners and disposal failures are aggregated; - the public manual refresh method retains its reject-on-failure/readiness contract. -- `src/server.ts` exposes an idempotent asynchronous `app.destroy()` that delegates to the hidden - router. -- `src/cli.ts` removes the global listener-limit override and performs single-flight, named signal - shutdown by starting HTTP close before router cleanup and awaiting both, including listen-error - startup cleanup. -- Focused lifecycle coverage was added to `src/router.spec.ts`, `src/server.spec.ts`, and - `src/cli.spec.ts`. -- Final validation: Yarn lint, TypeScript, all 129 tests, and production build passed. - -## Expected benefit - -Repeated setup/teardown, token changes, tests, and process shutdown release resources predictably -without masking listener growth. - -## Dependencies/decisions - -Define ownership for refresh timers, workers, Agents, and the router; decide whether an Agent is -shared or explicitly closed. Coordinate with rate-limit refresh and dispatcher changes. - -## Implementation notes - -Track every timer and resource that must be disposed, destroy workers before removing collection -entries or iterate over a stable snapshot, and make CLI shutdown destroy the router as well as the -HTTP server. Avoid using a global listener limit as lifecycle management. - -## Validation plan - -Add lifecycle tests for add/remove/destroy and repeated startup/shutdown, including timer cleanup -and queue cancellation. Check listener/resource behavior without relying on a raised global limit. - -## Definition of done - -- Router and worker teardown is idempotent and complete. -- Refresh timers, queues, Agents, and listeners have defined ownership and cleanup. -- Regression tests demonstrate no skipped clients or retained lifecycle resources. diff --git a/docs/enhancements/08-numeric-token-configuration.md b/docs/enhancements/08-numeric-token-configuration.md deleted file mode 100644 index 772398e..0000000 --- a/docs/enhancements/08-numeric-token-configuration.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -id: 08 -title: Validate numeric and token configuration at startup -status: verified -risk: moderate -urgency: normal -scope: CLI option parsing and credential validation ---- - -**Status:** Verified; review and final validation are complete. - -## Problem - -Numeric CLI values are parsed directly, while token validation applies a hard 40-character rule -that may not describe all supported GitHub credential formats. - -## Evidence - -- Number parsers are used at `src/cli.ts:30-35` and `src/cli.ts:47-57`. -- The multiplier parser is at `src/cli.ts:18-24`, with its option at `src/cli.ts:58-63`. -- Router options are applied at `src/router.ts:352-359`. -- The hard 40-character token rule is at `src/server.ts:80-84` and is also described in - `AGENTS.md:183-186`. - -## Expected benefit - -Invalid, non-finite, negative, or unsafe operational settings fail at startup, while valid GitHub -credential formats are accepted deliberately. - -## Dependencies/decisions - -Define supported GitHub credential formats and bounded validation for opaque tokens. Set valid -ranges and defaults for port, timeout, minimum remaining requests, and multiplier. - -## Implementation notes - -Introduce explicit parsers/validators with clear option-specific errors. Keep secrets out of error -messages and preserve the selected credential-format policy in operator documentation. - -The supported numeric ranges are port `0..65535`, request timeout `1..120000` milliseconds, minimum -remaining `0..5000`, and time-budget multiplier `1..10`. Integer settings must be safe integers; -the multiplier also accepts finite decimal values. Supported credentials are legacy 40-character -alphanumeric credentials, `ghp_`, `gho_`, `ghu_`, `ghs_`, and `ghr_` credentials with 36-character -alphanumeric suffixes, and `github_pat_` credentials with an 82-character alphanumeric/underscore -suffix. - -## Validation plan - -Test invalid and boundary values for every numeric option, supported token formats, duplicates, and -startup failure behavior. Run normal startup tests with default values. - -## Definition of done - -- All numeric configuration has finite, bounded validation. -- Supported token formats are explicitly defined and validated. -- Startup errors are safe, clear, tested, and reported. - -## Implementation evidence - -- `src/router.ts` provides shared numeric and credential validators for direct router configuration. -- `src/cli.ts` applies option-specific parsers to flags and environment-backed values. -- `src/server.ts` validates direct server options and delegates credential validation consistently. -- `src/cli.spec.ts` and `src/server.spec.ts` cover numeric boundaries, malformed values, credential - formats, duplicates, and startup validation failures. -- Final validation: focused CLI/server/router tests (119 passed), full test suite (146 passed), Yarn - lint, TypeScript, and production build passed. diff --git a/docs/enhancements/09-rate-limit-refresh.md b/docs/enhancements/09-rate-limit-refresh.md deleted file mode 100644 index 56af617..0000000 --- a/docs/enhancements/09-rate-limit-refresh.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -id: 09 -title: Harden rate-limit refresh against outages and malformed responses -status: verified -risk: moderate -urgency: high -scope: rate-limit fetching, parsing, refresh scheduling, and token workers ---- - -**Status:** Verified; review and final validation are complete. - -## Problem - -Rate-limit refresh fetches and parses remote data without a resilient failure policy. Initial and -interval refresh promises are not handled, and each token creates four refresh streams. - -## Evidence - -- Fetch and response parsing are at `src/router.ts:216-235`. -- Initial and interval refresh handling is at `src/router.ts:399-406`. -- Four workers per token, each with refresh behavior, are created at `src/router.ts:394-409`. - -## Expected benefit - -GitHub outages or malformed responses do not produce unhandled failures or unsafe scheduling, and -refresh traffic is reduced without losing resource-specific state. - -## Dependencies/decisions - -Define stale-state behavior, retry/backoff limits, malformed-response handling, and whether one -`/rate_limit` response should fan out to all resource workers for a token. - -## Implementation notes - -Handle fetch, HTTP, JSON, and resource-shape failures explicitly. Centralize or coordinate refresh -per token, preserve safe stale values, and emit actionable redacted diagnostics. - -The implementation makes up to three attempts per token refresh, with 250ms then 500ms bounded -backoff (capped at 2000ms). A successful response must contain validated `core`, `search`, -`code_search`, and `graphql` resources before any worker state changes. Failed refreshes retain -previous values, report only the token suffix, and detached initial/interval refreshes are contained. -Manual refreshes reject on failure and emit `ready` only after all tokens refresh successfully; a -single in-flight refresh is coalesced per token and its response is fanned out to all four workers. -Each attempt uses an item-09-owned `AbortController` bounded by the configured request timeout. -Each client owns its active controller, attempt timeout, and retry timer; token removal and router -destruction cancel and await that work, preventing later fetches or diagnostics for removed tokens. - -## Validation plan - -Test network failure, non-success responses, malformed JSON/resource data, backoff, stale state, and -successful refresh. Verify refresh fan-out and that no promise rejection is unhandled. - -## Definition of done - -- Refresh failures are contained, observable, and bounded by the selected retry policy. -- Valid responses update all required resource state from the intended refresh path. -- Tests cover outages and malformed responses with reported evidence. - -## Implementation evidence - -- `src/router.ts` centralizes one validated `/rate_limit` fetch per token, bounded retry/backoff, - stale-state preservation, coalescing, worker fan-out, abortable attempt timeouts, and per-client - retry-timer cleanup within the item 07 ownership model. -- `src/router.spec.ts` covers fan-out, coalescing, retry bounds, stale values, malformed responses, - manual failure behavior, destruction during refresh, removed-token cancellation, retry recovery, - and one-fetch-per-token behavior across multiple tokens. -- Final validation: focused router tests (35 passed), full test suite (155 passed), Yarn lint, - TypeScript, and production build passed. diff --git a/docs/enhancements/10-state-aware-proxy-errors-cancellation.md b/docs/enhancements/10-state-aware-proxy-errors-cancellation.md deleted file mode 100644 index fa919be..0000000 --- a/docs/enhancements/10-state-aware-proxy-errors-cancellation.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -id: 10 -title: Make proxy errors and cancellation state-aware -status: verified -risk: moderate -urgency: high -scope: proxy error responses, sockets, abort signals, and cancellation ---- - -**Status:** Verified; review and final validation are complete. - -## Problem - -The error path may send a response and then destroy it, checks the wrong request socket state, and -uses an abort controller that is never assigned to the request context. - -## Evidence - -- Send-then-destroy behavior is at `src/router.ts:197-211`. -- The request context declares an optional controller at `src/router.ts:18-21`, but the proxy call - does not assign one before `src/router.ts:209`. -- Existing error and cancellation tests are at `src/router.spec.ts:101-113` and `src/router.spec.ts:172-185`. - -## Expected benefit - -Disconnected clients and upstream failures do not trigger duplicate writes, noisy socket errors, or -ineffective cancellation. - -## Dependencies/decisions - -Coordinate request-body and stream timeout behavior with item 13. Define which side owns abort -controllers and which response/socket states permit an error response. - -## Implementation notes - -Make cancellation state explicit, attach the active controller to the request context, guard writes -with the correct response/request state, and avoid destroying a response after a completed send. - -The router owns one controller for each active request and passes it to `ProxyClient`; request, -socket, and response-close events abort that controller. Proxy operations race request-body reads, -upstream reads, and backpressure waits against the signal. Error handling sends `502` only when the -request is connected and no response has started, destroys only a connected partial response, and -leaves completed or disconnected responses untouched. Existing request timeout behavior remains the -only timeout boundary coordinated here; no item 13 body, queue, overload, or lifetime limits are -introduced. -Worker destruction aborts request-owned controllers before response teardown and task settlement. -Response streaming rechecks cancellation and downstream state before status/header mutation, each -chunk, drain wait, and terminal `end`; readers are best-effort cancelled and released on abort. - -## Validation plan - -Extend tests for timeout, upstream connection failure, client disconnect, completed response, and -partial response cases. Confirm no duplicate response writes and that in-flight work is cancelled. - -## Definition of done - -- Error handling is conditional on accurate request/response state. -- Cancellation reaches the active upstream operation. -- Existing and new timeout/disconnect regression tests pass. - -## Implementation evidence - -- `src/router.ts` attaches active request controllers, aborts on disconnect, and uses - `headersSent`, `writableEnded`, `destroyed`, and request/socket state before writing or destroying; - worker teardown aborts active requests before settling them. -- `src/proxy-client.ts` accepts the router-owned controller and makes body, upstream, and stream - operations cancellation-aware, with response-state guards and drain-listener cleanup. -- `src/router.spec.ts` covers timeout, upstream failure, client disconnect, completed responses, - partial responses, and cancellation propagation. -- Final validation: focused proxy/router tests (70 passed), full test suite (163 passed), Yarn lint, - TypeScript, and production build passed. diff --git a/docs/enhancements/11-swagger-stats-monitoring.md b/docs/enhancements/11-swagger-stats-monitoring.md deleted file mode 100644 index b2e3539..0000000 --- a/docs/enhancements/11-swagger-stats-monitoring.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -id: 11 -title: Replace or isolate public swagger-stats monitoring -status: verified -risk: moderate -urgency: normal -scope: monitoring middleware, public status surface, and health checks ---- - -**Status:** Verified; review and final validation are complete. - -## Problem - -The optional swagger-stats middleware exposes a monitoring URI that is also excluded from basic -authentication, creating a public observability surface whose necessity and boundary are unclear. - -## Evidence - -- The dependency is declared at `package.json:56`. -- Middleware and public URI configuration are at `src/server.ts:154-161`. -- The authentication bypass is at `src/server.ts:122-125`. -- README documents public monitoring at `README.md:104`. -- Docker health checking uses the status path at `Dockerfile:33-34`. - -## Expected benefit - -Health checks remain reliable while operational metrics are either removed, protected, or isolated -according to an explicit exposure policy. - -## Dependencies/decisions - -Decide between health-only status and a separately protected metrics surface. Preserve the Docker -health behavior and determine whether swagger-stats remains an approved dependency. - -## Implementation notes - -Separate liveness/readiness behavior from detailed monitoring if needed, restrict monitoring access -to the intended trust boundary, and update dependency and README guidance consistently. - -The selected policy keeps only `GET /status` and `GET /status/` public, with a small JSON health -response. Unknown `/status/*` paths return `404` before proxy routing. swagger-stats remains an -intentional optional runtime dependency and, when enabled, uses the isolated `/metrics` namespace -for its UI, stats, metrics, and logout paths. The existing Basic Auth middleware protects all -metrics paths when credentials are configured. When monitoring is disabled, `/metrics` is reserved -and returns `404`; the Docker health check remains on `/status`. - -## Validation plan - -Test enabled and disabled monitoring, authenticated and unauthenticated status/metrics access, and -the Docker health check. Verify the selected monitoring contract without exposing request secrets. - -## Definition of done - -- Monitoring exposure and authentication policy are explicit. -- Health checks continue to work. -- Dependency, route, documentation, and regression evidence support the selected design. - -## Implementation evidence - -- `src/server.ts` provides the public health routes, blocks unknown `/status/*` paths, and configures - swagger-stats under `/metrics` only when monitoring is enabled. -- `src/server.spec.ts` covers enabled/disabled monitoring, both public health forms, protected and - authenticated metrics, status lookalikes, and the `/status` Docker health contract. -- `README.md` documents the exposure and authentication policy. -- Final validation: focused server tests (28 passed), full test suite (165 passed), Yarn lint, - TypeScript, production build, Docker image build, and the built-container health check passed. diff --git a/docs/enhancements/12-http-forwarding-semantics.md b/docs/enhancements/12-http-forwarding-semantics.md deleted file mode 100644 index 6bdabee..0000000 --- a/docs/enhancements/12-http-forwarding-semantics.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -id: 12 -title: Correct HTTP forwarding semantics at the proxy boundary -status: verified -risk: moderate/high -urgency: high -scope: request/response headers, hop-by-hop semantics, links, and proxy trust ---- - -**Status:** Verified; review and final validation are complete. - -## Problem - -The proxy copies headers without hop-by-hop filtering, flattens upstream response headers, and -rewrites links to hard-coded HTTP while trusting the inbound Host value. - -## Evidence - -- Request header copying is at `src/proxy-client.ts:48-60`. -- Response header flattening is at `src/proxy-client.ts:86-113`. -- No hop-by-hop filtering is present in those forwarding paths. -- Link rewriting hard-codes HTTP and uses inbound Host at `src/router.ts:147-153`. - -## Expected benefit - -Requests and responses follow HTTP proxy semantics, preserve meaningful metadata, and generate links -that match the externally visible deployment URL. - -## Dependencies/decisions - -Define the hop-by-hop header policy, trusted proxy behavior, and external base URL configuration. -Coordinate forwarded metadata changes with item 05 and timeout/stream work with item 13. - -## Implementation notes - -Filter connection-specific headers on both directions, preserve valid multi-value semantics, and -derive link rewriting from an explicit trusted external scheme/host rather than an untrusted inbound -value. - -The proxy removes the standard hop-by-hop header set plus every header named by the inbound -`Connection` field on requests and responses, while preserving end-to-end metadata and repeated -`Set-Cookie` values. The optional `externalBaseUrl`/`--external-base-url`/ -`GPS_EXTERNAL_BASE_URL` setting accepts only absolute HTTP(S) URLs, rewrites GitHub `Location` and -`Link` values when configured, and leaves upstream links unchanged when omitted. Inbound `Host` is -never used for externally visible links; the item 05 forwarded-header overwrite policy remains in -place. - -## Validation plan - -Add integration tests for hop-by-hop headers, multi-value response headers, forwarded requests, and -HTTP/HTTPS external URL combinations. Verify redirects and Link headers under the selected trust -configuration. - -## Definition of done - -- Header forwarding follows the documented HTTP semantics. -- Link rewriting uses the selected trusted external URL policy. -- Integration/regression tests cover the proxy boundary and evidence is reported. - -## Implementation evidence - -- `src/proxy-client.ts` filters request/response hop-by-hop headers and preserves response header - arrays, including repeated cookies. -- `src/router.ts` validates the trusted external base URL and rewrites redirects and `Link` headers - only when explicitly configured. -- `src/server.ts`, `src/cli.ts`, and `README.md` expose and document the option consistently. -- Focused proxy, router, server, and CLI tests cover filtering, forwarded headers, redirects, link - rewriting, HTTP/HTTPS combinations, missing values, and invalid configuration. -- Final validation: focused forwarding tests (184 passed), full test suite (184 passed), Yarn lint, - TypeScript, and production build passed. diff --git a/docs/enhancements/13-body-queue-request-lifetime.md b/docs/enhancements/13-body-queue-request-lifetime.md deleted file mode 100644 index f23d5b1..0000000 --- a/docs/enhancements/13-body-queue-request-lifetime.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -id: 13 -title: Bound request bodies, queue residency, and end-to-end request lifetime -status: verified -risk: high -urgency: high -scope: request bodies, queues, overload handling, and timeout budgets ---- - -**Status:** Verified; focused and full validation are complete. - -## Problem - -Request bodies are fully buffered, proxy body reads are outside the existing fetch timeout window, -and queues have no depth or end-to-end request-lifetime bound. - -## Evidence - -- Request-body buffering and active timeout handling are implemented in `src/proxy-client.ts`. -- Bounded queue contexts, deadline timers, cancellation, and rejection responses are implemented in - `src/router.ts`. -- New limits are validated and propagated through `src/cli.ts` and `src/server.ts`. - -## Expected benefit - -Memory use, queue latency, and work held for disconnected clients become bounded, and overload is -reported predictably instead of accumulating indefinitely. - -## Dependencies/decisions - -Define maximum body size, queue depth, queue-wait timeout, total request lifetime, and overload -status/response. Coordinate cancellation with item 10 and defer dispatcher changes until these -queue boundaries are explicit. - -## Implementation notes - -The proxy enforces a 1 MiB default request-body limit before buffering (configurable from 1–16 MiB), -with a typed `PAYLOAD_TOO_LARGE` error and a deterministic `413` response. Queue capacity is shared -per resource at `live workers × maxQueueDepthPerWorker`; full queues return `503` with -`Retry-After: 1`. Queue contexts track absolute queue and lifetime deadlines, remove expired work, -share one abort controller through retries and active proxy work, and clean up disconnect and -destruction listeners. Queue expiry returns `504` with `Request expired in proxy queue`, while total -lifetime expiry returns `504` with `Request lifetime exceeded`. The existing upstream request timeout -continues to use its existing `502` behavior. - -## Validation plan - -Test body-size boundaries, slow uploads, queue saturation, queue expiry, client disconnects, and -end-to-end timeout behavior. Measure that rejected overload does not grow queue residency without -bound. - -## Definition of done - -- Body, queue, wait, and total-lifetime limits are configured and documented. -- Overload and timeout responses are deterministic. -- Regression tests cover memory-sensitive and cancellation-sensitive paths. - -## Configuration - -| Option | Environment | Default | Range | -| --- | --- | ---: | ---: | -| `maxRequestBodyBytes` | `GPS_MAX_REQUEST_BODY_BYTES` | 1 MiB | 1–16 MiB | -| `maxQueueDepthPerWorker` | `GPS_MAX_QUEUE_DEPTH` | 50 | 1–1000 | -| `queueWaitTimeout` | `GPS_QUEUE_WAIT_TIMEOUT` | 30,000 ms | 1–120,000 ms | -| `requestLifetimeTimeout` | `GPS_REQUEST_LIFETIME_TIMEOUT` | 120,000 ms | 1–600,000 ms | - -## Validation evidence - -- Focused proxy, router, server, and CLI suites: 224 tests passed. -- Full test suite: 224 tests passed. -- Biome lint, TypeScript checking, production build, and `git diff --check` passed. diff --git a/docs/enhancements/14-event-driven-dispatcher.md b/docs/enhancements/14-event-driven-dispatcher.md deleted file mode 100644 index 019dd64..0000000 --- a/docs/enhancements/14-event-driven-dispatcher.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -id: 14 -title: Replace per-worker polling with an event-driven bounded dispatcher -status: verified -risk: highest -urgency: normal -scope: scheduling architecture, queue notification, fairness, and bounded dispatch ---- - -**Status:** Verified; focused and full validation are complete. - -## Problem - -Workers previously polled every 100ms and enqueueing did not notify workers. This made dispatch -latency, fairness, and resource behavior harder to bound. - -## Evidence - -- `ProxyRouter` now owns per-resource dispatch state and schedules one coalesced microtask per - notification burst. -- Workers notify the router when they become available, update rate limits, reset their time budget, - retry work, or complete work. -- Dispatch uses per-resource round-robin selection and atomic worker reservations. -- A single reset wake timer is maintained per resource when all eligible workers are rate-limited. -- Router-owned budget reset scheduling replaces per-worker budget polling. - -## Expected benefit - -Dispatch reacts immediately to available work, has explicit fairness and capacity behavior, and -avoids unnecessary polling and repeated dispatch passes. - -## Dependencies/decisions - -Defer this item until lifecycle cleanup, rate-limit refresh, cancellation, and queue-boundary work -has landed. Define fairness, per-token/resource capacity, notification ownership, and overload -behavior before changing the scheduling architecture. - -## Implementation notes - -Polling was replaced with explicit queue/worker notifications and a bounded dispatcher. Resource -routing, rate-limit constraints, cancellation, retry behavior, queue capacity, and shared request -contexts remain unchanged. - -## Validation plan - -Validation covers dispatch latency, round-robin fairness, capacity limits, retries, shutdown, -cancellation, reset wakeups, worker destruction, and queue saturation. The item-14 focused tests -use direct queue inspection, mocked worker scheduling, and fake timers; no network throughput is -measured. - -### Reproducible evidence - -Commands run from the repository root: - -```text -npx vitest run src/router.spec.ts --reporter=dot -npx vitest run src/router.spec.ts -t "enqueue notification|next request immediately|resource dispatch queues|round-robin|atomic concurrency|dispatcher timers|exhausted worker" -``` - -Observed results: - -- Focused dispatcher checks: 7 passed. -- Complete router suite: 75 passed. -- Full project suite: 232 passed across 4 files. -- TypeScript, Biome lint, production build, and `git diff --check`: passed. -- Enqueue dispatch occurs after the next microtask; no 100ms polling advance is required. -- Exhausted-budget fake-timer coverage observed zero schedule/dequeue notifications through 59,999ms; - one dispatch occurred at the 60,000ms budget reset. -- Two-worker round-robin order was exactly `[0, 1, 0, 1]`; the existing multi-token integration - check observed every configured token serving work. -- Saturated capacity returned HTTP 503 with `Retry-After: 1`; adding a token increased capacity, - and removing it restored the bounded worker count. - -Timer accounting after refresh completion is linear only for the existing per-token refresh -intervals: 1, 10, and 100 tokens create respectively 1, 10, and 100 refresh intervals. The -dispatcher itself uses one global budget-reset timer, zero idle resource wake timers, and at most -one rate-reset wake timer per resource; it creates no per-worker polling intervals. The measured -steady-state dispatcher timer count is therefore one for each of 1/10/100 tokens when no resource -is rate-limited (or up to five including four resource wake timers while blocked). - -Baseline commit `6882049` was not benchmarked: it has polling-driven scheduling and no equivalent -deterministic dispatcher boundary, so a wall-clock comparison would conflate polling, network, and -test-harness timing. No throughput claim is made. - -## Definition of done - -- Polling is removed or isolated behind a documented compatibility fallback. -- Dispatcher capacity, fairness, notification, and overload semantics are tested. -- Performance and regression evidence demonstrate no loss of supported proxy behavior. diff --git a/docs/enhancements/15-documentation-developer-experience.md b/docs/enhancements/15-documentation-developer-experience.md deleted file mode 100644 index eb91831..0000000 --- a/docs/enhancements/15-documentation-developer-experience.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -id: 15 -title: Documentation and developer-experience cleanup -status: verified -risk: low -urgency: optional -scope: README, CLI help, package-manager guidance, hooks, and Biome configuration ---- - -**Status:** Verified; final review and validation are complete. **Priority:** Optional cleanup. - -## Problem - -Project documentation and developer tooling contain small consistency and clarity gaps that can -mislead contributors or operators, but do not require product behavior changes. - -## Evidence - -- The README badge is at `README.md:3`. -- Package-manager commands are at `README.md:49-55`. -- CLI help text includes the relevant wording at `src/cli.ts:112-116`. -- The synchronized CLI help snapshot appears at `README.md:133-157`. -- The pre-commit hook is at `.husky/pre-commit:1-2`. -- Limited Biome rules are configured at `biome.json:25-33`. - -## Expected benefit - -Contributors get accurate commands, clearer help, and consistent automated feedback with less setup -friction. - -## Dependencies/decisions - -Apply this cleanup after the package-manager decision in item 06 and any CI policy decisions in the -earlier items. Keep scope limited to documentation and developer-experience consistency. - -## Implementation notes - -The README now references the existing `ci.yml` workflow, uses Yarn 1.22.22 with Node.js >=24, and -documents frozen installation, test, lint, typecheck, build, and pre-commit checks. CLI help wording -was corrected without changing option names, defaults, parsing, or runtime behavior. The hook now -uses the same Yarn commands as the contributor instructions. The Biome schema URL matches the -locked Biome 2.3.11 tool, while its intentionally limited `noConsole` and `noExplicitAny` rules are -unchanged. No dependencies, workflows, public options, or product behavior were changed. - -## Validation plan - -Verified with Yarn 1.22.22 and Node.js v24.19.0 using `yarn test`, `yarn lint`, `npx tsc --noEmit`, -`yarn build`, `node dist/cli.js --help`, and `git diff --check`. The generated help output was -compared with the synchronized README snapshot; the full test suite passed with 232 tests. - -## Definition of done - -- README, CLI help, hooks, and Biome guidance are internally consistent. -- Documented commands are reproducible. -- Changes remain limited to optional cleanup and evidence is reported. diff --git a/docs/enhancements/AGENT-ROADMAP.md b/docs/enhancements/AGENT-ROADMAP.md deleted file mode 100644 index ecc09aa..0000000 --- a/docs/enhancements/AGENT-ROADMAP.md +++ /dev/null @@ -1,142 +0,0 @@ -# Agent roadmap - -## Mission and scope - -This roadmap guides agents implementing the enhancement dossier in small, reviewable increments. -The mission is to improve security, correctness, operability, and developer experience without -changing unrelated behavior. The dossier itself is documentation-only; its recommendations are not -implemented by creating these files. - -The numbered order in [README.md](./README.md) is the authoritative risk order. Dependencies can -make a later item wait for an earlier item even when the later item has a smaller isolated change. - -## Status vocabulary - -Use these statuses in recommendation frontmatter: - -- `planned`: documented, not started, and not implemented. -- `in-progress`: an assigned implementation is actively being changed. -- `blocked`: work cannot proceed until a named dependency or decision is resolved. -- `implemented`: code and tests are complete, but the review gate is not yet closed. -- `verified`: review and required validation are complete, with evidence recorded. -- `deferred`: intentionally postponed with a reason and owner/decision recorded. - -Every agent must update the relevant recommendation status as work changes. Do not mark an item -`implemented` or `verified` based only on documentation edits. - -## Ordered phases and dependencies - -1. **Baseline and security containment (01-03).** Redact secrets, reject partial authentication, - and decide/enforce the exact status namespace. These are urgent and should precede public - deployment changes. -2. **Contract corrections (04-06).** Standardize unsupported-method responses, forwarded metadata, - and the package-manager/audit path. Item 05 should coordinate its trust decision with item 12; - item 06 precedes optional documentation cleanup. -3. **Resource safety and configuration (07-10).** Fix lifecycle ownership, validate configuration, - harden rate-limit refresh, and make errors/cancellation state-aware. Items 07 and 09 should land - before architectural dispatch work; item 10 coordinates with request lifetime limits. -4. **Boundary and capacity work (11-13).** Decide monitoring exposure, correct HTTP forwarding, - and bound bodies, queues, and request lifetime. Item 13 establishes limits needed by the - dispatcher. -5. **Architecture (14).** Replace polling with an event-driven bounded dispatcher only after the - lifecycle, refresh, cancellation, and queue-boundary contracts are stable. -6. **Optional cleanup (15).** Refresh documentation and developer experience after package-manager - and CI decisions settle. It may be scheduled independently when it does not conflict with an - active lane. - -## Suggested roles and validation ownership - -- **Explorer:** maps the exact implementation surface and existing tests; does not edit source. -- **Oracle:** resolves behavior, security, compatibility, and deployment decisions; records the - rationale before implementation. -- **Fixer:** makes the smallest scoped code change and adds regression tests. -- **Librarian:** updates the relevant recommendation, README, changelog-style evidence, and status. -- **Designer:** owns layout, styling, visual hierarchy, responsive behavior, and animation when a - user-facing design decision is required; do not assign those decisions to a code fixer. -- **Observer:** runs the assigned validation, watches regressions/resource behavior, and records - command output or other evidence. - -The orchestrator owns validation for this dossier and decides which checks are assigned for each -implementation. Agents must not silently broaden validation scope. The implementing agent reports -what was run and what was skipped; the observer/orchestrator records the authoritative result. - -## Lane and write-scope rules - -- One active implementation lane owns a recommendation and its directly related tests at a time. -- A lane may write only the source, tests, configuration, and documentation explicitly named by its - recommendation. Ask the orchestrator before crossing lanes. -- The librarian may update the recommendation status and evidence, but must not rewrite unrelated - recommendations. -- Do not combine security, dependency, dispatcher, or broad formatting rewrites in one change. -- Avoid broad rewrites and opportunistic refactors. Preserve unrelated behavior and existing APIs - unless the recommendation explicitly requires a contract decision. -- Before editing, check for another active lane's files and coordinate overlapping paths, especially - `src/router.ts`, `src/server.ts`, `src/cli.ts`, `README.md`, and CI/package files. - -## Per-recommendation workflow - -1. Read the relevant recommendation file, this roadmap, and the exact repository paths cited there. -2. Confirm the status is `planned`, identify dependencies, and obtain unresolved decisions from the - orchestrator/oracle. -3. Set the item to `in-progress` and record the implementation lane and scope. -4. Make a focused change; avoid broad rewrites. -5. Add regression tests for the changed behavior, including security and boundary cases where - applicable. Do not claim an item is done without tests unless the recommendation explicitly has - no runtime behavior. -6. Run the validation assigned by the orchestrator and preserve command/result evidence. -7. Have the observer/orchestrator review the diff and gates. Set `implemented`, then `verified` - only after the required review and validation are complete; otherwise record `blocked` or - `deferred` with the reason. -8. Report changed files, tests, commands, failures/skips, and evidence. Update the recommendation - status and implementation notes without altering historical evidence. - -## Security and secret-redaction rules - -- Never commit, print, paste, or include full GitHub tokens, passwords, authorization headers, or - other credentials in source, tests, logs, issue text, or dossier evidence. -- Use placeholders and last-four-or-shorter representations only; tests must assert that secrets do - not appear in errors or logs. -- Treat inbound Host and forwarded headers as untrusted until the selected trust policy says - otherwise. -- Do not weaken authentication or expose monitoring to make tests or health checks pass. -- Redact command output and audit artifacts before reporting them. If a secret is encountered, - stop, remove it from the working output, and notify the orchestrator. - -## Required validation commands - -Unless the orchestrator assigns a narrower set, the project validation baseline is: - -```text -npm run lint -npx tsc --noEmit -npm test -npm run build -``` - -Use the repository's selected package manager after item 06 settles the path; CI currently invokes -the Yarn equivalents at `.github/workflows/ci.yml:37-38`, `54-56`, and `71-72`. For dependency work, -run the supported lockfile-aware audit command and record its limitations: Yarn previously reported -68 production advisories (1 critical, 24 high), while `npm audit` is unavailable without an npm -lockfile. Run focused tests in addition to, not instead of, the assigned baseline when the change -affects a specific path. - -## Review gates - -- **Scope gate:** only the named recommendation and its dependencies changed. -- **Security gate:** secrets remain redacted; authentication, status, trust, and monitoring exposure - decisions are explicit. -- **Regression gate:** focused and required tests cover the changed contract. -- **Resource gate:** timers, listeners, sockets, queues, body memory, and cancellation have clear - ownership where relevant. -- **Operational gate:** configuration, deployment, health checks, and package-manager instructions - remain reproducible. -- **Evidence gate:** the recommendation status, validation commands, results, and known skips are - recorded before verification. - -## Definition of done - -The dossier is complete when every recommendation has a deliberate status, implementation lanes -have respected the ordered dependencies and write scopes, planned work is not misrepresented as -implemented, relevant regression tests exist for runtime changes, assigned validation has been run -by the orchestrator or reported as skipped, review gates have passed, and agents have reported -concrete file and command evidence. diff --git a/docs/enhancements/README.md b/docs/enhancements/README.md deleted file mode 100644 index 211367c..0000000 --- a/docs/enhancements/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# Enhancement dossier - -This dossier records the project-improvement recommendations from the prior analysis. It expands the -earlier grouped analysis into 15 actionable work items, ordered from lowest to highest implementation -risk. Recommendations 01 through 15 are **verified**. - -## Project baseline - -The verified baseline from the prior analysis is: - -- Lint passed. -- Production type-check passed. -- Tests passed: **110/110**. -- Dependency audit caveat: Yarn reported **68 production advisories**, including **1 critical** and - **24 high**. `npm audit` is unavailable without an npm lockfile. - -These findings describe implementation and regression risk, not issue severity. A low-risk item can -still address a serious security concern; conversely, a high-risk item is high because changing it -may affect behavior broadly, not because the underlying issue is necessarily severe. - -## Recommendations - -| # | Recommendation | Risk | Urgency | -| ---: | --- | --- | --- | -| 01 | [Redact invalid tokens from errors](./01-redact-invalid-tokens.md) | Very low | Urgent | -| 02 | [Fail closed for partial authentication](./02-partial-authentication.md) | Very low | Urgent | -| 03 | [Tighten the exact status exception](./03-exact-status-exception.md) | Very low | Urgent | -| 04 | [Correct standard unsupported-method responses](./04-standard-unsupported-method-responses.md) | Low | Normal | -| 05 | [Correct forwarded metadata](./05-forwarded-metadata.md) | Low | Normal | -| 06 | [Establish one package-manager and audit path](./06-package-manager-audit-path.md) | Low/moderate | Normal | -| 07 | [Fix worker/router lifecycle leaks](./07-worker-router-lifecycle-leaks.md) | Low/moderate | Normal | -| 08 | [Validate numeric and token configuration](./08-numeric-token-configuration.md) | Moderate | Normal | -| 09 | [Harden rate-limit refresh](./09-rate-limit-refresh.md) | Moderate | High | -| 10 | [Make proxy errors and cancellation state-aware](./10-state-aware-proxy-errors-cancellation.md) | Moderate | High | -| 11 | [Replace or isolate swagger-stats monitoring](./11-swagger-stats-monitoring.md) | Moderate | Normal | -| 12 | [Correct HTTP forwarding semantics](./12-http-forwarding-semantics.md) | Moderate/high | High | -| 13 | [Bound body, queue, and request lifetime](./13-body-queue-request-lifetime.md) | High | High | -| 14 | [Use an event-driven bounded dispatcher](./14-event-driven-dispatcher.md) | Highest | Normal | -| 15 | [Documentation and developer-experience cleanup](./15-documentation-developer-experience.md) | Low | Optional | - -## Reading and execution guidance - -Read [AGENT-ROADMAP.md](./AGENT-ROADMAP.md) before implementing any item. Each recommendation -contains the evidence, decisions, implementation notes, validation plan, and definition of done -needed for a focused change. The numeric order is authoritative for filenames and index order; -dependencies may require waiting for an earlier item before starting a later one.