From da2e677082e533fc454af4585f0108060e9bde3c Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 9 Oct 2026 19:48:18 -0400 Subject: [PATCH] fix: own server request and response lifetimes --- CHANGELOG.md | 24 + benches/binding.bench.ts | 29 +- benches/request-dispatch.bench.ts | 24 +- benches/router-matcher.bench.ts | 2 +- benches/router-scale.ts | 40 +- docs/0.5.0-hardening.md | 96 ++++ package-lock.json | 722 ++++++++++++++++++++--------- package.json | 12 +- src/application.ts | 20 +- src/body-limit.ts | 56 ++- src/dispatch.ts | 49 +- src/response-body.ts | 6 + tests/askr.test.ts | 34 ++ tests/body-limit.test.ts | 20 + tests/lifecycle-boundaries.test.ts | 617 ++++++++++++++++++++++++ tests/package-artifacts.js | 9 +- tests/package-consumers.js | 101 ++++ tests/packed-runtime.js | 98 ++++ tsconfig.benchmarks.json | 9 + vitest.test.unit.config.ts | 15 + 20 files changed, 1690 insertions(+), 293 deletions(-) create mode 100644 docs/0.5.0-hardening.md create mode 100644 src/response-body.ts create mode 100644 tests/lifecycle-boundaries.test.ts create mode 100644 tests/package-consumers.js create mode 100644 tests/packed-runtime.js create mode 100644 tsconfig.benchmarks.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 6142d1b..132a32c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed + +- Enforce a stricter request-size limit on cached body reads. Abort stalled reads + promptly, preserve their cause through model binding, and release reader locks + without waiting for application cancellation hooks. +- Cancel suppressed HEAD bodies and downstream responses discarded by middleware, + including responses that arrive after a middleware failure. Preserve transferred + bodies and delivered clones. +- Retire middleware continuations after completion, including synchronous returns + followed by queued microtasks. +- Reject invalid route, fallback, access-denial, and error-handler response values. + Invalid access-denial values no longer fall through to the protected handler. + Propagate a failing error handler once per request instead of invoking it again + at enclosing middleware boundaries. +- Report invalid health-probe return values as unavailable. +- Make benchmark fixtures dispatch actual registered routes and check their types. + +### Added + +- Coverage floors for request dispatch/body ownership and the broader unit suite; + normal packed-install runtime and strict declaration checks. See + [0.5 hardening evidence](docs/0.5.0-hardening.md) for the exercised scenarios, + observed costs, and qualification still pending. + ## 0.4.1 - 2026-09-30 ### Fixed diff --git a/benches/binding.bench.ts b/benches/binding.bench.ts index 1831b93..90f7965 100644 --- a/benches/binding.bench.ts +++ b/benches/binding.bench.ts @@ -1,21 +1,26 @@ +import assert from "node:assert/strict"; +import { createRouter } from "../src/router"; import { bench } from "vitest"; import { createServerApp } from "../src/application"; import { bind } from "../src/binding"; let sink: unknown; const response = new Response(null, { status: 204 }); -const app = createServerApp({ - routes: [ - { - path: "/items/{id}", - method: "POST", - handler: async (ctx) => { - sink = await ctx.bind(); - return response; - }, - }, - ], -}); +const app = createServerApp( + createRouter().post("/items/{id}", async (ctx) => { + sink = await ctx.bind(); + return response; + }), +); +const proof = await app.fetch( + new Request("http://example.test/items/42?view=full", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"name":"Ada"}', + }), +); +assert.equal(proof.status, 204); +assert.deepEqual(sink, { name: "Ada", view: "full", id: "42" }); const queryRequest = new Request( "http://example.test/items/42?tag=one&tag=two&view=full&locale=en", diff --git a/benches/request-dispatch.bench.ts b/benches/request-dispatch.bench.ts index c97651d..a90abf0 100644 --- a/benches/request-dispatch.bench.ts +++ b/benches/request-dispatch.bench.ts @@ -1,23 +1,21 @@ +import assert from "node:assert/strict"; +import { createRouter } from "../src/router"; import { bench } from "vitest"; import { createServerApp } from "../src/index"; -const app = createServerApp({ - routes: [ - { - path: "/health", - method: "GET", - handler: (ctx) => ctx.ok({ ok: true }), - }, - ], -}); +const app = createServerApp(createRouter().get("/health", (ctx) => ctx.ok({ ok: true }))); const response = new Response(null, { status: 204 }); -const prebuiltResponseApp = createServerApp({ - routes: [{ path: "/health", method: "GET", handler: () => response }], -}); +const prebuiltResponseApp = createServerApp(createRouter().get("/health", () => response)); const middlewareApp = createServerApp({ middleware: [(_context, next) => next(), (_context, next) => next()], - routes: [{ path: "/health", method: "GET", handler: () => response }], + router: createRouter().get("/health", () => response), }); +for (const [target, status] of [ + [app, 200], + [prebuiltResponseApp, 204], + [middlewareApp, 204], +] as const) + assert.equal((await target.fetch(new Request("http://example.test/health"))).status, status); const request = new Request("http://example.test/health"); bench("dispatch a server request", async () => { diff --git a/benches/router-matcher.bench.ts b/benches/router-matcher.bench.ts index 90568cf..c25ecac 100644 --- a/benches/router-matcher.bench.ts +++ b/benches/router-matcher.bench.ts @@ -18,7 +18,7 @@ const methodMatcher = createMatcher([ { path: "/items/{*path}", method: "POST", handler }, ]); -let sink: MatchResult; +let sink: MatchResult | undefined; bench("match a static route", () => { sink = staticMatcher.match("/items/1023", "GET"); diff --git a/benches/router-scale.ts b/benches/router-scale.ts index 7a5d3fa..27aa119 100644 --- a/benches/router-scale.ts +++ b/benches/router-scale.ts @@ -1,15 +1,19 @@ -import { bench } from "vitest"; +import assert from "node:assert/strict"; +import { createRouter } from "../src/router"; +import { bench, beforeAll } from "vitest"; import { createServerApp, type Handler } from "../src/index"; export function defineRouterScaleBench(tier: string, routeCount: number): void { const handler: Handler = (context) => context.ok({ ok: true }); - const routes = Array.from({ length: routeCount }, (_, index) => ({ - path: `/items/${index}`, - handler, - })); - const app = createServerApp({ routes }); + const router = createRouter(); + for (let index = 0; index < routeCount; index++) router.get(`/items/${index}`, handler); + const app = createServerApp(router); const request = new Request(`http://example.test/items/${routeCount - 1}`); + beforeAll(async () => { + assert.equal((await app.fetch(request)).status, 200); + }); + bench(`${tier}: dispatch through ${routeCount} static routes`, async () => { await app.fetch(request); }); @@ -17,13 +21,15 @@ export function defineRouterScaleBench(tier: string, routeCount: number): void { export function defineDynamicRouterScaleBench(tier: string, routeCount: number): void { const handler: Handler = (context) => context.ok({ ok: true }); - const routes = Array.from({ length: routeCount }, (_, index) => ({ - path: `/buckets/${index}/{key}`, - handler, - })); - const app = createServerApp({ routes }); + const router = createRouter(); + for (let index = 0; index < routeCount; index++) router.get(`/buckets/${index}/{key}`, handler); + const app = createServerApp(router); const request = new Request(`http://example.test/buckets/${routeCount - 1}/object`); + beforeAll(async () => { + assert.equal((await app.fetch(request)).status, 200); + }); + bench(`${tier}: dispatch through ${routeCount} dynamic routes`, async () => { await app.fetch(request); }); @@ -31,13 +37,15 @@ export function defineDynamicRouterScaleBench(tier: string, routeCount: number): export function defineWildcardRouterScaleBench(tier: string, routeCount: number): void { const handler: Handler = (context) => context.ok({ ok: true }); - const routes = Array.from({ length: routeCount }, (_, index) => ({ - path: `/objects/${index}/{*key}`, - handler, - })); - const app = createServerApp({ routes }); + const router = createRouter(); + for (let index = 0; index < routeCount; index++) router.get(`/objects/${index}/{*key}`, handler); + const app = createServerApp(router); const request = new Request(`http://example.test/objects/${routeCount - 1}/a/b/c.json`); + beforeAll(async () => { + assert.equal((await app.fetch(request)).status, 200); + }); + bench(`${tier}: dispatch through ${routeCount} wildcard routes`, async () => { await app.fetch(request); }); diff --git a/docs/0.5.0-hardening.md b/docs/0.5.0-hardening.md new file mode 100644 index 0000000..266b2de --- /dev/null +++ b/docs/0.5.0-hardening.md @@ -0,0 +1,96 @@ +# 0.5.0 Server hardening evidence + +Preparation baseline: `6dc2c4377e0069ca4a9765d9c40259a7a9ed83f3`. +The version remains 0.4.1 during preparation. This is not publication approval. +API contraction, the complete 0.5 package graph, and final documentation remain +separate release gates. + +## Confirmed failures and ownership + +One request owns its buffered body, each middleware invocation owns its downstream +response until it transfers or discards it, and a failed error handler propagates +its original failure through enclosing boundaries once. + +| Boundary | Reproducer and resulting behavior | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Cached body | Buffer four bytes, then request a three-byte maximum. The second read rejects with 413; a later four-byte read still reuses the same bytes. | +| Oversized stream | Five bytes with no Content-Length and a four-byte maximum. Throwing or never-settling cancellation hooks cannot stall the 413 result or retain the reader lock. | +| Request abort | Abort an in-progress, stalled read with throwing or never-settling cancellation. The low-level read rejects with the exact signal reason and releases its lock. A pre-aborted request does not pull its source. | +| Model binding | JSON, URL-encoded and multipart aborts preserve the original cause in BindingError. Public application dispatch maps unreadable bodies to the existing 400 Problem response. | +| HEAD | Suppressed response bodies are canceled once; cancellation failure or a pending cancellation promise cannot replace the HEAD response. Headers survive. | +| Middleware | A thrown failure after next(), or before a late downstream response arrives, disposes the discarded stream. Returning the original response, transferring its body, or returning its clone preserves delivered bytes. A replacement response remains usable. | +| Continuation | Saved next() calls are rejected after completion. Synchronous short circuits retire next() before queued microtasks execute. Existing double-next and ordered composition assertions remain. | +| Authorization | Undefined, null, string and object denial results are rejected before protected handler execution. | +| Response contracts | Invalid route and fallback results reach the owning error boundary with corrective TypeErrors, with and without global middleware. | +| Error handler | Invalid onError results reject with a corrective TypeError. Thrown/rejected callbacks propagate the exact failure with one callback invocation, including overlapping requests. | +| Probes | Undefined and booleans retain their documented meaning. Invalid values report 503 with no-store; an unrelated liveness request remains healthy. | +| Host rejection tracking | Repeated cached failures remain handled while a caller delays awaiting them. This and the synchronous microtask edge were caught during review of the patch before integration. | + +Cancellation is best effort: the application does not await an arbitrary +application-owned cancel hook. Middleware that locks a stream itself owns that +reader and must release it. Handler work that never completes cannot be forcibly +terminated by the Web Request/Response core. + +## Retained assertions and characterization + +The review inspected assertion bodies, rather than relying on test names: + +- Routing: literal/parameter/wildcard precedence, equal-priority order, static + descendants, empty named wildcards, method aggregation, explicit HEAD/OPTIONS, + malformed parameter escapes and capture isolation. +- Binding and headers: JSON/form/multipart precedence, repeated values and cookie + order, mixed-case media types, consumed/cached streams, prototype-shaped keys, + native source failures, application and route body limits. +- Policy: one auth resolution, request-local auth identity, denial before route + middleware/upgrade, CSRF form-buffer reuse, encoded redirect allowlists, invalid + redirect cookie suppression, and rate-limit/CORS order. +- Metadata: escaped HTML/JSON-LD and control sanitation; five new characterization + cases retain ASCII and Latin-1 raw headers and round-trip Japanese, emoji and a + 40,000-character payload through base64url-v1. The prior Unicode fix remains + a regression target; these cases did not identify a new encoding defect. +- Streams: SSE write quotas and serialization recovery, heartbeat coalescing, + abort cleanup, deferred SSR chunks, streamed style-prefix cancellation. +- Protocols: MCP negotiation, Origin/Host/media checks, malformed initialization, + bounded session capacity/expiry/deletion, tool/schema/auth failures, invalid + cursors and URI escapes; OpenAPI duplicate/invalid metadata and references, + required/malformed input, source-prefixed issues and opt-in response validation. + +Coverage remains partial for the broader MCP/resource/prompt and auth-helper +surfaces. Adapter-conformance runner tests exercise faulty exercise functions, +deadlines and cancellation; they do not prove real socket/listener cleanup. +That transport qualification belongs to the Node adapter pass. The generated +website and Vite decoder/real-HTTP header-size parity remain final candidate +checks. In particular, the large metadata unit case makes no claim that an HTTP +server accepts headers of that size. + +## Validation and costs + +Node 24.21.0: npm ci; npm run fmt -- --check; npm run check. +The gate checks source and benchmark types, unit coverage, strict built +declarations, publint, packed files and an ordinary installed tarball. The latter +imports all nine current entrypoints and executes HEAD/replacement cleanup, +denial/probe guards, error-handler failure and request-abort binding. Unexported +implementation paths remain inaccessible. + +Whole-source coverage measured approximately 89% lines, 88% statements, 81% +branches and 87% functions, with explicit regression floors. Affected-file floors +are stronger where established: application 96% lines/branches, body reader 91% +lines and 100% functions, dispatch 94% lines/89% branches, and the private disposer +100% lines/functions. Coverage is not proof of every policy combination. + +The prior route-array benchmark fixtures silently dispatched 404s. The corrected +fixtures register a router and assert actual status/bound values before timing; +benchmark sources now participate in typechecking. Baseline timing uses the same +corrected fixtures and dependency/toolchain set as the candidate. + +Local diagnostic pairs measured approximately 0.5-0.6 microseconds more for JSON/form +binding and 0.1-0.15 microseconds more for two-middleware dispatch. Body reads now +register/remove cancellation and race stalled work; middleware tracks and disposes +owned downstream responses. These are correctness costs, with no optimization, +throughput or capacity claim. The causal path stays explicit, with no cache or +alternate fast path added. Raw results and exact candidate provenance belong in +the maintainer review packet. + +The production dependency lock entries are unchanged. Compatible development +tooling updates remove the reported source-map-js/tinypool advisories; npm audit +reported zero findings after the update. diff --git a/package-lock.json b/package-lock.json index 1f15347..c080d5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ }, "devDependencies": { "@askrjs/askr": ">=0.4.0 <0.5.0", + "@types/node": "24.19.2", + "@vitest/coverage-v8": "4.1.11", "publint": "^0.3.24", "typescript": "^7.0.2", "vite-plus": "^0.3.1", @@ -91,6 +93,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", @@ -101,6 +113,22 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -111,6 +139,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@blazediff/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", @@ -118,6 +170,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -125,10 +187,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@oxc-project/runtime": { - "version": "0.148.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.148.0.tgz", - "integrity": "sha512-0ExYgJZv8+nFuoV0T/xzX5ZyXFXwkHJFJg9LWTwhqgRLpe+IzBLirvyYKSdS8mPofTBKAjDYIA2o84xh5BHXew==", + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.150.0.tgz", + "integrity": "sha512-vC4tN4n2c+gVq69Oi6CoZxGk/1mYjLiN4hVEYwvtuns/S4QwUBiJYViwLGLxPaLQnsY5FQDgGs+h6moAmSedvA==", "dev": true, "license": "MIT", "engines": { @@ -146,9 +219,9 @@ } }, "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.66.0.tgz", - "integrity": "sha512-2Me9eoptv6ERdEuI2P8AOlYdHHraXebJaM6SC0kc2Dfb+mLrep2db+fedBPKaYn673h/vBgvP4tkOdAbaudX6w==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.68.0.tgz", + "integrity": "sha512-dhfYPbzv/h9JgHjNkl2R6sOjUfxDyLGOZVb3g8/ScaTNwwJcYgmHh8kcYFDUhinuy1QAoANCWUvw1jlk+z6gAg==", "cpu": [ "arm" ], @@ -163,9 +236,9 @@ } }, "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.66.0.tgz", - "integrity": "sha512-u7O+bSSF0HGsDKkQQxBqvLGVepu93RA+JKu+ONqvfh4sCnCEbj31wZj4iG5gk3XfRwrmYj0/8catkO2LcblQKQ==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.68.0.tgz", + "integrity": "sha512-v3Njdi6qY0O/5eGfg01ww2w6gTn2mUvZ72Bnx1/UN53A9wruh3Nk6otc3WkgJLkXD4Qgz1SOcVQieH1oD03V9Q==", "cpu": [ "arm64" ], @@ -180,9 +253,9 @@ } }, "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.66.0.tgz", - "integrity": "sha512-/ikyMIVjX/sdo7KtjxoEsSUosfPzveVhT9RWMx9yGqFDKFJ89JAEKuEeLBmurDjrkb4w8tOnAdSO3SBaplY3bw==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.68.0.tgz", + "integrity": "sha512-ei4MCMzHFREmZwPJ7KuWUB4kBuHdsgDrnXGJVcEAopU7fj7S42I8BChdFILWdHvhFqR08FLJtOfbZIr2CDw0cA==", "cpu": [ "arm64" ], @@ -197,9 +270,9 @@ } }, "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.66.0.tgz", - "integrity": "sha512-q5xUsKeFqawa9NXa6ZGXWimFV19m8MogKPdTaSVDAAk2EQKBmBZRDeluwcl1p8ty/OFc9s9888OKEh3xfPVH0g==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.68.0.tgz", + "integrity": "sha512-UrKgzZxYhwB9DSvTX+vdgl9M32wLUNKJcAKIoiyx/Kzn/zveqi7W6kYVcRroFMhS3Kwz0KhTk3WBeSuQn4YCTg==", "cpu": [ "x64" ], @@ -214,9 +287,9 @@ } }, "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.66.0.tgz", - "integrity": "sha512-CR+x4VzMY0pRXLK/xFQ/RzsSFkP5t2Z2mef0QY6OP/rTRcMUoMLCOM62/3Fp/t0K+UDoBKxvMyeb6D0zPMjleA==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.68.0.tgz", + "integrity": "sha512-6jrEKgpJbilM1QaRv7hEtKXr4p4AK4jvvyOtajwyhu0kOz3e0O7OLnSTk6tBotRqCcUC4ehZRJ1Zx+Y99wieLw==", "cpu": [ "x64" ], @@ -231,9 +304,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.66.0.tgz", - "integrity": "sha512-ZEYmO/LbH9tTQCADILHGZE4GeOXOAj2VzedHkASNwjmwlwtutJCLpCJbIs37wRGTFgWRoEcD72jpMX+IBJUGjQ==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.68.0.tgz", + "integrity": "sha512-YOIVnKOBaLeGullskS179N12hjSAdFYnzLjOaKiLhAKNWgnShq9w4xRdtmUm6BlnP65l2/EA9Aw/KlftNxDM7Q==", "cpu": [ "arm" ], @@ -248,9 +321,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.66.0.tgz", - "integrity": "sha512-hNtR9/oU0CeTkq7JnRkmBQwqe17v2ZaAMLC4VcN7IIOWeRyWDk0knSPWS9iiLmtbZ2RRBBtsG01jQgkZmKCJeQ==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.68.0.tgz", + "integrity": "sha512-xW5XoEHVNqydPBv2KXvk9lmEzyAlOQHVEazKoXUuAacqekjya+OdiaFjjEBl0oJD02raG8g3TRl9OVCh9PDIHA==", "cpu": [ "arm" ], @@ -265,9 +338,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.66.0.tgz", - "integrity": "sha512-uwOVQ8i6I1LT/+eDzfsgrrcZp8Fn6NPVUPn8fF5gdFGekFf0PddF+LEuwsD0/pbNUcKZhDj2rQ5UpITh9gF4iQ==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.68.0.tgz", + "integrity": "sha512-QCvYwVVQieu6oyJglAgV9vH/YMDxZyR4cwVSYtoq9oOXd5N+D3TDUBjNwxFrLn5AdcJZOcvn/7IB17vJGp+2Og==", "cpu": [ "arm64" ], @@ -285,9 +358,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.66.0.tgz", - "integrity": "sha512-tTkF2Dmx4nGAjmBlZb+UtTGqR/EK4ZrW9qBfzte07a9XWqzoGGKzpFFlyNDhQe+Uwql94+ReCTeNbhOXscw1Dg==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.68.0.tgz", + "integrity": "sha512-4TVz5iFQ8ndrHnhX50UXiz9BIWAtUSOHJ6Nus4qWFfJBXq/Ed/krXbF/ehJu42BXM5tIvBs99jNIM22s9agY5A==", "cpu": [ "arm64" ], @@ -305,9 +378,9 @@ } }, "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.66.0.tgz", - "integrity": "sha512-F3cKHUav4yXOHn6GFnwpBhSYsJOYKKf9eqO/9jlEuqPxNw9zb98E9ZFct79gcg8pibUGkbveEu9WDlmXJpDzKw==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.68.0.tgz", + "integrity": "sha512-qLe3ao0RP84bnPxBvRI+GnlK/jybo538NWu0Xrm+zYeTmJtpzqLhnnd5BH21NafqKnplbGZjtN1cnrOlWF73lw==", "cpu": [ "ppc64" ], @@ -325,9 +398,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.66.0.tgz", - "integrity": "sha512-K5fDaNZfDyQMYA/3qL21bqyN0X9T15LLwwbFPt2aHc94+ZG7bh0vZEsy2y7NlRnjjHFSwN+Hzg6ldJtbOriH4Q==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.68.0.tgz", + "integrity": "sha512-Yvyl7a6gbb0vM6r925KW2dO+/CmXySO5TVbcX7o/uZJ+d108HFOG0TxIyHApmn5USuj5mhDPxzhiVwOlGP7uSA==", "cpu": [ "riscv64" ], @@ -345,9 +418,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.66.0.tgz", - "integrity": "sha512-44Yc+I+qOmTElRcEhm5hUKIUJEQIOugymz4ua4tB0Wox7tGAfIbjzmXz/HDAtw1Ij6gmBwZlzh4hc9679RhWeA==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.68.0.tgz", + "integrity": "sha512-mJlFuFVCxzrYM5sFStN433D/s/mb6Wq4aCQAM02vs/OudHywnaSAd2rb1vlYUJtqdYIciJtiasuxvfbYkv5fLg==", "cpu": [ "riscv64" ], @@ -365,9 +438,9 @@ } }, "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.66.0.tgz", - "integrity": "sha512-1e29Eg9hEj2kRBB19M0seIehPbbXHCk35GvImjDvb79rjjYjXCRmtbUNHJcgoktZAMIzXrTbxDBKmTc1V4bg3A==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.68.0.tgz", + "integrity": "sha512-RlfSg++qs1hbKltRR6lYvV9EoI3MdlfSQD9w1hdHVYjHqjIn1tkH4FWOpMSmjKGN20zr+nI+W9o4ARogCDudGQ==", "cpu": [ "s390x" ], @@ -385,9 +458,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.66.0.tgz", - "integrity": "sha512-vODY1UQo10gngn0+D4xHKU84F1Twm1LqrzV4SqPXvmQKSd87paehvZ6jqA5wKs6XQrlWul9clYMDVHcoW9CPMA==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.68.0.tgz", + "integrity": "sha512-nyzRB9U+dlYUKu3pMo3afHzZBUv/oTHZMG36ZfJViNVfOIzp70Q4GS8FFRGgYJ/p0zcyDCgpBvYISOdJOMh+jQ==", "cpu": [ "x64" ], @@ -405,9 +478,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.66.0.tgz", - "integrity": "sha512-YDzXx2JsT4+HL4MdkVrYjO55NS5lUKNm8rLC4ZPou8+seu0v0jhecSh+ufoO6+xEa8gccEezMlI2WHJi4ApUgw==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.68.0.tgz", + "integrity": "sha512-iCx3sbZRIvGrL1RafphEiUKBaW1lc0/tAjKOIB/Wjw2+STRBEdu5+fH1Gc1faEWEmc2k5Ks4iUUV54Zd5C9a1A==", "cpu": [ "x64" ], @@ -425,9 +498,9 @@ } }, "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.66.0.tgz", - "integrity": "sha512-mJjUYd8lj0+j4JkYyEM+5qKBf1Rnrpgjn/SVYKJhicVDqLz566ooa7Fs8zflPqt+dnZDV7X054rVIQX6ZcQNlQ==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.68.0.tgz", + "integrity": "sha512-x2X5AZez7OgyLLFpwIgItoXBUqudDM7yiaTsxv8R8vKQ6e81l0jVw0NFeUCXzcl1sAJq8h+tC8N4mY8EiMeL4w==", "cpu": [ "arm64" ], @@ -442,9 +515,9 @@ } }, "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.66.0.tgz", - "integrity": "sha512-soV+0vESv7e5ntCHWC61x4gg8OSak6IHHnWsZmHrJFlvMj2AK+kmldErCNkVkrvc1Ts2/++rJXn+IuAb2WMXhw==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.68.0.tgz", + "integrity": "sha512-AHVPjXkenLPQUh6kB8zSC8pX2ct9r4T1Edk9r/RNJyov6wPsS5uAfYtipwG4chn6+3bPFG5rI/3DxEeH8vib1w==", "cpu": [ "arm64" ], @@ -459,9 +532,9 @@ } }, "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.66.0.tgz", - "integrity": "sha512-YCPi23uRIEYuIKTZohAkKbPFpujQ5QBuUM5iDv+UqbCmTPAkaFsxjsSuB8xlBpRT0G7eP/4HMF+cPDSqHtOD9A==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.68.0.tgz", + "integrity": "sha512-n09SjEk5VH7z8Hl4WVP7hho+cCwGViENkQFiM45vbW85dJd7kEhWaHRUobaPzEmrWyu6uumd4EuNfNyDKLtzDA==", "cpu": [ "ia32" ], @@ -476,9 +549,9 @@ } }, "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.66.0.tgz", - "integrity": "sha512-bwTQcv/JVRPkOqQtMF0X7vpvpncDQiBcXHxZ9S2hR12Hlo8bvBdUR5x5XnxzDZ3kM0qoZw1rv7KaD66Ly+pFWA==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.68.0.tgz", + "integrity": "sha512-gPe+dJLXaPuWPWqlpklDAJp0k+K9KhQPYiQLHfb+i2rmFuUGfJ/5Qlj6tr1mO6of5g0DiLjG/XCFHIaPhotqqA==", "cpu": [ "x64" ], @@ -577,9 +650,9 @@ ] }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.81.0.tgz", - "integrity": "sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.83.0.tgz", + "integrity": "sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA==", "cpu": [ "arm" ], @@ -594,9 +667,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.81.0.tgz", - "integrity": "sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.83.0.tgz", + "integrity": "sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg==", "cpu": [ "arm64" ], @@ -611,9 +684,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.81.0.tgz", - "integrity": "sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.83.0.tgz", + "integrity": "sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ==", "cpu": [ "arm64" ], @@ -628,9 +701,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.81.0.tgz", - "integrity": "sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.83.0.tgz", + "integrity": "sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA==", "cpu": [ "x64" ], @@ -645,9 +718,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.81.0.tgz", - "integrity": "sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.83.0.tgz", + "integrity": "sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w==", "cpu": [ "x64" ], @@ -662,9 +735,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.81.0.tgz", - "integrity": "sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.83.0.tgz", + "integrity": "sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw==", "cpu": [ "arm" ], @@ -679,9 +752,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.81.0.tgz", - "integrity": "sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.83.0.tgz", + "integrity": "sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA==", "cpu": [ "arm" ], @@ -696,9 +769,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.81.0.tgz", - "integrity": "sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.83.0.tgz", + "integrity": "sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg==", "cpu": [ "arm64" ], @@ -716,9 +789,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.81.0.tgz", - "integrity": "sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.83.0.tgz", + "integrity": "sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew==", "cpu": [ "arm64" ], @@ -736,9 +809,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.81.0.tgz", - "integrity": "sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.83.0.tgz", + "integrity": "sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g==", "cpu": [ "ppc64" ], @@ -756,9 +829,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.81.0.tgz", - "integrity": "sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.83.0.tgz", + "integrity": "sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw==", "cpu": [ "riscv64" ], @@ -776,9 +849,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.81.0.tgz", - "integrity": "sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.83.0.tgz", + "integrity": "sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw==", "cpu": [ "riscv64" ], @@ -796,9 +869,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.81.0.tgz", - "integrity": "sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.83.0.tgz", + "integrity": "sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ==", "cpu": [ "s390x" ], @@ -816,9 +889,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.81.0.tgz", - "integrity": "sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.83.0.tgz", + "integrity": "sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw==", "cpu": [ "x64" ], @@ -836,9 +909,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.81.0.tgz", - "integrity": "sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.83.0.tgz", + "integrity": "sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ==", "cpu": [ "x64" ], @@ -856,9 +929,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.81.0.tgz", - "integrity": "sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.83.0.tgz", + "integrity": "sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA==", "cpu": [ "arm64" ], @@ -873,9 +946,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.81.0.tgz", - "integrity": "sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.83.0.tgz", + "integrity": "sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg==", "cpu": [ "arm64" ], @@ -890,9 +963,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.81.0.tgz", - "integrity": "sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.83.0.tgz", + "integrity": "sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA==", "cpu": [ "ia32" ], @@ -907,9 +980,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.81.0.tgz", - "integrity": "sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.83.0.tgz", + "integrity": "sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q==", "cpu": [ "x64" ], @@ -1312,6 +1385,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -1693,6 +1776,37 @@ "vitest": "4.1.11" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@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.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", @@ -1807,9 +1921,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-darwin-arm64": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-arm64/-/vite-plus-darwin-arm64-0.3.1.tgz", - "integrity": "sha512-3xK+G6zJHa4Q60NgAnnZ38ML5NSf5n3+DBHSINqCgsPpQ7XtMWkpEsXArWU7u3nlW3f1F2XLAvuAk54TmQXptw==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-arm64/-/vite-plus-darwin-arm64-0.3.3.tgz", + "integrity": "sha512-1hwUfQGqvzlO+qQ7r1i5XQWkKaWq/JcdAAZOPloMQsZq7P8rgiY1pq/0q4stzcNxFF74k4aEFOlDUKi6D149YA==", "cpu": [ "arm64" ], @@ -1824,9 +1938,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-darwin-x64": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-x64/-/vite-plus-darwin-x64-0.3.1.tgz", - "integrity": "sha512-GbJ2RWZezmpr/erPlJ4Kw9T3y2EP3cwZSkh+Ph8IfcJMj3MRqTw1lfxCl81x8Opyct6wYU6kDRU3zH95Tl341Q==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-x64/-/vite-plus-darwin-x64-0.3.3.tgz", + "integrity": "sha512-nM2+EC1BPochRhtf48FWZX3wPnrsy8aBSI5TPqjxr0OS0bydvPavrhdSUmgUGZG9/hp0NTeXhhqY9A7yuvZLZQ==", "cpu": [ "x64" ], @@ -1841,9 +1955,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-linux-arm64-gnu": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-gnu/-/vite-plus-linux-arm64-gnu-0.3.1.tgz", - "integrity": "sha512-vYXxMRxE7DAa8QIh3k5IkfTeBbA/8JHYHL14rsodq4er4FNOd4AAM07eAczin7Pdf4Aie/2BPkymSF8hFyjZDg==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-gnu/-/vite-plus-linux-arm64-gnu-0.3.3.tgz", + "integrity": "sha512-WC8/kT0/btxnwgYR5QvvelbgUaLQ6lbxs8aMQpjc63p1QdMTNZEbp3vnTV2l9KgoUYeyHnALJVBW9l1CJAM+fA==", "cpu": [ "arm64" ], @@ -1861,9 +1975,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-linux-arm64-musl": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-musl/-/vite-plus-linux-arm64-musl-0.3.1.tgz", - "integrity": "sha512-LAnyEqhZ2Fji6bXDnigSWbEjS/B7u/Al01N4JERSR6scr4PbZpfMW8/1EUHfi+9pIdxwoDazIRZeh5DvEDX8eQ==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-musl/-/vite-plus-linux-arm64-musl-0.3.3.tgz", + "integrity": "sha512-iy3AgJK9wwOGjlEzq0Sm1bqK5pDSWF4nz29Ey5BmK7bGciyfufeKRqRASKJdMhRMEQ7WVUQz5luC5GddLEDwXg==", "cpu": [ "arm64" ], @@ -1881,9 +1995,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-linux-x64-gnu": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-gnu/-/vite-plus-linux-x64-gnu-0.3.1.tgz", - "integrity": "sha512-F9Ek2p+ZnDB2FrO6ugZU2ZnuOO/Q9GR9jwUcnK2oUHwl6AZUvg671L7vU57rrtY4f9pS+AcL9lWRoS4wedXm3A==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-gnu/-/vite-plus-linux-x64-gnu-0.3.3.tgz", + "integrity": "sha512-ZHN3RCA422XrGYmSDE9IZqx/eM5PsFCXHn8hUttQEwcSVXsuKBArDyb/uxonn1n0bPNW8c4G4HvNmIS3ckLXzA==", "cpu": [ "x64" ], @@ -1901,9 +2015,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-linux-x64-musl": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-musl/-/vite-plus-linux-x64-musl-0.3.1.tgz", - "integrity": "sha512-3Q+hJ19iw1k2NOCoop/gK+etMPnPYbgdqXBS9mgUuuDUA142v1PoYH+gk3NMV+KyDvSZaLkgDuGrdNav0pJtqw==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-musl/-/vite-plus-linux-x64-musl-0.3.3.tgz", + "integrity": "sha512-lDmmjrgh6qNvBlzapzKMjxiqRigkyPjIimkxrk8CSz+/AyQp6I/wuIAyjBmNfeh14SQoDNVgf03CTJzmJycrpg==", "cpu": [ "x64" ], @@ -1921,9 +2035,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-win32-arm64-msvc": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-arm64-msvc/-/vite-plus-win32-arm64-msvc-0.3.1.tgz", - "integrity": "sha512-aZAfZdQBFDzktOF/0OJDYrkOP551IxmoMRGfU3KdJm25Gpj8WHl5QTNx/mL8216rbZ7p4QIdBc4gajoRCAtsuA==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-arm64-msvc/-/vite-plus-win32-arm64-msvc-0.3.3.tgz", + "integrity": "sha512-/5/YdLItQH9KC+jL49uw9CidC7MeO3Q8ojRcuOPcH5ujU5tU4P4lO2qJ9RbM8ckC1lfiZYTtVtJUSkbPn1R/YQ==", "cpu": [ "arm64" ], @@ -1938,9 +2052,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-win32-x64-msvc": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-x64-msvc/-/vite-plus-win32-x64-msvc-0.3.1.tgz", - "integrity": "sha512-8rxCvdTpd1v/+g/ECjqKnHxBlxmk8O9kNl4wwDcfWszbL/O/pNNSktKHq6rR3A6m6p0NCoZsenbGLuJsHE+q9A==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-x64-msvc/-/vite-plus-win32-x64-msvc-0.3.3.tgz", + "integrity": "sha512-npbLQAAKdWHEB0qOFkmF/2OUzdraNBe1w7gY1LjXQFvqORAKZlHyQ2rgF5nBtG5+LKvbKWL6XpvyP+cQEh7MZg==", "cpu": [ "x64" ], @@ -1979,6 +2093,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -1993,6 +2108,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2007,6 +2123,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2021,6 +2138,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2035,6 +2153,7 @@ "cpu": [ "arm" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "glibc" @@ -2052,6 +2171,7 @@ "cpu": [ "arm" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "musl" @@ -2069,6 +2189,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "glibc" @@ -2086,6 +2207,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "musl" @@ -2103,6 +2225,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "glibc" @@ -2120,6 +2243,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "musl" @@ -2137,6 +2261,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2151,6 +2276,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2165,6 +2291,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2179,6 +2306,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2193,6 +2321,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2207,6 +2336,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2221,6 +2351,7 @@ "cpu": [ "arm" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "glibc" @@ -2238,6 +2369,7 @@ "cpu": [ "arm" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "musl" @@ -2255,6 +2387,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "glibc" @@ -2272,6 +2405,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "musl" @@ -2289,6 +2423,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "glibc" @@ -2306,6 +2441,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "musl" @@ -2323,6 +2459,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2337,6 +2474,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2394,6 +2532,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2504,6 +2661,62 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2804,6 +3017,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -2858,13 +3099,13 @@ } }, "node_modules/oxfmt": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.66.0.tgz", - "integrity": "sha512-FfvqR8RFtV6JJpRrpkfqyVCQ7HDvZ/VriWFx7veftCgL1B5ZO9qNr+1rvPieycMQnNfVG0PWyJQiy7p0hq1I5w==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.68.0.tgz", + "integrity": "sha512-Z0XMofcXCGUXbcpBHnWyUiX93BGiw1B+lcHNbQDWEtOhX06ewoFfu4zXkyiLhRrNnMq0twqXRHUcJetf+GsiQQ==", "dev": true, "license": "MIT", "dependencies": { - "tinypool": "2.1.0" + "tinypool": "2.1.2" }, "bin": { "oxfmt": "bin/oxfmt" @@ -2876,25 +3117,25 @@ "url": "https://github.com/sponsors/oxc-project" }, "optionalDependencies": { - "@oxfmt/binding-android-arm-eabi": "0.66.0", - "@oxfmt/binding-android-arm64": "0.66.0", - "@oxfmt/binding-darwin-arm64": "0.66.0", - "@oxfmt/binding-darwin-x64": "0.66.0", - "@oxfmt/binding-freebsd-x64": "0.66.0", - "@oxfmt/binding-linux-arm-gnueabihf": "0.66.0", - "@oxfmt/binding-linux-arm-musleabihf": "0.66.0", - "@oxfmt/binding-linux-arm64-gnu": "0.66.0", - "@oxfmt/binding-linux-arm64-musl": "0.66.0", - "@oxfmt/binding-linux-ppc64-gnu": "0.66.0", - "@oxfmt/binding-linux-riscv64-gnu": "0.66.0", - "@oxfmt/binding-linux-riscv64-musl": "0.66.0", - "@oxfmt/binding-linux-s390x-gnu": "0.66.0", - "@oxfmt/binding-linux-x64-gnu": "0.66.0", - "@oxfmt/binding-linux-x64-musl": "0.66.0", - "@oxfmt/binding-openharmony-arm64": "0.66.0", - "@oxfmt/binding-win32-arm64-msvc": "0.66.0", - "@oxfmt/binding-win32-ia32-msvc": "0.66.0", - "@oxfmt/binding-win32-x64-msvc": "0.66.0" + "@oxfmt/binding-android-arm-eabi": "0.68.0", + "@oxfmt/binding-android-arm64": "0.68.0", + "@oxfmt/binding-darwin-arm64": "0.68.0", + "@oxfmt/binding-darwin-x64": "0.68.0", + "@oxfmt/binding-freebsd-x64": "0.68.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.68.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.68.0", + "@oxfmt/binding-linux-arm64-gnu": "0.68.0", + "@oxfmt/binding-linux-arm64-musl": "0.68.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.68.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.68.0", + "@oxfmt/binding-linux-riscv64-musl": "0.68.0", + "@oxfmt/binding-linux-s390x-gnu": "0.68.0", + "@oxfmt/binding-linux-x64-gnu": "0.68.0", + "@oxfmt/binding-linux-x64-musl": "0.68.0", + "@oxfmt/binding-openharmony-arm64": "0.68.0", + "@oxfmt/binding-win32-arm64-msvc": "0.68.0", + "@oxfmt/binding-win32-ia32-msvc": "0.68.0", + "@oxfmt/binding-win32-x64-msvc": "0.68.0" }, "peerDependencies": { "svelte": "^5.0.0", @@ -2910,9 +3151,9 @@ } }, "node_modules/oxlint": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.81.0.tgz", - "integrity": "sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.83.0.tgz", + "integrity": "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ==", "dev": true, "license": "MIT", "bin": { @@ -2925,25 +3166,25 @@ "url": "https://github.com/sponsors/oxc-project" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.81.0", - "@oxlint/binding-android-arm64": "1.81.0", - "@oxlint/binding-darwin-arm64": "1.81.0", - "@oxlint/binding-darwin-x64": "1.81.0", - "@oxlint/binding-freebsd-x64": "1.81.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.81.0", - "@oxlint/binding-linux-arm-musleabihf": "1.81.0", - "@oxlint/binding-linux-arm64-gnu": "1.81.0", - "@oxlint/binding-linux-arm64-musl": "1.81.0", - "@oxlint/binding-linux-ppc64-gnu": "1.81.0", - "@oxlint/binding-linux-riscv64-gnu": "1.81.0", - "@oxlint/binding-linux-riscv64-musl": "1.81.0", - "@oxlint/binding-linux-s390x-gnu": "1.81.0", - "@oxlint/binding-linux-x64-gnu": "1.81.0", - "@oxlint/binding-linux-x64-musl": "1.81.0", - "@oxlint/binding-openharmony-arm64": "1.81.0", - "@oxlint/binding-win32-arm64-msvc": "1.81.0", - "@oxlint/binding-win32-ia32-msvc": "1.81.0", - "@oxlint/binding-win32-x64-msvc": "1.81.0" + "@oxlint/binding-android-arm-eabi": "1.83.0", + "@oxlint/binding-android-arm64": "1.83.0", + "@oxlint/binding-darwin-arm64": "1.83.0", + "@oxlint/binding-darwin-x64": "1.83.0", + "@oxlint/binding-freebsd-x64": "1.83.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.83.0", + "@oxlint/binding-linux-arm-musleabihf": "1.83.0", + "@oxlint/binding-linux-arm64-gnu": "1.83.0", + "@oxlint/binding-linux-arm64-musl": "1.83.0", + "@oxlint/binding-linux-ppc64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-musl": "1.83.0", + "@oxlint/binding-linux-s390x-gnu": "1.83.0", + "@oxlint/binding-linux-x64-gnu": "1.83.0", + "@oxlint/binding-linux-x64-musl": "1.83.0", + "@oxlint/binding-openharmony-arm64": "1.83.0", + "@oxlint/binding-win32-arm64-msvc": "1.83.0", + "@oxlint/binding-win32-ia32-msvc": "1.83.0", + "@oxlint/binding-win32-x64-msvc": "1.83.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", @@ -3140,6 +3381,19 @@ "node": ">=6" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3163,9 +3417,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3186,6 +3440,19 @@ "dev": true, "license": "MIT" }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3221,9 +3488,9 @@ } }, "node_modules/tinypool": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", - "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.2.tgz", + "integrity": "sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==", "dev": true, "license": "MIT", "engines": { @@ -3285,6 +3552,13 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", @@ -3364,13 +3638,13 @@ } }, "node_modules/vite-plus": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/vite-plus/-/vite-plus-0.3.1.tgz", - "integrity": "sha512-U8KZ3c3mbX0qLfigx9rW2W9J73w9wjdf4s/s91H77ff5afqSYEdZd8Or41Jl99kD42a+UCL3LFXzb5b+bIBCpQ==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/vite-plus/-/vite-plus-0.3.3.tgz", + "integrity": "sha512-gr3t8ZIsytJnpPE0YPxtkouf+asMsc+TWlKXYzEdY6rRXUdCGYxgaPmzRePx1Gyip2gmARRLogAwoMMW9BvSUA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.148.0", + "@oxc-project/types": "=0.150.0", "@oxlint/plugins": "=1.79.0", "@vitest/browser": "4.1.11", "@vitest/browser-preview": "4.1.11", @@ -3381,10 +3655,10 @@ "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", - "oxfmt": "=0.66.0", - "oxlint": "=1.81.0", + "oxfmt": "=0.68.0", + "oxlint": "=1.83.0", "oxlint-tsgolint": "=7.0.2001", - "vite": "npm:@voidzero-dev/vite-plus-core@0.3.1", + "vite": "npm:@voidzero-dev/vite-plus-core@0.3.3", "vitest": "4.1.11" }, "bin": { @@ -3397,14 +3671,14 @@ "node": "^20.19.0 || ^22.18.0 || >=24.11.0" }, "optionalDependencies": { - "@voidzero-dev/vite-plus-darwin-arm64": "0.3.1", - "@voidzero-dev/vite-plus-darwin-x64": "0.3.1", - "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.3.1", - "@voidzero-dev/vite-plus-linux-arm64-musl": "0.3.1", - "@voidzero-dev/vite-plus-linux-x64-gnu": "0.3.1", - "@voidzero-dev/vite-plus-linux-x64-musl": "0.3.1", - "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.3.1", - "@voidzero-dev/vite-plus-win32-x64-msvc": "0.3.1" + "@voidzero-dev/vite-plus-darwin-arm64": "0.3.3", + "@voidzero-dev/vite-plus-darwin-x64": "0.3.3", + "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.3.3", + "@voidzero-dev/vite-plus-linux-arm64-musl": "0.3.3", + "@voidzero-dev/vite-plus-linux-x64-gnu": "0.3.3", + "@voidzero-dev/vite-plus-linux-x64-musl": "0.3.3", + "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.3.3", + "@voidzero-dev/vite-plus-win32-x64-msvc": "0.3.3" }, "peerDependencies": { "@vitest/browser-playwright": "4.1.11", @@ -3420,9 +3694,9 @@ } }, "node_modules/vite-plus/node_modules/@oxc-project/types": { - "version": "0.148.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", - "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", "dev": true, "license": "MIT", "funding": { @@ -3431,14 +3705,14 @@ }, "node_modules/vite-plus/node_modules/vite": { "name": "@voidzero-dev/vite-plus-core", - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-core/-/vite-plus-core-0.3.1.tgz", - "integrity": "sha512-3uVTyZzrLWKV3aIjRPuqkPwzG0iNQIncJlCDIT/gXFk0FQOW5US9bSHf3jsDqD+NjgTP1vAfI99MZuYTUjEDbg==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-core/-/vite-plus-core-0.3.3.tgz", + "integrity": "sha512-Ui92VENIZG+p0nMJuVGMsHBHrQqgUNxXObdo5PlSDEBOdRmD8AHKr8tOClZTVU1U5blLQKtHKplxtTun0yZEmw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/runtime": "=0.148.0", - "@oxc-project/types": "=0.148.0", + "@oxc-project/runtime": "=0.150.0", + "@oxc-project/types": "=0.150.0", "lightningcss": "^1.33.0", "postcss": "^8.5.6", "yuku-codegen": "^0.9.3", @@ -3448,20 +3722,20 @@ "node": "^20.19.0 || ^22.18.0 || >=24.11.0" }, "optionalDependencies": { - "@voidzero-dev/vite-plus-darwin-arm64": "0.3.1", - "@voidzero-dev/vite-plus-darwin-x64": "0.3.1", - "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.3.1", - "@voidzero-dev/vite-plus-linux-arm64-musl": "0.3.1", - "@voidzero-dev/vite-plus-linux-x64-gnu": "0.3.1", - "@voidzero-dev/vite-plus-linux-x64-musl": "0.3.1", - "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.3.1", - "@voidzero-dev/vite-plus-win32-x64-msvc": "0.3.1", + "@voidzero-dev/vite-plus-darwin-arm64": "0.3.3", + "@voidzero-dev/vite-plus-darwin-x64": "0.3.3", + "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.3.3", + "@voidzero-dev/vite-plus-linux-arm64-musl": "0.3.3", + "@voidzero-dev/vite-plus-linux-x64-gnu": "0.3.3", + "@voidzero-dev/vite-plus-linux-x64-musl": "0.3.3", + "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.3.3", + "@voidzero-dev/vite-plus-win32-x64-msvc": "0.3.3", "fsevents": "~2.3.3" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/package.json b/package.json index ba6abbe..097f962 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ }, "files": [ "dist/**/*.js", - "dist/**/*.d.ts" + "dist/**/*.d.ts", + "CHANGELOG.md", + "docs/**/*.md" ], "type": "module", "main": "./dist/index.js", @@ -83,14 +85,16 @@ "bench:tier4": "vp test bench --run --reporter=default --config vitest.bench.tier4.config.ts", "fmt": "vp fmt .", "lint": "vp lint src tests benches vite.config.ts vitest.*.config.ts", - "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.benchmarks.json", "test": "npm run test:unit && npm run test:checks", "test:unit": "vp test run -c vitest.test.unit.config.ts", + "test:coverage": "npm run test:unit -- --coverage", "test:checks": "npm run typecheck && npm run test:types", "test:types": "npm run build && tsc -p tests/types/tsconfig.json", "test:publint": "publint", "pack:check": "node tests/package-artifacts.js", - "check": "npm run lint && npm run typecheck && npm test && npm run build && npm run test:publint && npm run pack:check", + "test:packed": "node tests/package-consumers.js", + "check": "npm run lint && npm run typecheck && npm run test:coverage && npm run test:types && npm run test:publint && npm run pack:check && npm run test:packed", "prepack": "npm run build", "prepublishOnly": "npm run check" }, @@ -100,6 +104,8 @@ }, "devDependencies": { "@askrjs/askr": ">=0.4.0 <0.5.0", + "@types/node": "24.19.2", + "@vitest/coverage-v8": "4.1.11", "publint": "^0.3.24", "typescript": "^7.0.2", "vite-plus": "^0.3.1", diff --git a/src/application.ts b/src/application.ts index 386f68a..87e2058 100644 --- a/src/application.ts +++ b/src/application.ts @@ -85,6 +85,22 @@ export function createServerApp(input: Router | ServerAppOptions = {}): ServerAp options, dispatchOptions?.websocket, ); + let errorHandlerFailure: { reason: unknown } | undefined; + const respondToError = async ( + error: unknown, + failedContext: ServerContext, + ): Promise => { + if (errorHandlerFailure) throw errorHandlerFailure.reason; + try { + const response = await errorResponse(error, failedContext); + if (!(response instanceof Response)) throw new TypeError("onError must return a Response"); + return response; + } catch (reason) { + // Propagate this request's failed error handler without invoking it again at outer boundaries. + errorHandlerFailure = { reason }; + throw reason; + } + }; const traceId = options.telemetry?.traceId(); if (requestId) context.state.requestId = requestId; if (traceId) context.state.traceId = traceId; @@ -103,10 +119,10 @@ export function createServerApp(input: Router | ServerAppOptions = {}): ServerAp } return await dispatchRequest(middleware, context, found, { ...options, - errorResponse, + errorResponse: respondToError, }); } catch (error) { - return errorResponse(error, context); + return respondToError(error, context); } }; diff --git a/src/body-limit.ts b/src/body-limit.ts index 852f0c2..7224de8 100644 --- a/src/body-limit.ts +++ b/src/body-limit.ts @@ -58,38 +58,66 @@ export function readRequestBytes( request: Request, maximum = requestLimit(request), ): Promise { + validateMaxRequestBytes(maximum); rejectOversizedContentLength(request, maximum); const existing = bodies.get(request); - if (existing) return existing; + if (existing) { + const repeated = existing.then((bytes) => { + request.signal.throwIfAborted(); + if (bytes.byteLength > maximum) throw new PayloadTooLargeError(); + return bytes; + }); + void repeated.catch(() => undefined); + return repeated; + } if (request.bodyUsed) return Promise.reject(new TypeError("Request body has already been consumed.")); const pending = (async () => { - if (!request.body) return new Uint8Array(); + if (!request.body) { + request.signal.throwIfAborted(); + return new Uint8Array(); + } const reader = request.body.getReader(); - const chunks: Uint8Array[] = []; - let length = 0; - try { + let rejectAbort!: (reason: unknown) => void; + const aborted = new Promise((_resolve, reject) => { + rejectAbort = reject; + }); + const abort = () => { + void reader.cancel(request.signal.reason).catch(() => undefined); + rejectAbort(request.signal.reason); + }; + request.signal.addEventListener("abort", abort, { once: true }); + if (request.signal.aborted) abort(); + const consume = async () => { + request.signal.throwIfAborted(); + const chunks: Uint8Array[] = []; + let length = 0; for (;;) { const { done, value } = await reader.read(); + request.signal.throwIfAborted(); if (done) break; length += value.byteLength; if (length > maximum) { - await reader.cancel().catch(() => undefined); + void reader.cancel().catch(() => undefined); throw new PayloadTooLargeError(); } chunks.push(value); } + if (chunks.length === 1) return chunks[0]!; + const output = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; + }; + try { + return await Promise.race([consume(), aborted]); } finally { + request.signal.removeEventListener("abort", abort); reader.releaseLock(); } - if (chunks.length === 1) return chunks[0]!; - const output = new Uint8Array(length); - let offset = 0; - for (const chunk of chunks) { - output.set(chunk, offset); - offset += chunk.byteLength; - } - return output; })(); // Mark the cached promise handled for host rejection tracking without changing what callers await. void pending.catch(() => undefined); diff --git a/src/dispatch.ts b/src/dispatch.ts index b9e0e67..fcef363 100644 --- a/src/dispatch.ts +++ b/src/dispatch.ts @@ -10,12 +10,13 @@ import type { } from "./contracts"; import { challenge, forbidden, methodNotAllowed, notFound } from "./http/responses"; import type { MatchResult } from "./router/matcher"; +import { discardResponseBody } from "./response-body"; export class MiddlewareNextError extends Error { readonly code = "middleware_next_reused"; constructor() { - super("next() may only be called once per middleware invocation"); + super("next() may only be called once while its middleware invocation is running"); this.name = "MiddlewareNextError"; } } @@ -29,25 +30,48 @@ export class MiddlewareResponseError extends TypeError { } } +function discardDownstream(pending: Promise | undefined, returned?: Response): void { + void pending?.then( + (response) => { + if (response instanceof Response && response !== returned && response.body !== returned?.body) + discardResponseBody(response); + }, + () => { + // The middleware/terminal error path owns this rejection. + }, + ); +} + function runMiddleware( middleware: readonly Middleware[], context: ServerContext, terminal: Handler, onError?: (error: unknown, context: ServerContext) => Response | Promise, ): Promise { - let index = -1; const dispatch = async (nextIndex: number): Promise => { + let active = true; + let downstream: Promise | undefined; try { - if (nextIndex <= index) throw new MiddlewareNextError(); - index = nextIndex; const current = middleware[nextIndex]; if (!current) return await terminal(context); - const response = await current(context, () => dispatch(nextIndex + 1)); + const result = current(context, () => { + if (!active || downstream) throw new MiddlewareNextError(); + downstream = dispatch(nextIndex + 1); + void downstream.catch(() => undefined); + return downstream; + }); + // A synchronous short circuit retires next() before queued microtasks can call it. + if (result instanceof Response) active = false; + const response = await result; if (!(response instanceof Response)) throw new MiddlewareResponseError(); + discardDownstream(downstream, response); return response; } catch (error) { + discardDownstream(downstream); if (onError) return onError(error, context); throw error; + } finally { + active = false; } }; return dispatch(0); @@ -59,7 +83,12 @@ async function denial( onAccessDenied?: AccessDeniedHandler, ): Promise { if (decision.allowed) return undefined; - if (onAccessDenied) return onAccessDenied(decision, context); + if (onAccessDenied) { + const response = await onAccessDenied(decision, context); + if (!(response instanceof Response)) + throw new TypeError("The access denial handler must return a Response"); + return response; + } return decision.reason === "unauthenticated" ? challenge() : forbidden(decision.reason === "already_authenticated" ? "Already authenticated" : undefined); @@ -109,6 +138,8 @@ async function runProbe( try { const result = handler ? await handler(context) : undefined; if (result instanceof Response) return result; + if (result !== undefined && typeof result !== "boolean") + throw new TypeError("A probe must return a boolean, Response, or undefined"); return new Response(null, { status: result === false ? 503 : 200, headers: { "cache-control": "no-store" }, @@ -119,6 +150,7 @@ async function runProbe( } function withoutHeadBody(response: Response, request: Request): Response { + if (request.method === "HEAD") discardResponseBody(response); return request.method === "HEAD" ? new Response(null, { status: response.status, @@ -164,7 +196,10 @@ export async function dispatchRequest( ): Promise { const terminal = async (): Promise => { try { - return await executeTerminal(found, options, context); + const response = await executeTerminal(found, options, context); + if (!(response instanceof Response)) + throw new TypeError("A route or fallback handler must return a Response"); + return response; } catch (error) { return options.errorResponse(error, context); } diff --git a/src/response-body.ts b/src/response-body.ts new file mode 100644 index 0000000..9374c50 --- /dev/null +++ b/src/response-body.ts @@ -0,0 +1,6 @@ +/** Release an owned, discarded response without waiting for application cleanup. */ +export function discardResponseBody(response: Response): void { + void response.body?.cancel().catch(() => { + // A failed or never-settling cancel hook must not replace the request outcome. + }); +} diff --git a/tests/askr.test.ts b/tests/askr.test.ts index 827a830..8e428c5 100644 --- a/tests/askr.test.ts +++ b/tests/askr.test.ts @@ -21,6 +21,40 @@ const anonymous: AuthContext = { }; describe("Askr page fallback", () => { + it.each([ + { title: "ASCII title", lang: "en", encoded: false }, + { title: "Café", lang: "fr", encoded: false }, + { title: "日本語 🦉", lang: "ja", encoded: true }, + { title: "English", lang: "日本語", encoded: true }, + { title: `${"A".repeat(40_000)}🦉`, lang: "ja", encoded: true }, + ])( + "preserves fragment metadata with UTF-8 encoding $encoded for language $lang", + async ({ title, lang, encoded }) => { + const registry = createRouteRegistry(() => + route("/", () => "fragment", { + meta: { title, html: { lang, dir: "ltr" } }, + }), + ); + const response = await createServerApp({ + fallback: createAskrPageHandler({ registry }), + }).fetch(new Request("https://askr.test/")); + expect(response.status).toBe(200); + expect(response.headers.get("x-askr-encoding")).toBe(encoded ? "base64url-v1" : null); + const readHeader = (name: string) => { + const value = response.headers.get(name)!; + if (encoded) { + expect(value).toMatch(/^[A-Za-z0-9_-]+$/u); + return Buffer.from(value, "base64url").toString("utf8"); + } + return value; + }; + expect(readHeader("x-askr-head")).toBe(`${title}`); + expect(readHeader("x-askr-html-lang")).toBe(lang); + expect(readHeader("x-askr-html-dir")).toBe("ltr"); + expect(await response.text()).toBe("fragment"); + }, + ); + it("should reject manifest-only page handler options at runtime", () => { expect(() => createAskrPageHandler({ manifest: {} } as never)).toThrow( "createAskrPageHandler requires a route registry.", diff --git a/tests/body-limit.test.ts b/tests/body-limit.test.ts index f09b806..4bbccf6 100644 --- a/tests/body-limit.test.ts +++ b/tests/body-limit.test.ts @@ -2,6 +2,26 @@ import { describe, expect, it, vi } from "vitest"; import { PayloadTooLargeError, readRequestBytes } from "../src/body-limit"; describe("request body limits", () => { + it("should handle a cached rejection on a repeated read before a caller awaits it", async () => { + const unhandled = vi.fn(); + process.on("unhandledRejection", unhandled); + let repeated: Promise | undefined; + try { + const request = new Request("https://example.test/upload", { + method: "POST", + body: "too large", + }); + await expect(readRequestBytes(request, 1)).rejects.toBeInstanceOf(PayloadTooLargeError); + repeated = readRequestBytes(request, 1); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(unhandled).not.toHaveBeenCalled(); + await expect(repeated).rejects.toBeInstanceOf(PayloadTooLargeError); + } finally { + await repeated?.catch(() => undefined); + process.off("unhandledRejection", unhandled); + } + }); + it("should handle a cached body rejection before a caller awaits it", async () => { const unhandled = vi.fn(); process.on("unhandledRejection", unhandled); diff --git a/tests/lifecycle-boundaries.test.ts b/tests/lifecycle-boundaries.test.ts new file mode 100644 index 0000000..998e1bc --- /dev/null +++ b/tests/lifecycle-boundaries.test.ts @@ -0,0 +1,617 @@ +import { describe, expect, it } from "vitest"; +import { createServerApp } from "./test-app"; +import { PayloadTooLargeError, readRequestBytes } from "../src/body-limit"; +import { bind, BindingError } from "../src/binding"; +import type { + AccessDeniedHandler, + Handler, + Middleware, + Next, + ProbeHandler, +} from "../src/contracts"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} +const decoder = new TextDecoder(); + +describe("owned request body lifetime", () => { + it.each([ + "application/json", + "application/x-www-form-urlencoded", + "multipart/form-data; boundary=test", + ])( + "maps an aborted %s body to a binding failure with the original cause", + async (contentType) => { + const entered = deferred(); + let cancelled = 0; + const controller = new AbortController(); + const reason = new Error("binding read aborted"); + const request = new Request("https://askr.test/", { + method: "POST", + duplex: "half", + signal: controller.signal, + headers: { "content-type": contentType }, + body: new ReadableStream( + { + pull() { + entered.resolve(); + }, + cancel(value) { + expect(value).toBe(reason); + cancelled++; + }, + }, + { highWaterMark: 0 }, + ), + } as RequestInit); + const url = new URL(request.url); + const pending = bind({ request, url, params: {}, query: url.searchParams }); + const rejected = expect(pending).rejects.toMatchObject({ + name: "BindingError", + cause: reason, + }); + await entered.promise; + controller.abort(reason); + await rejected; + await expect(pending).rejects.toBeInstanceOf(BindingError); + expect(cancelled).toBe(1); + expect(request.body!.locked).toBe(false); + }, + ); + + it("does not pull a body after its request was already aborted", async () => { + const controller = new AbortController(); + const reason = new Error("aborted before reading"); + let cancelled = 0, + pulled = 0; + const request = new Request("https://askr.test/", { + method: "POST", + duplex: "half", + signal: controller.signal, + body: new ReadableStream( + { + pull() { + pulled++; + }, + cancel(value) { + expect(value).toBe(reason); + cancelled++; + }, + }, + { highWaterMark: 0 }, + ), + } as RequestInit); + controller.abort(reason); + await expect(readRequestBytes(request, 4)).rejects.toBe(reason); + expect(pulled).toBe(0); + expect(cancelled).toBe(1); + expect(request.body!.locked).toBe(false); + }); + + it("rejects a cached body after abort without canceling an already consumed stream", async () => { + const controller = new AbortController(); + const request = new Request("https://askr.test/", { + method: "POST", + body: "1234", + signal: controller.signal, + }); + await readRequestBytes(request, 4); + const reason = new Error("aborted after reading"); + controller.abort(reason); + await expect(readRequestBytes(request, 4)).rejects.toBe(reason); + expect(request.body!.locked).toBe(false); + }); + + it.each([4, 5])("bounds chunked bodies without Content-Length at %i bytes", async (length) => { + const request = new Request("https://askr.test/", { + method: "POST", + duplex: "half", + headers: { "content-type": "application/json" }, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("null")); + if (length > 4) controller.enqueue(new Uint8Array([32])); + controller.close(); + }, + }), + } as RequestInit); + expect(request.headers.has("content-length")).toBe(false); + if (length === 4) expect(decoder.decode(await readRequestBytes(request, 4))).toBe("null"); + else await expect(readRequestBytes(request, 4)).rejects.toBeInstanceOf(PayloadTooLargeError); + }); + + it("honors a stricter limit when a body has already been buffered, without poisoning later reads", async () => { + const request = new Request("https://askr.test/", { method: "POST", body: "1234" }); + const first = await readRequestBytes(request, 4); + await expect(readRequestBytes(request, 3)).rejects.toBeInstanceOf(PayloadTooLargeError); + expect(await readRequestBytes(request, 4)).toBe(first); + }); + + it.each(["throws", "never settles"])( + "reports oversized body promptly when cancellation %s", + async (mode) => { + const cancel = deferred(); + let cancellations = 0, + failure: unknown; + const request = new Request("https://askr.test/", { + method: "POST", + duplex: "half", + body: new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(5)); + }, + cancel() { + cancellations++; + if (mode === "throws") throw new Error("cancel failed"); + return cancel.promise; + }, + }), + } as RequestInit); + const pending = readRequestBytes(request, 4); + void pending.catch((error: unknown) => { + failure = error; + }); + try { + await expect.poll(() => failure).toBeInstanceOf(PayloadTooLargeError); + expect(request.body!.locked).toBe(false); + expect(cancellations).toBe(1); + } finally { + cancel.resolve(); + await pending.catch(() => {}); + } + }, + ); + + it.each(["throws", "never settles"])( + "aborts a stalled body read when cancellation %s", + async (mode) => { + const entered = deferred(), + cancel = deferred(); + const controller = new AbortController(); + const reason = new Error("request aborted"); + let source!: ReadableStreamDefaultController; + let cancellations = 0, + failure: unknown; + const request = new Request("https://askr.test/", { + method: "POST", + duplex: "half", + signal: controller.signal, + body: new ReadableStream({ + start(value) { + source = value; + }, + pull() { + entered.resolve(); + }, + cancel() { + cancellations++; + if (mode === "throws") throw new Error("cancel failed"); + return cancel.promise; + }, + }), + } as RequestInit); + const pending = readRequestBytes(request, 4); + void pending.catch((error: unknown) => { + failure = error; + }); + await entered.promise; + controller.abort(reason); + try { + await expect.poll(() => failure).toBe(reason); + expect(cancellations).toBe(1); + expect(request.body!.locked).toBe(false); + } finally { + if (cancellations === 0) source.close(); + cancel.resolve(); + await pending.catch(() => {}); + } + }, + ); +}); + +describe("owned response lifetime", () => { + it.each(["global", "route"])( + "retires a synchronous %s continuation before queued work runs", + async (kind) => { + const attempted = deferred(); + let calls = 0; + const middleware: Middleware = (_context, next) => { + queueMicrotask(() => { + try { + void next(); + attempted.resolve(undefined); + } catch (error) { + attempted.resolve(error); + } + }); + return new Response("short circuit"); + }; + const app = createServerApp({ + middleware: kind === "global" ? [middleware] : [], + routes: [ + { + path: "/", + middleware: kind === "route" ? [middleware] : [], + handler: () => { + calls++; + return new Response("unreachable"); + }, + }, + ], + }); + expect(await (await app.fetch(new Request("https://askr.test/"))).text()).toBe( + "short circuit", + ); + expect(await attempted.promise).toBeInstanceOf(Error); + expect(calls).toBe(0); + }, + ); + + it("isolates failed error handlers across overlapping requests", async () => { + const failed = deferred(); + const reason = new Error("first error handler failed"); + const calls: string[] = []; + const app = createServerApp({ + routes: [ + { + path: "/{id}", + handler: () => { + throw new Error("route failed"); + }, + }, + ], + middleware: [(_context, next) => next()], + onError: async (_error, context) => { + calls.push(context.params.id!); + if (context.params.id === "one") { + failed.resolve(); + throw reason; + } + await failed.promise; + return new Response("recovered two", { status: 503 }); + }, + }); + const results = await Promise.allSettled( + ["one", "two"].map((id) => app.fetch(new Request(`https://askr.test/${id}`))), + ); + expect(results[0]).toEqual({ status: "rejected", reason }); + expect(results[1]!.status).toBe("fulfilled"); + if (results[1]!.status === "fulfilled") + expect(await results[1].value.text()).toBe("recovered two"); + expect(calls.sort()).toEqual(["one", "two"]); + }); + + it.each( + [undefined, null, "invalid", {}].flatMap((value) => + [false, true].map((global) => ({ value, global })), + ), + )( + "rejects an invalid error-handler result $value with global middleware $global", + async ({ value, global }) => { + let calls = 0; + const app = createServerApp({ + routes: [ + { + path: "/", + handler: () => { + throw new Error("route failed"); + }, + }, + ], + middleware: global ? [(_context, next) => next(), (_context, next) => next()] : [], + onError: (() => { + calls++; + return value; + }) as (error: unknown) => Response, + }); + await expect(app.fetch(new Request("https://askr.test/"))).rejects.toThrow( + /onError.*Response/u, + ); + expect(calls).toBe(1); + }, + ); + + it.each([false, true])( + "preserves an error-handler failure exactly once when async is %s", + async (asynchronous) => { + let calls = 0; + const reason = new Error("error handler failed"); + const app = createServerApp({ + routes: [ + { + path: "/", + handler: () => { + throw new Error("route failed"); + }, + }, + ], + middleware: [(_context, next) => next(), (_context, next) => next()], + onError: () => { + calls++; + if (asynchronous) return Promise.reject(reason); + throw reason; + }, + }); + await expect(app.fetch(new Request("https://askr.test/"))).rejects.toBe(reason); + expect(calls).toBe(1); + }, + ); + + it.each(["same response", "same body", "clone"])( + "preserves the delivered stream when middleware returns the %s", + async (mode) => { + let cancelled = 0; + const app = createServerApp({ + routes: [ + { + path: "/", + handler: () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("delivered")); + controller.close(); + }, + cancel() { + cancelled++; + }, + }), + ), + }, + ], + middleware: [ + async (_context, next) => { + const response = await next(); + return mode === "same response" + ? response + : mode === "same body" + ? new Response(response.body) + : response.clone(); + }, + ], + }); + expect(await (await app.fetch(new Request("https://askr.test/"))).text()).toBe("delivered"); + expect(cancelled).toBe(0); + }, + ); + + it("discards a replaced downstream stream without canceling its replacement", async () => { + let discarded = 0, + replacementCancelled = 0; + const app = createServerApp({ + routes: [ + { + path: "/", + handler: () => + new Response( + new ReadableStream({ + cancel() { + discarded++; + }, + }), + ), + }, + ], + middleware: [ + async (_context, next) => { + await next(); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("replacement")); + controller.close(); + }, + cancel() { + replacementCancelled++; + }, + }), + ); + }, + ], + }); + expect(await (await app.fetch(new Request("https://askr.test/"))).text()).toBe("replacement"); + await expect.poll(() => discarded).toBe(1); + expect(replacementCancelled).toBe(0); + }); + + it.each([null, "healthy", 1, {}])( + "fails closed when a probe returns invalid result %j", + async (value) => { + const app = createServerApp({ probes: { readyz: (() => value) as ProbeHandler } }); + const response = await app.fetch(new Request("https://askr.test/readyz")); + expect(response.status).toBe(503); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect((await app.fetch(new Request("https://askr.test/livez"))).status).toBe(200); + }, + ); + + it.each(["global", "route"])( + "retires the %s middleware continuation after return", + async (kind) => { + let saved!: Next, + calls = 0; + const middleware: Middleware = (_context, next) => { + saved = next; + return new Response("short circuit"); + }; + const app = createServerApp({ + middleware: kind === "global" ? [middleware] : [], + routes: [ + { + path: "/", + middleware: kind === "route" ? [middleware] : [], + handler: () => { + calls++; + return new Response("must not run"); + }, + }, + ], + }); + expect(await (await app.fetch(new Request("https://askr.test/"))).text()).toBe( + "short circuit", + ); + await expect(Promise.resolve().then(() => saved())).rejects.toThrow(/next\(\).*middleware/iu); + expect(calls).toBe(0); + }, + ); + + it.each([undefined, null, "invalid", {}])( + "does not execute a denied route when the denial handler returns %j", + async (value) => { + let calls = 0, + observed: unknown; + const app = createServerApp({ + routes: [ + { + path: "/", + auth: () => ({ allowed: false, reason: "forbidden" }), + handler: () => { + calls++; + return new Response("secret"); + }, + }, + ], + onAccessDenied: (() => value) as AccessDeniedHandler, + onError: (error) => { + observed = error; + return new Response("handled", { status: 500 }); + }, + }); + const response = await app.fetch(new Request("https://askr.test/")); + expect(response?.status).toBe(500); + expect(calls).toBe(0); + expect(observed).toBeInstanceOf(TypeError); + expect((observed as Error).message).toMatch(/denial.*Response/iu); + }, + ); + + it.each(["succeeds", "throws", "never settles"])( + "discards the suppressed HEAD body when cancellation %s", + async (mode) => { + let cancelled = 0; + const app = createServerApp({ + routes: [ + { + path: "/", + handler: () => + new Response( + new ReadableStream({ + cancel() { + cancelled++; + if (mode === "throws") throw new Error("cancel failed"); + if (mode === "never settles") return new Promise(() => undefined); + }, + }), + { headers: { "x-source": "handler" } }, + ), + }, + ], + }); + const response = await app.fetch(new Request("https://askr.test/", { method: "HEAD" })); + expect(response.body).toBeNull(); + expect(response.headers.get("x-source")).toBe("handler"); + await expect.poll(() => cancelled).toBe(1); + expect((await app.fetch(new Request("https://askr.test/missing"))).status).toBe(404); + }, + ); + + it("discards a downstream response when middleware throws after next", async () => { + let cancelled = 0; + const reason = new Error("middleware failed"); + const app = createServerApp({ + routes: [ + { + path: "/", + handler: () => + new Response( + new ReadableStream({ + cancel() { + cancelled++; + }, + }), + ), + }, + ], + middleware: [ + async (_context, next) => { + await next(); + throw reason; + }, + ], + onError: (error) => { + expect(error).toBe(reason); + return new Response("handled", { status: 500 }); + }, + }); + expect(await (await app.fetch(new Request("https://askr.test/"))).text()).toBe("handled"); + await expect.poll(() => cancelled).toBe(1); + }); + + it("discards a late downstream response after middleware has already failed", async () => { + const entered = deferred(), + reply = deferred(); + let cancelled = 0; + const app = createServerApp({ + routes: [ + { + path: "/", + handler: () => { + entered.resolve(); + return reply.promise; + }, + }, + ], + middleware: [ + async (_context, next) => { + void next(); + await entered.promise; + throw new Error("failed first"); + }, + ], + onError: () => new Response("handled", { status: 500 }), + }); + expect((await app.fetch(new Request("https://askr.test/"))).status).toBe(500); + reply.resolve( + new Response( + new ReadableStream({ + cancel() { + cancelled++; + }, + }), + ), + ); + await expect.poll(() => cancelled).toBe(1); + }); + + it.each( + [undefined, null, "invalid", {}].flatMap((value) => + ["route", "fallback"].flatMap((kind) => + [false, true].map((global) => ({ value, kind, global })), + ), + ), + )( + "rejects an invalid $kind result $value with global middleware $global", + async ({ value, kind, global }) => { + const handler = (() => value) as Handler; + let observed: unknown; + const middleware: Middleware[] = global ? [(_context, next) => next()] : []; + const app = createServerApp({ + ...(kind === "route" ? { routes: [{ path: "/", handler }] } : { fallback: handler }), + middleware, + onError: (error) => { + observed = error; + return new Response("handled", { status: 500 }); + }, + }); + const response = await app.fetch(new Request("https://askr.test/")); + expect(response?.status).toBe(500); + expect(observed).toBeInstanceOf(TypeError); + expect((observed as Error).message).toMatch(/handler.*Response/iu); + }, + ); +}); diff --git a/tests/package-artifacts.js b/tests/package-artifacts.js index 33ac3fc..418fbdd 100644 --- a/tests/package-artifacts.js +++ b/tests/package-artifacts.js @@ -21,7 +21,12 @@ if (JSON.stringify(dependencies) !== JSON.stringify(allowedDependencies)) { } const packedFiles = new Set(result.files.map(({ path }) => normalize(path))); -for (const expected of ["dist/testing.js", "dist/testing.d.ts"]) { +for (const expected of [ + "dist/testing.js", + "dist/testing.d.ts", + "CHANGELOG.md", + "docs/0.5.0-hardening.md", +]) { if (!packedFiles.has(normalize(expected))) { throw new Error(`Packed artifact is missing ${expected}.`); } @@ -37,6 +42,8 @@ for (const file of packedFiles) { file !== normalize("LICENSE") && file !== normalize("README.md") && file !== normalize("package.json") && + file !== normalize("CHANGELOG.md") && + file !== normalize("docs/0.5.0-hardening.md") && !file.startsWith(`${normalize("dist")}\\`) && !file.startsWith(`${normalize("dist")}/`) ) { diff --git a/tests/package-consumers.js b/tests/package-consumers.js new file mode 100644 index 0000000..aae83d1 --- /dev/null +++ b/tests/package-consumers.js @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readPackRecord } from "./pack-result.js"; + +const npmCli = process.env.npm_execpath; +if (!npmCli) throw new Error("npm_execpath is unavailable; run this check through npm"); +const root = process.cwd(); +const npm = (args, options) => execFileSync(process.execPath, [npmCli, ...args], options); +const manifest = JSON.parse(await readFile("package.json", "utf8")); +const directory = await mkdtemp(join(tmpdir(), "askr-server-consumer-")); +try { + const packed = readPackRecord( + JSON.parse( + npm(["pack", "--ignore-scripts", "--json", "--pack-destination", directory], { + encoding: "utf8", + }), + ), + ); + await writeFile( + join(directory, "package.json"), + JSON.stringify({ private: true, type: "module" }), + ); + npm( + [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--no-package-lock", + join(directory, packed.filename), + `@askrjs/askr@${manifest.devDependencies["@askrjs/askr"]}`, + ], + { cwd: directory, stdio: "pipe" }, + ); + const installed = JSON.parse( + await readFile(join(directory, "node_modules/@askrjs/server/package.json"), "utf8"), + ); + assert.deepEqual(installed.exports, manifest.exports); + const fixture = (await readFile("tests/types/public-contract.ts", "utf8")).replace( + /"\.\.\/\.\.\/dist\/([^"/]+)\.js"/gu, + (_match, entry) => JSON.stringify(`@askrjs/server${entry === "index" ? "" : `/${entry}`}`), + ); + await writeFile(join(directory, "contract.ts"), fixture); + await writeFile( + join(directory, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext", + strict: true, + noEmit: true, + types: [], + lib: ["ES2022", "DOM", "DOM.Iterable"], + }, + files: ["contract.ts"], + }), + ); + execFileSync( + process.execPath, + [join(root, "node_modules/typescript/bin/tsc"), "-p", "tsconfig.json"], + { + cwd: directory, + stdio: "pipe", + }, + ); + const entrypoints = Object.keys(manifest.exports).filter((key) => key !== "./package.json"); + await writeFile( + join(directory, "runtime.js"), + ` + for (const entry of ${JSON.stringify(entrypoints)}) + await import('@askrjs/server' + (entry === '.' ? '' : entry.slice(1))); + ${await readFile("tests/packed-runtime.js", "utf8")} + `, + ); + execFileSync(process.execPath, [join(directory, "runtime.js")], { + cwd: directory, + stdio: "pipe", + timeout: 20_000, + }); + console.log( + JSON.stringify({ + normalInstall: true, + publicEntrypoints: entrypoints.length, + strictTypeScript: true, + runtime: [ + "HEAD disposal", + "replacement disposal", + "denial guard", + "probe guard", + "error handler failure", + "aborted body", + ], + }), + ); +} finally { + await rm(directory, { recursive: true, force: true }); +} diff --git a/tests/packed-runtime.js b/tests/packed-runtime.js new file mode 100644 index 0000000..bb9c4b9 --- /dev/null +++ b/tests/packed-runtime.js @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { createRouter, createServerApp } from "@askrjs/server"; + +let cancelled = 0; +const stream = () => + new Response( + new ReadableStream({ + cancel() { + cancelled++; + }, + }), + ); +const head = createServerApp(createRouter().get("/", stream)); +assert.equal((await head.fetch(new Request("https://askr.test/", { method: "HEAD" }))).body, null); +assert.equal(cancelled, 1); + +const replaced = createServerApp({ + router: createRouter().get("/", stream), + middleware: [ + async (_context, next) => { + await next(); + return new Response("replacement"); + }, + ], +}); +assert.equal(await (await replaced.fetch(new Request("https://askr.test/"))).text(), "replacement"); +assert.equal(cancelled, 2); + +let deniedCalls = 0; +const denied = createServerApp({ + router: createRouter().get( + "/", + () => { + deniedCalls++; + return new Response("secret"); + }, + { + auth: () => ({ allowed: false, reason: "forbidden" }), + }, + ), + onAccessDenied: () => undefined, +}); +assert.equal((await denied.fetch(new Request("https://askr.test/"))).status, 500); +assert.equal(deniedCalls, 0); +const probe = createServerApp({ probes: { readyz: () => "invalid" } }); +assert.equal((await probe.fetch(new Request("https://askr.test/readyz"))).status, 503); + +let errorCalls = 0; +const reason = new Error("onError failure"); +const failure = createServerApp({ + router: createRouter().get("/", () => { + throw new Error("route failure"); + }), + middleware: [(_context, next) => next()], + onError: () => { + errorCalls++; + throw reason; + }, +}); +await assert.rejects(failure.fetch(new Request("https://askr.test/")), (error) => error === reason); +assert.equal(errorCalls, 1); + +let entered; +const pulling = new Promise((resolve) => { + entered = resolve; +}); +const controller = new AbortController(); +const aborted = new Error("request aborted"); +const body = new ReadableStream({ + pull() { + entered(); + }, + cancel(value) { + assert.equal(value, aborted); + cancelled++; + return new Promise(() => {}); + }, +}); +const request = new Request("https://askr.test/", { + method: "POST", + signal: controller.signal, + body, + headers: { "content-type": "application/json" }, + duplex: "half", +}); +const binding = createServerApp({ + router: createRouter().post("/", async (context) => context.json(await context.bind())), +}); +const pending = binding.fetch(request); +await pulling; +controller.abort(aborted); +const abortedResponse = await pending; +assert.equal(abortedResponse.status, 400); +assert.equal((await abortedResponse.json()).detail, "Request body could not be read."); +assert.equal(cancelled, 3); +assert.equal(request.body.locked, false); +for (const path of ["response-body", "dist/index.js"]) + await assert.rejects(import(`@askrjs/server/${path}`), { code: "ERR_PACKAGE_PATH_NOT_EXPORTED" }); diff --git a/tsconfig.benchmarks.json b/tsconfig.benchmarks.json new file mode 100644 index 0000000..71fc689 --- /dev/null +++ b/tsconfig.benchmarks.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "types": ["node"] + }, + "include": ["src/**/*.ts", "benches/**/*.ts"] +} diff --git a/vitest.test.unit.config.ts b/vitest.test.unit.config.ts index f7f3ccc..d2a7735 100644 --- a/vitest.test.unit.config.ts +++ b/vitest.test.unit.config.ts @@ -3,5 +3,20 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ test: { include: ["tests/**/*.test.ts"], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + reporter: ["text", "json-summary"], + thresholds: { + statements: 87, + branches: 80, + functions: 86, + lines: 89, + "src/application.ts": { statements: 96, branches: 96, functions: 90, lines: 96 }, + "src/body-limit.ts": { statements: 91, branches: 76, functions: 100, lines: 91 }, + "src/dispatch.ts": { statements: 92, branches: 89, functions: 95, lines: 94 }, + "src/response-body.ts": { statements: 100, functions: 100, lines: 100 }, + }, + }, }, });