diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index 5885a62bbb..0000000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM mcr.microsoft.com/devcontainers/typescript-node:20 - -# Install Deno -ENV DENO_INSTALL=/usr/local -RUN curl -fsSL https://deno.land/install.sh | sh - -# Install Bun -ENV BUN_INSTALL=/usr/local -RUN curl -fsSL https://bun.sh/install | bash - -WORKDIR /hono diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index ceab567e52..0000000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "build": { - "dockerfile": "Dockerfile" - }, - "containerEnv": { - "HOME": "/home/node" - }, - "customizations": { - "vscode": { - "settings": { - "deno.enable": false, - "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], - "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" - } - }, - "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] - } - } -} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml deleted file mode 100644 index 5abeecfa71..0000000000 --- a/.devcontainer/docker-compose.yml +++ /dev/null @@ -1,16 +0,0 @@ -services: - hono: - build: . - container_name: hono - volumes: - - ../:/hono - networks: - - hono - command: bash - stdin_open: true - tty: true - restart: 'no' - -networks: - hono: - driver: bridge diff --git a/.github/actions/perf-measures/action.yml b/.github/actions/perf-measures/action.yml index 69e35fca6c..0d4532ea46 100644 --- a/.github/actions/perf-measures/action.yml +++ b/.github/actions/perf-measures/action.yml @@ -10,8 +10,7 @@ inputs: runs: using: 'composite' steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version-file: '.tool-versions' - run: | @@ -45,7 +44,7 @@ runs: - name: Run octocov if: ${{ inputs.target-ref == 'auto' }} - uses: k1LoW/octocov-action@v1 + uses: k1LoW/octocov-action@b3b6ee60482a667950f87553abf1df63217235d9 # v1 with: config: perf-measures/.octocov.consolidated.perf-measures.yml env: @@ -55,7 +54,7 @@ runs: - name: Run octocov with custom target ref if: ${{ inputs.target-ref == 'main' }} - uses: k1LoW/octocov-action@v1 + uses: k1LoW/octocov-action@b3b6ee60482a667950f87553abf1df63217235d9 # v1 with: config: perf-measures/.octocov.consolidated.perf-measures.main.yml env: diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 2626de8997..23907f086d 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -19,14 +19,14 @@ jobs: if: ${{ github.event_name == 'push' || !github.event.pull_request.draft }} steps: - name: Checkout - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile - run: bun run format:fix - run: bun run lint:fix - name: Apply fixes - uses: autofix-ci/action@v1 + uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1 with: commit-message: 'ci: apply automated fixes' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b6c6a9ca5..5627ae15c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,9 @@ on: - '.gitignore' - 'LICENSE' +permissions: + contents: read + jobs: coverage: name: 'Coverage' @@ -20,13 +23,13 @@ jobs: - bun - deno steps: - - uses: actions/checkout@v6 - - uses: actions/download-artifact@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: pattern: coverage-* merge-multiple: true path: ./coverage - - uses: codecov/codecov-action@v5 + - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: fail_ci_if_error: true directory: ./coverage @@ -35,11 +38,11 @@ jobs: name: 'Main' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: '.tool-versions' - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile @@ -48,7 +51,7 @@ jobs: - run: bun run editorconfig-checker -format github-actions - run: bun run build - run: bun run test - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: coverage-main path: coverage/ @@ -57,11 +60,11 @@ jobs: name: "Checking if it's valid for JSR" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: denoland/setup-deno@v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2 with: deno-version-file: '.tool-versions' - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version-file: '.tool-versions' - run: bunx jsr publish --dry-run @@ -70,8 +73,8 @@ jobs: name: 'Deno' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: denoland/setup-deno@v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2 with: deno-version-file: '.tool-versions' - run: env NAME=Deno deno test --coverage=coverage/raw/deno-runtime --allow-read --allow-env --allow-write --allow-net -c runtime-tests/deno/deno.json runtime-tests/deno @@ -79,7 +82,7 @@ jobs: - run: deno test -c runtime-tests/deno-jsx/deno.react-jsx.json --coverage=coverage/raw/deno-react-jsx runtime-tests/deno-jsx - run: grep -R '"url":' coverage | grep -v runtime-tests | sed -e 's/.*file:..//;s/.,//' | xargs deno cache --unstable-sloppy-imports - run: deno coverage --lcov > coverage/deno-runtime-coverage-lcov.info - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: coverage-deno path: coverage/ @@ -88,13 +91,13 @@ jobs: name: 'Bun' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile - run: bun run test:bun - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: coverage-bun path: coverage/ @@ -103,8 +106,8 @@ jobs: name: 'Bun - Windows' runs-on: windows-latest steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version-file: '.tool-versions' - run: bun run test:bun @@ -113,8 +116,8 @@ jobs: name: 'Fastly Compute' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile @@ -126,13 +129,13 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: ['18.18.2', '20.x', '22.x'] + node: ['20.x', '22.x', '24.x'] steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ matrix.node }} - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile @@ -143,11 +146,11 @@ jobs: name: 'workerd' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: '.tool-versions' - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile @@ -158,8 +161,8 @@ jobs: name: 'AWS Lambda' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile @@ -170,8 +173,8 @@ jobs: name: 'Lambda@Edge' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile @@ -183,7 +186,7 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: ./.github/actions/perf-measures with: target-ref: 'auto' @@ -192,9 +195,12 @@ jobs: name: 'HTTP Speed Check on PR' runs-on: ubuntu-latest if: github.event_name == 'pull_request' + permissions: + contents: read + pull-requests: write steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile @@ -208,7 +214,7 @@ jobs: cd benchmarks/http-server bun run benchmark.ts - name: Comment PR - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 if: github.event.pull_request.head.repo.full_name == github.repository with: script: | @@ -255,7 +261,7 @@ jobs: runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: ./.github/actions/perf-measures with: target-ref: 'main' diff --git a/.github/workflows/cr.yml b/.github/workflows/cr.yml index dfb946839a..323d63ffe2 100644 --- a/.github/workflows/cr.yml +++ b/.github/workflows/cr.yml @@ -10,21 +10,28 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.number }} # Concurrency group for each PR cancel-in-progress: true # Cancel in progress builds for the same PR +permissions: + contents: read + jobs: publish: if: github.repository == 'honojs/hono' && (github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'cr-tracked')) runs-on: ubuntu-latest name: 'Publish: pkg.pr.new' + permissions: + contents: read + id-token: write + pull-requests: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: '.tool-versions' - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version-file: '.tool-versions' diff --git a/.github/workflows/no-response.yml b/.github/workflows/no-response.yml index 2d317689f2..a98c97a204 100644 --- a/.github/workflows/no-response.yml +++ b/.github/workflows/no-response.yml @@ -5,7 +5,7 @@ on: - cron: '0 0 * * *' permissions: - contents: write + contents: read issues: write jobs: @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Close stale issues with "not bug" label - uses: actions/stale@v8 + uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8 with: days-before-stale: 7 days-before-close: 2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dea98526b2..ca4ef72cd8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,33 @@ on: tags: - '*' +permissions: + contents: read + jobs: + npm: + name: publish-to-npm + runs-on: ubuntu-latest + + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version-file: '.tool-versions' + registry-url: 'https://registry.npmjs.org' + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version-file: '.tool-versions' + - run: npm install -g npm@11.17.0 + - run: bun install --frozen-lockfile + - run: bun run build + - name: Publish to npm + run: npm publish --provenance --access public + jsr: name: publish-to-jsr runs-on: ubuntu-latest @@ -15,9 +41,9 @@ jobs: id-token: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install deno - uses: denoland/setup-deno@v2 + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2 with: deno-version-file: '.tool-versions' - run: deno install --no-lock --allow-scripts diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index e6f3581da5..0000000000 --- a/.gitpod.yml +++ /dev/null @@ -1,9 +0,0 @@ -tasks: - - name: Setup - init: bun install --frozen-lockfile -image: - file: ./.devcontainer/Dockerfile -vscode: - extensions: - - oven.bun-vscode - - vitest.explorer diff --git a/.vitest.config/setup-vitest.ts b/.vitest.config/setup-vitest.ts deleted file mode 100644 index d81a616924..0000000000 --- a/.vitest.config/setup-vitest.ts +++ /dev/null @@ -1,47 +0,0 @@ -import * as nodeCrypto from 'node:crypto' -import { vi } from 'vitest' - -/** - * crypto - */ -if (!globalThis.crypto) { - vi.stubGlobal('crypto', nodeCrypto) - vi.stubGlobal('CryptoKey', nodeCrypto.webcrypto.CryptoKey) -} - -/** - * Cache API - */ -type StoreMap = Map - -class MockCache { - name: string - store: StoreMap - - constructor(name: string, store: StoreMap) { - this.name = name - this.store = store - } - - async match(key: Request | string): Promise { - return this.store.get(key) || null - } - - async keys() { - return this.store.keys() - } - - async put(key: Request | string, response: Response): Promise { - this.store.set(key, response) - } -} - -const globalStore: Map = new Map() - -const caches = { - open: (name: string) => { - return new MockCache(name, globalStore) - }, -} - -vi.stubGlobal('caches', caches) diff --git a/benchmarks/routers-deno/README.md b/benchmarks/routers-deno/README.md index 9d358408fd..f42e032325 100644 --- a/benchmarks/routers-deno/README.md +++ b/benchmarks/routers-deno/README.md @@ -18,7 +18,7 @@ For Deno: deno run --allow-read --allow-run src/bench.mts ``` -This project is heavily impaired by [delvedor/router-benchmark](https://github.com/delvedor/router-benchmark) +This project is heavily inspired by [delvedor/router-benchmark](https://github.com/delvedor/router-benchmark) ## License diff --git a/benchmarks/routers/README.md b/benchmarks/routers/README.md index db17ded624..46904bc691 100644 --- a/benchmarks/routers/README.md +++ b/benchmarks/routers/README.md @@ -31,7 +31,7 @@ For Bun: bun run bench:bun ``` -This project is heavily impaired by [delvedor/router-benchmark](https://github.com/delvedor/router-benchmark) +This project is heavily inspired by [delvedor/router-benchmark](https://github.com/delvedor/router-benchmark) ## License diff --git a/build/build.ts b/build/build.ts index 0ce1a5f58d..63ee9496ae 100644 --- a/build/build.ts +++ b/build/build.ts @@ -5,23 +5,17 @@ Copyright (c) 2022 Taishi Naritomi */ -/// +/// -import arg from 'arg' -import { $ } from 'bun' +import { $, Glob } from 'bun' import { build, context } from 'esbuild' import type { Plugin, PluginBuild, BuildOptions } from 'esbuild' -import * as glob from 'glob' import fs from 'fs' import path from 'path' import { removePrivateFields } from './remove-private-fields' import { validateExports } from './validate-exports' -const args = arg({ - '--watch': Boolean, -}) - -const isWatch = args['--watch'] || false +const isWatch = process.argv.includes('--watch') const readJsonExports = (path: string) => JSON.parse(fs.readFileSync(path, 'utf-8')).exports @@ -31,9 +25,18 @@ const [packageJsonExports, jsrJsonExports] = ['./package.json', './jsr.json'].ma validateExports(packageJsonExports, jsrJsonExports, 'jsr.json') validateExports(jsrJsonExports, packageJsonExports, 'package.json') -const entryPoints = glob.sync('./src/**/*.ts', { - ignore: ['./src/**/*.test.ts', './src/mod.ts', './src/middleware.ts', './src/deno/**/*.ts'], -}) +const ignorePatterns = [ + 'src/**/*.test.ts', + 'src/mod.ts', + 'src/middleware.ts', + 'src/deno/**/*.ts', +].map((pattern) => new Glob(pattern)) +const entryPoints: string[] = [] +for await (const file of new Glob('src/**/*.ts').scan('.')) { + if (!ignorePatterns.some((ignore) => ignore.match(file))) { + entryPoints.push(file) + } +} /* This plugin is inspired by the following. @@ -101,10 +104,13 @@ await Promise.all([ runBuild(esmConfig), runBuild(cjsConfig), $`tsc ${ - isWatch ? '-w' : '' + isWatch ? ['-w'] : [] } --emitDeclarationOnly --declaration --project tsconfig.build.json`.nothrow(), ]) // Remove #private fields -const dtsEntries = glob.globSync('./dist/types/**/*.d.ts') +const dtsEntries: string[] = [] +for await (const file of new Glob('dist/types/**/*.d.ts').scan('.')) { + dtsEntries.push(file) +} await removePrivateFields(dtsEntries) diff --git a/bun.lock b/bun.lock index 7a8e868672..d36d6264ad 100644 --- a/bun.lock +++ b/bun.lock @@ -6,19 +6,16 @@ "name": "hono", "devDependencies": { "@hono/eslint-config": "^2.1.0", - "@hono/node-server": "^1.13.5", - "@types/glob": "^9.0.0", + "@hono/node-server": "^2.0.2", "@types/jsdom": "^21.1.7", "@types/node": "^24.3.0", "@types/ws": "^8.18.1", "@typescript/native-preview": "7.0.0-dev.20260210.1", - "@vitest/coverage-v8": "^3.2.4", - "arg": "^5.0.2", + "@vitest/coverage-v8": "^4.1.7", "bun-types": "^1.2.20", "editorconfig-checker": "6.1.1", "esbuild": "^0.27.1", "eslint": "^9.39.3", - "glob": "^11.0.0", "jsdom": "22.1.0", "msw": "^2.6.0", "np": "10.2.0", @@ -29,7 +26,7 @@ "typescript": "^5.9.2", "undici": "^6.21.3", "vite-plugin-fastly-js-compute": "^0.4.2", - "vitest": "^3.2.4", + "vitest": "^4.1.7", "wrangler": "4.12.0", "ws": "^8.18.0", "zod": "^3.23.8", @@ -37,19 +34,17 @@ }, }, "packages": { - "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], - "@babel/code-frame": ["@babel/code-frame@7.24.7", "", { "dependencies": { "@babel/highlight": "^7.24.7", "picocolors": "^1.0.0" } }, "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.24.8", "", {}, "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.24.7", "", {}, "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "@babel/highlight": ["@babel/highlight@7.24.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.24.7", "chalk": "^2.4.2", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw=="], - "@babel/parser": ["@babel/parser@7.25.6", "", { "dependencies": { "@babel/types": "^7.25.6" }, "bin": "./bin/babel-parser.js" }, "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q=="], + "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], - "@babel/types": ["@babel/types@7.25.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.24.8", "@babel/helper-validator-identifier": "^7.24.7", "to-fast-properties": "^2.0.0" } }, "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw=="], + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], @@ -179,7 +174,7 @@ "@hono/eslint-config": ["@hono/eslint-config@2.1.0", "", { "dependencies": { "@eslint/js": "^9.39.3", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import-x": "^4.16.1", "eslint-plugin-n": "^17.24.0", "typescript-eslint": "^8.56.1" }, "peerDependencies": { "eslint": "^9.0.0", "typescript": "^5.0.0" } }, "sha512-uNR7As09OPTFLj5f6JwMd5DgTkEhMMlN8kqSlHmPYDU0p3voyaDghNahckydnAdhxIyRHrZhtLkuJDmZ5aeiqg=="], - "@hono/node-server": ["@hono/node-server@1.13.5", "", { "peerDependencies": { "hono": "^4" } }, "sha512-lSo+CFlLqAFB4fX7ePqI9nauEn64wOfJHAfc9duYFTvAG3o416pC0nTGeNjuLHchLedH+XyWda5v79CVx1PIjg=="], + "@hono/node-server": ["@hono/node-server@2.0.2", "", { "peerDependencies": { "hono": "^4" } }, "sha512-tXlTi1h/4V7sDe7i97IVP+9re9ZU7wXZZggnR5ucCRclf1+AX6YhGStrR5w8bLj+3Mlyl0pKfBh9gqTqqnGKfQ=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -255,10 +250,6 @@ "@inquirer/type": ["@inquirer/type@3.0.0", "", { "peerDependencies": { "@types/node": ">=18" } }, "sha512-YYykfbw/lefC7yKj7nanzQXILM7r3suIvyFlCcMskc99axmsSewXWkAfXKwMbgxL76iAFVmRwmYdwNZNc8gjog=="], - "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], - - "@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.5", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], @@ -267,9 +258,9 @@ "@jridgewell/source-map": ["@jridgewell/source-map@0.3.6", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jsdevtools/ez-spawn": ["@jsdevtools/ez-spawn@3.0.4", "", { "dependencies": { "call-me-maybe": "^1.0.1", "cross-spawn": "^7.0.3", "string-argv": "^0.3.1", "type-detect": "^4.0.8" } }, "sha512-f5DRIOZf7wxogefH03RjMPMdBF7ADTWUMoOs9kaJo06EfwF+aFhMZMDZxHg/Xe12hptN9xoZjGso2fdjapBRIA=="], @@ -381,8 +372,6 @@ "@oxc-project/types": ["@oxc-project/types@0.96.0", "", {}, "sha512-r/xkmoXA0xEpU6UGtn18CNVjXH6erU3KCpCDbpLmbVxBFor1U9MqN5Z2uMmCHJuXjJzlnDR+hWY+yPoLo8oHDw=="], - "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="], "@pnpm/network.ca-file": ["@pnpm/network.ca-file@1.0.2", "", { "dependencies": { "graceful-fs": "4.2.10" } }, "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA=="], @@ -391,6 +380,38 @@ "@publint/pack": ["@publint/pack@0.1.2", "", {}, "sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.1", "", { "os": "linux", "cpu": "arm" }, "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.1", "", { "os": "none", "cpu": "arm64" }, "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.1", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.22.4", "", { "os": "android", "cpu": "arm" }, "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.22.4", "", { "os": "android", "cpu": "arm64" }, "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA=="], @@ -427,6 +448,8 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -439,8 +462,6 @@ "@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="], - "@types/glob": ["@types/glob@9.0.0", "", { "dependencies": { "glob": "*" } }, "sha512-00UxlRaIUvYm4R4W9WYkN8/J+kV8fmOQ7okeH6YFtGWFMt3odD45tpG5yA5wnL7HE6lLgjaTW5n14ju2hl2NNA=="], - "@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -531,21 +552,21 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], - "@vitest/coverage-v8": ["@vitest/coverage-v8@3.2.4", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", "ast-v8-to-istanbul": "^0.3.3", "debug": "^4.4.1", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, "peerDependencies": { "@vitest/browser": "3.2.4", "vitest": "3.2.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.7", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.7", "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" }, "peerDependencies": { "@vitest/browser": "4.1.7", "vitest": "4.1.7" }, "optionalPeers": ["@vitest/browser"] }, "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ=="], - "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + "@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="], - "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], + "@vitest/mocker": ["@vitest/mocker@4.1.7", "", { "dependencies": { "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA=="], - "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.7", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw=="], - "@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="], + "@vitest/runner": ["@vitest/runner@4.1.7", "", { "dependencies": { "@vitest/utils": "4.1.7", "pathe": "^2.0.3" } }, "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw=="], - "@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.7", "", { "dependencies": { "@vitest/pretty-format": "4.1.7", "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw=="], - "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + "@vitest/spy": ["@vitest/spy@4.1.7", "", {}, "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q=="], - "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + "@vitest/utils": ["@vitest/utils@4.1.7", "", { "dependencies": { "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw=="], "abab": ["abab@2.0.6", "", {}, "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA=="], @@ -563,21 +584,17 @@ "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], - "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "any-observable": ["any-observable@0.3.0", "", {}, "sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog=="], - "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "as-table": ["as-table@1.0.55", "", { "dependencies": { "printable-characters": "^1.0.42" } }, "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ=="], - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - - "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.29", "estree-walker": "^3.0.3", "js-tokens": "^9.0.1" } }, "sha512-cxrAnZNLBnQwBPByK4CeDaw5sWZtMilJE/Q3iDA0aamgaIVNDF9T6K2/8DfYDZEejZ2jNnDrG9m8MY72HFd0KA=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], @@ -617,15 +634,13 @@ "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], - "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], - "call-me-maybe": ["call-me-maybe@1.0.2", "", {}, "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], - "chai": ["chai@5.3.1", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-48af6xm9gQK8rhIcOxWwdGzIervm8BVTin+yRp9HEvU20BtVZ2lBywlIJBzwaDtvo0FvjeL7QdCADoUoqIbV3A=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -633,8 +648,6 @@ "chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="], - "check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="], - "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], "cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], @@ -671,6 +684,8 @@ "configstore": ["configstore@7.0.0", "", { "dependencies": { "atomically": "^2.0.3", "dot-prop": "^9.0.0", "graceful-fs": "^4.2.11", "xdg-basedir": "^5.1.0" } }, "sha512-yk7/5PN5im4qwz0WFZW3PXnzHgPu9mX29Y8uZ3aefe2lBPC1FYttWZRcaW9fKkT0pBCJyuQ2HfbmPVaODi9jcQ=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@0.5.0", "", {}, "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="], "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], @@ -705,8 +720,6 @@ "decompress-unzip": ["decompress-unzip@4.0.1", "", { "dependencies": { "file-type": "^3.8.0", "get-stream": "^2.2.0", "pify": "^2.3.0", "yauzl": "^2.4.2" } }, "sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw=="], - "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], - "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], @@ -731,8 +744,6 @@ "dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="], - "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "editorconfig-checker": ["editorconfig-checker@6.1.1", "", { "bin": { "ec": "dist/index.js", "editorconfig-checker": "dist/index.js" } }, "sha512-kiOb6qaWpMNt7Z/43ba0Pa1Inhr2/t9nKbvEKtCeXJ5AesztoM9AgLOOQVB4QUv/nGjgz3xkbx4pcogVRD2NWw=="], "elegant-spinner": ["elegant-spinner@1.0.1", "", {}, "sha512-B+ZM+RXvRqQaAmkMlO/oSe5nMUOaUnyfGYCEHoR8wrXsZR2mA0XVibsxV1bvTwxdRWah1PkQqso2EzhILGHtEQ=="], @@ -747,7 +758,7 @@ "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], "esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="], @@ -793,7 +804,7 @@ "exit-hook": ["exit-hook@4.0.0", "", {}, "sha512-Fqs7ChZm72y40wKjOFXBKg7nJZvQJmewP5/7LtePDdnah/+FH9Hp5sgMujSCMPXlxOAW2//1jrW9pnsY7o20vQ=="], - "expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], "exsolve": ["exsolve@1.0.5", "", {}, "sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg=="], @@ -833,8 +844,6 @@ "flatted": ["flatted@3.3.1", "", {}, "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw=="], - "foreground-child": ["foreground-child@3.3.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" } }, "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg=="], - "form-data": ["form-data@4.0.0", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww=="], "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], @@ -855,8 +864,6 @@ "github-url-from-git": ["github-url-from-git@1.5.0", "", {}, "sha512-WWOec4aRI7YAykQ9+BHmzjyNlkfJFG8QLXnDTsLz/kZefq7qkzdfo4p6fkYYMIq1aj+gZcQs/1HQhQh3DPPxlQ=="], - "glob": ["glob@11.0.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^4.0.1", "minimatch": "^10.0.0", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g=="], - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], @@ -879,7 +886,7 @@ "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], - "hono": ["hono@4.6.3", "", {}, "sha512-0LeEuBNFeSHGqZ9sNVVgZjB1V5fmhkBSB0hZrpqStSMLOWgfLy0dHOvrjbJh0H2khsjet6rbHfWTHY0kpYThKQ=="], + "hono": ["hono@4.12.18", "", {}, "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="], "hosted-git-info": ["hosted-git-info@8.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-sYKnA7eGln5ov8T8gnYlkSOxFJvywzEx9BueN6xo/GKO8PGiI6uK6xx+DIGe45T3bdVjLAQDQW1aicT8z8JwQg=="], @@ -979,13 +986,9 @@ "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], - "istanbul-lib-source-maps": ["istanbul-lib-source-maps@5.0.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" } }, "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A=="], - - "istanbul-reports": ["istanbul-reports@3.1.7", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g=="], - - "jackspeak": ["jackspeak@4.0.2", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw=="], + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], - "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1009,6 +1012,30 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "listr": ["listr@0.14.3", "", { "dependencies": { "@samverschueren/stream-to-observable": "^0.3.0", "is-observable": "^1.1.0", "is-promise": "^2.1.0", "is-stream": "^1.1.0", "listr-silent-renderer": "^1.1.1", "listr-update-renderer": "^0.5.0", "listr-verbose-renderer": "^0.5.0", "p-map": "^2.0.0", "rxjs": "^6.3.3" } }, "sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA=="], @@ -1033,13 +1060,11 @@ "log-update": ["log-update@2.3.0", "", { "dependencies": { "ansi-escapes": "^3.0.0", "cli-cursor": "^2.0.0", "wrap-ansi": "^3.0.1" } }, "sha512-vlP11XfFGyeNQlmEn9tJ66rEW1coA/79m5z6BCkudjbAGE83uhAcGYrBFwfs3AdLiLzGRusRPAbSPK9xZteCmg=="], - "loupe": ["loupe@3.2.0", "", {}, "sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw=="], - - "lru-cache": ["lru-cache@11.0.1", "", {}, "sha512-CgeuL5uom6j/ZVrg7G/+1IXqRY8JXX4Hghfy5YE0EhoYQWvndP1kufu58cmZLNIDKnRhZrXfdS9urVWx98AipQ=="], + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="], + "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], @@ -1067,8 +1092,6 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], - "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], "mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="], @@ -1109,6 +1132,8 @@ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -1143,8 +1168,6 @@ "package-json": ["package-json@10.0.1", "", { "dependencies": { "ky": "^1.2.0", "registry-auth-token": "^5.0.2", "registry-url": "^6.0.1", "semver": "^7.6.0" } }, "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg=="], - "package-json-from-dist": ["package-json-from-dist@1.0.0", "", {}, "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw=="], - "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], @@ -1157,21 +1180,17 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-scurry": ["path-scurry@2.0.0", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg=="], - "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "pathval": ["pathval@2.0.0", "", {}, "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA=="], - "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], @@ -1251,6 +1270,8 @@ "reusify": ["reusify@1.0.4", "", {}, "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="], + "rolldown": ["rolldown@1.0.1", "", { "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.1", "@rolldown/binding-darwin-arm64": "1.0.1", "@rolldown/binding-darwin-x64": "1.0.1", "@rolldown/binding-freebsd-x64": "1.0.1", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", "@rolldown/binding-linux-arm64-gnu": "1.0.1", "@rolldown/binding-linux-arm64-musl": "1.0.1", "@rolldown/binding-linux-ppc64-gnu": "1.0.1", "@rolldown/binding-linux-s390x-gnu": "1.0.1", "@rolldown/binding-linux-x64-gnu": "1.0.1", "@rolldown/binding-linux-x64-musl": "1.0.1", "@rolldown/binding-openharmony-arm64": "1.0.1", "@rolldown/binding-wasm32-wasi": "1.0.1", "@rolldown/binding-win32-arm64-msvc": "1.0.1", "@rolldown/binding-win32-x64-msvc": "1.0.1" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ=="], + "rollup": ["rollup@4.22.4", "", { "dependencies": { "@types/estree": "1.0.5" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.22.4", "@rollup/rollup-android-arm64": "4.22.4", "@rollup/rollup-darwin-arm64": "4.22.4", "@rollup/rollup-darwin-x64": "4.22.4", "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", "@rollup/rollup-linux-arm-musleabihf": "4.22.4", "@rollup/rollup-linux-arm64-gnu": "4.22.4", "@rollup/rollup-linux-arm64-musl": "4.22.4", "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", "@rollup/rollup-linux-riscv64-gnu": "4.22.4", "@rollup/rollup-linux-s390x-gnu": "4.22.4", "@rollup/rollup-linux-x64-gnu": "4.22.4", "@rollup/rollup-linux-x64-musl": "4.22.4", "@rollup/rollup-win32-arm64-msvc": "4.22.4", "@rollup/rollup-win32-ia32-msvc": "4.22.4", "@rollup/rollup-win32-x64-msvc": "4.22.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A=="], "rrweb-cssom": ["rrweb-cssom@0.6.0", "", {}, "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw=="], @@ -1317,7 +1338,7 @@ "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], - "std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="], + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], @@ -1329,13 +1350,9 @@ "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - "strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], - - "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-dirs": ["strip-dirs@2.1.0", "", { "dependencies": { "is-natural-number": "^4.0.1" } }, "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g=="], @@ -1343,8 +1360,6 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "strip-literal": ["strip-literal@3.0.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA=="], - "stubborn-fs": ["stubborn-fs@1.2.5", "", {}, "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -1363,28 +1378,20 @@ "terser": ["terser@5.33.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-JuPVaB7s1gdFKPKTelwUyRq5Sid2A3Gko2S0PncwdBq7kN9Ti9HPWDQ06MPsEDGsZeVESjKEnyGy68quBk1w6g=="], - "test-exclude": ["test-exclude@7.0.1", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^9.0.4" } }, "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg=="], - "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], - "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], - - "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], - - "tinyspy": ["tinyspy@4.0.3", "", {}, "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], "tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], "to-buffer": ["to-buffer@1.1.1", "", {}, "sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg=="], - "to-fast-properties": ["to-fast-properties@2.0.0", "", {}, "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog=="], - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="], @@ -1449,11 +1456,9 @@ "vite": ["vite@5.4.7", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ=="], - "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], - "vite-plugin-fastly-js-compute": ["vite-plugin-fastly-js-compute@0.4.2", "", { "dependencies": { "@fastly/js-compute": "^3.7.3", "node-fetch": "^3.3.2", "vite": "^5.0.10" } }, "sha512-Z7jtm6fyrtK4sxKWcsIvnV8W+WBLawUzYgZYXpXYSaoy8hcMheKhswop5MdRiucT4UQUHI3xkjoQuKOPr/DWEg=="], - "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], + "vitest": ["vitest@4.1.7", "", { "dependencies": { "@vitest/expect": "4.1.7", "@vitest/mocker": "4.1.7", "@vitest/pretty-format": "4.1.7", "@vitest/runner": "4.1.7", "@vitest/snapshot": "4.1.7", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.7", "@vitest/browser-preview": "4.1.7", "@vitest/browser-webdriverio": "4.1.7", "@vitest/coverage-istanbul": "4.1.7", "@vitest/coverage-v8": "4.1.7", "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA=="], "w3c-xmlserializer": ["w3c-xmlserializer@4.0.0", "", { "dependencies": { "xml-name-validator": "^4.0.0" } }, "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw=="], @@ -1481,9 +1486,7 @@ "wrangler": ["wrangler@4.12.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.3.1", "blake3-wasm": "2.1.5", "esbuild": "0.25.2", "miniflare": "4.20250416.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.15", "workerd": "1.20250416.0" }, "optionalDependencies": { "fsevents": "~2.3.2", "sharp": "^0.33.5" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250415.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-4rfAXOi5KqM3ECvOrZJ97k3zEqxVwtdt4bijd8jcRBZ6iJYvEtjgjVi4TsfkVa/eXGhpfHTUnKu2uk8UHa8M2w=="], - "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - - "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -1519,6 +1522,8 @@ "@babel/code-frame/picocolors": ["picocolors@1.1.0", "", {}, "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="], + "@babel/highlight/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.24.7", "", {}, "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w=="], + "@babel/highlight/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], "@babel/highlight/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -1547,10 +1552,6 @@ "@inquirer/checkbox/@inquirer/type": ["@inquirer/type@3.0.3", "", { "peerDependencies": { "@types/node": ">=18" } }, "sha512-I4VIHFxUuY1bshGbXZTxCmhwaaEst9s/lll3ekok+o1Z26/ZUKdx8y1b7lsoG6rtsBDwEGfiBJ2SfirjoISLpg=="], - "@inquirer/core/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - "@inquirer/editor/@inquirer/core": ["@inquirer/core@10.1.5", "", { "dependencies": { "@inquirer/figures": "^1.0.10", "@inquirer/type": "^3.0.3", "ansi-escapes": "^4.3.2", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.2" } }, "sha512-/vyCWhET0ktav/mUeBqJRYTwmjFPIKPRYb3COAw7qORULgipGSUO2vL32lQKki3UxDKJ8BvuEbokaoyCA6YlWw=="], "@inquirer/editor/@inquirer/type": ["@inquirer/type@3.0.3", "", { "peerDependencies": { "@types/node": ">=18" } }, "sha512-I4VIHFxUuY1bshGbXZTxCmhwaaEst9s/lll3ekok+o1Z26/ZUKdx8y1b7lsoG6rtsBDwEGfiBJ2SfirjoISLpg=="], @@ -1589,7 +1590,11 @@ "@inquirer/select/@inquirer/type": ["@inquirer/type@3.0.3", "", { "peerDependencies": { "@types/node": ">=18" } }, "sha512-I4VIHFxUuY1bshGbXZTxCmhwaaEst9s/lll3ekok+o1Z26/ZUKdx8y1b7lsoG6rtsBDwEGfiBJ2SfirjoISLpg=="], - "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + + "@jridgewell/source-map/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], "@jsdevtools/ez-spawn/cross-spawn": ["cross-spawn@7.0.3", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w=="], @@ -1609,6 +1614,12 @@ "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], + "@rolldown/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@types/jsdom/@types/node": ["@types/node@22.6.1", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-V48tCfcKb/e6cVUigLAaJDAILdMP0fUW6BidkPK4GpGjXcfbnoHasCZDwz3N3yVt5we2RHm4XTQCpv0KJz9zqw=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], @@ -1637,8 +1648,6 @@ "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], - "ast-v8-to-istanbul/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.30", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q=="], - "boxen/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], "boxen/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -1649,8 +1658,6 @@ "cli-truncate/string-width": ["string-width@1.0.2", "", { "dependencies": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", "strip-ansi": "^3.0.0" } }, "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw=="], - "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "config-chain/ini": ["ini@1.3.7", "", {}, "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ=="], @@ -1697,18 +1704,12 @@ "find-up/path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "foreground-child/cross-spawn": ["cross-spawn@7.0.3", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w=="], - "get-source/data-uri-to-buffer": ["data-uri-to-buffer@2.0.2", "", {}, "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA=="], - "glob/minimatch": ["minimatch@10.0.1", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ=="], - "globby/path-type": ["path-type@5.0.0", "", {}, "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg=="], "has-ansi/ansi-regex": ["ansi-regex@2.1.1", "", {}, "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA=="], - "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "ignore-walk/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "import-local/pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="], @@ -1727,8 +1728,6 @@ "is-observable/symbol-observable": ["symbol-observable@1.2.0", "", {}, "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="], - "istanbul-lib-source-maps/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], - "listr/is-stream": ["is-stream@1.1.0", "", {}, "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ=="], "listr/p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="], @@ -1785,6 +1784,8 @@ "ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "ora/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + "p-memoize/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="], "postcss/picocolors": ["picocolors@1.1.0", "", {}, "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="], @@ -1801,43 +1802,33 @@ "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "rolldown/@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], + "rollup/@types/estree": ["@types/estree@1.0.5", "", {}, "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw=="], "seek-bzip/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "simple-swizzle/is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], - "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "terminal-link/ansi-escapes": ["ansi-escapes@5.0.0", "", { "dependencies": { "type-fest": "^1.0.2" } }, "sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA=="], "terser/acorn": ["acorn@8.12.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], - "test-exclude/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], - - "test-exclude/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - - "ts-declaration-location/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "tinyglobby/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], "update-notifier/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], "vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], - "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - - "wrangler/esbuild": ["esbuild@0.25.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.2", "@esbuild/android-arm": "0.25.2", "@esbuild/android-arm64": "0.25.2", "@esbuild/android-x64": "0.25.2", "@esbuild/darwin-arm64": "0.25.2", "@esbuild/darwin-x64": "0.25.2", "@esbuild/freebsd-arm64": "0.25.2", "@esbuild/freebsd-x64": "0.25.2", "@esbuild/linux-arm": "0.25.2", "@esbuild/linux-arm64": "0.25.2", "@esbuild/linux-ia32": "0.25.2", "@esbuild/linux-loong64": "0.25.2", "@esbuild/linux-mips64el": "0.25.2", "@esbuild/linux-ppc64": "0.25.2", "@esbuild/linux-riscv64": "0.25.2", "@esbuild/linux-s390x": "0.25.2", "@esbuild/linux-x64": "0.25.2", "@esbuild/netbsd-arm64": "0.25.2", "@esbuild/netbsd-x64": "0.25.2", "@esbuild/openbsd-arm64": "0.25.2", "@esbuild/openbsd-x64": "0.25.2", "@esbuild/sunos-x64": "0.25.2", "@esbuild/win32-arm64": "0.25.2", "@esbuild/win32-ia32": "0.25.2", "@esbuild/win32-x64": "0.25.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ=="], + "vitest/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - "wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], + "vitest/vite": ["vite@8.0.13", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.1", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw=="], - "wrap-ansi/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "wrangler/esbuild": ["esbuild@0.25.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.2", "@esbuild/android-arm": "0.25.2", "@esbuild/android-arm64": "0.25.2", "@esbuild/android-x64": "0.25.2", "@esbuild/darwin-arm64": "0.25.2", "@esbuild/darwin-x64": "0.25.2", "@esbuild/freebsd-arm64": "0.25.2", "@esbuild/freebsd-x64": "0.25.2", "@esbuild/linux-arm": "0.25.2", "@esbuild/linux-arm64": "0.25.2", "@esbuild/linux-ia32": "0.25.2", "@esbuild/linux-loong64": "0.25.2", "@esbuild/linux-mips64el": "0.25.2", "@esbuild/linux-ppc64": "0.25.2", "@esbuild/linux-riscv64": "0.25.2", "@esbuild/linux-s390x": "0.25.2", "@esbuild/linux-x64": "0.25.2", "@esbuild/netbsd-arm64": "0.25.2", "@esbuild/netbsd-x64": "0.25.2", "@esbuild/openbsd-arm64": "0.25.2", "@esbuild/openbsd-x64": "0.25.2", "@esbuild/sunos-x64": "0.25.2", "@esbuild/win32-arm64": "0.25.2", "@esbuild/win32-ia32": "0.25.2", "@esbuild/win32-x64": "0.25.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ=="], "youch/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], @@ -1849,6 +1840,8 @@ "@babel/highlight/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + "@cspotcode/source-map-support/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + "@fastly/js-compute/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.23.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ=="], "@fastly/js-compute/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.23.1", "", { "os": "android", "cpu": "arm" }, "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ=="], @@ -1897,43 +1890,25 @@ "@fastly/js-compute/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.23.1", "", { "os": "win32", "cpu": "x64" }, "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg=="], - "@inquirer/checkbox/@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - - "@inquirer/core/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@fastly/js-compute/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], "@inquirer/editor/@inquirer/core/@inquirer/figures": ["@inquirer/figures@1.0.10", "", {}, "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw=="], - "@inquirer/editor/@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - "@inquirer/expand/@inquirer/core/@inquirer/figures": ["@inquirer/figures@1.0.10", "", {}, "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw=="], - "@inquirer/expand/@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - "@inquirer/input/@inquirer/core/@inquirer/figures": ["@inquirer/figures@1.0.10", "", {}, "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw=="], - "@inquirer/input/@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - "@inquirer/number/@inquirer/core/@inquirer/figures": ["@inquirer/figures@1.0.10", "", {}, "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw=="], - "@inquirer/number/@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - "@inquirer/password/@inquirer/core/@inquirer/figures": ["@inquirer/figures@1.0.10", "", {}, "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw=="], - "@inquirer/password/@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - "@inquirer/prompts/@inquirer/confirm/@inquirer/core": ["@inquirer/core@10.1.5", "", { "dependencies": { "@inquirer/figures": "^1.0.10", "@inquirer/type": "^3.0.3", "ansi-escapes": "^4.3.2", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.2" } }, "sha512-/vyCWhET0ktav/mUeBqJRYTwmjFPIKPRYb3COAw7qORULgipGSUO2vL32lQKki3UxDKJ8BvuEbokaoyCA6YlWw=="], "@inquirer/prompts/@inquirer/confirm/@inquirer/type": ["@inquirer/type@3.0.3", "", { "peerDependencies": { "@types/node": ">=18" } }, "sha512-I4VIHFxUuY1bshGbXZTxCmhwaaEst9s/lll3ekok+o1Z26/ZUKdx8y1b7lsoG6rtsBDwEGfiBJ2SfirjoISLpg=="], "@inquirer/rawlist/@inquirer/core/@inquirer/figures": ["@inquirer/figures@1.0.10", "", {}, "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw=="], - "@inquirer/rawlist/@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - - "@inquirer/search/@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - - "@inquirer/select/@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - - "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "@jridgewell/source-map/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], "@napi-rs/lzma-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.2.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.1", "tslib": "^2.4.0" } }, "sha512-E7Vgw78I93we4ZWdYCb4DGAwRROGkMIXk7/y87UmANR+J6qsWusmC3gLt0H+O0KOt5e6O38U8oJamgbudrES/w=="], @@ -1953,14 +1928,14 @@ "@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + "@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@types/jsdom/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], "@typescript-eslint/typescript-estree/tinyglobby/fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "@typescript-eslint/typescript-estree/tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "@typescript-eslint/utils/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" } }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="], @@ -1971,14 +1946,16 @@ "boxen/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="], + "boxen/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + "boxen/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], + "boxen/wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + "cli-truncate/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@1.0.0", "", { "dependencies": { "number-is-nan": "^1.0.0" } }, "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw=="], "cli-truncate/string-width/strip-ansi": ["strip-ansi@3.0.1", "", { "dependencies": { "ansi-regex": "^2.0.0" } }, "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg=="], - "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "decompress/make-dir/pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="], "eslint-plugin-es-x/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -1987,8 +1964,6 @@ "eslint-plugin-n/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], - "ignore-walk/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "import-local/pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -2017,8 +1992,6 @@ "inquirer/@inquirer/core/@inquirer/figures": ["@inquirer/figures@1.0.10", "", {}, "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw=="], - "inquirer/@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - "listr-input/inquirer/cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], "listr-input/inquirer/cli-width": ["cli-width@3.0.0", "", {}, "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw=="], @@ -2029,8 +2002,6 @@ "listr-input/inquirer/run-async": ["run-async@2.4.1", "", {}, "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="], - "listr-input/inquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "listr-input/rxjs/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "listr-update-renderer/chalk/ansi-styles": ["ansi-styles@2.2.1", "", {}, "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA=="], @@ -2055,28 +2026,18 @@ "log-update/wrap-ansi/strip-ansi": ["strip-ansi@4.0.0", "", { "dependencies": { "ansi-regex": "^3.0.0" } }, "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow=="], - "normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "ora/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="], - "restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], - - "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "ora/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], - "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], "terminal-link/ansi-escapes/type-fest": ["type-fest@1.4.0", "", {}, "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA=="], - "test-exclude/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - - "test-exclude/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - - "test-exclude/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], - "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], @@ -2123,8 +2084,18 @@ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + "vitest/tinyglobby/fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "vitest/vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "vitest/vite/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "vitest/vite/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "widest-line/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="], + "widest-line/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag=="], "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.2", "", { "os": "android", "cpu": "arm" }, "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA=="], @@ -2175,42 +2146,22 @@ "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.2", "", { "os": "win32", "cpu": "x64" }, "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA=="], - "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "wrap-ansi/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - "@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "@babel/highlight/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], - "@inquirer/checkbox/@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "@inquirer/editor/@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "@inquirer/expand/@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "@inquirer/input/@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "@inquirer/number/@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "@inquirer/password/@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@inquirer/prompts/@inquirer/confirm/@inquirer/core/@inquirer/figures": ["@inquirer/figures@1.0.10", "", {}, "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw=="], - "@inquirer/prompts/@inquirer/confirm/@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - - "@inquirer/rawlist/@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "@inquirer/search/@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "@inquirer/select/@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@napi-rs/lzma-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g=="], + "boxen/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + + "boxen/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + "cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@2.1.1", "", {}, "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA=="], "eslint-plugin-import-x/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -2231,14 +2182,10 @@ "inquirer-autosubmit-prompt/inquirer/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], - "inquirer/@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "listr-input/inquirer/cli-cursor/restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], "listr-input/inquirer/figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], - "listr-input/inquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "listr-verbose-renderer/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "listr-verbose-renderer/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], @@ -2247,29 +2194,13 @@ "log-update/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@3.0.1", "", {}, "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw=="], - "test-exclude/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - - "@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], - - "@inquirer/checkbox/@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "vitest/vite/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], - "@inquirer/editor/@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "vitest/vite/tinyglobby/fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "@inquirer/expand/@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "widest-line/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], - "@inquirer/input/@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "@inquirer/number/@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "@inquirer/password/@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "@inquirer/prompts/@inquirer/confirm/@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "@inquirer/rawlist/@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "@inquirer/search/@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "@inquirer/select/@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "import-local/pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], @@ -2277,16 +2208,12 @@ "inquirer-autosubmit-prompt/inquirer/string-width/strip-ansi/ansi-regex": ["ansi-regex@3.0.1", "", {}, "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw=="], - "inquirer/@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "listr-input/inquirer/cli-cursor/restore-cursor/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "listr-input/inquirer/cli-cursor/restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "listr-verbose-renderer/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], - "@inquirer/prompts/@inquirer/confirm/@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "import-local/pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "listr-input/inquirer/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 9d693cd582..95203d0146 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -18,6 +18,12 @@ So, if you propose great ideas, but I do not appropriate them, the idea may not Although, don't worry! Hono is tested well, polished by the contributors, and used by many developers. And I'll try my best to make Hono cool and hot, beautiful, and ultrafast. +## AI Usage Policy + +You may use AI to contribute, but it must never waste a maintainer's time or make their work unpleasant. + +To enforce this, and regardless of whether AI was actually used, a maintainer may close your PR without notice and block your account. + ## Installing dependencies The `honojs/hono` project uses [Bun](https://bun.sh/) as its package manager. Developers should install Bun. @@ -50,7 +56,5 @@ If you want to do it, create an issue about your middleware. ## Local Development ```bash -git clone git@github.com:honojs/hono.git && cd hono/.devcontainer && bun install --frozen-lockfile -docker compose up -d --build -docker compose exec hono bash +git clone git@github.com:honojs/hono.git && cd hono && bun install --frozen-lockfile ``` diff --git a/package.json b/package.json index 39c5867382..f5a7ff0d9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hono", - "version": "4.12.8", + "version": "4.12.26", "description": "Web framework built on Web Standards", "main": "dist/cjs/index.js", "type": "module", @@ -31,7 +31,7 @@ "watch": "bun run --shell bun remove-dist && bun ./build/build.ts --watch && bun run copy:package.cjs.json", "coverage": "vitest --run --coverage", "prerelease": "bun test:deno && bun run build", - "release": "np", + "release": "np --no-publish", "remove-dist": "rm -rf dist" }, "exports": { @@ -506,9 +506,18 @@ "jsx/dom/server": [ "./dist/types/jsx/dom/server.d.ts" ], + "jsx/dom/jsx-dev-runtime": [ + "./dist/types/jsx/dom/jsx-dev-runtime.d.ts" + ], + "jsx/dom/jsx-runtime": [ + "./dist/types/jsx/dom/jsx-runtime.d.ts" + ], "jwt": [ "./dist/types/middleware/jwt" ], + "jwk": [ + "./dist/types/middleware/jwk" + ], "timeout": [ "./dist/types/middleware/timeout" ], @@ -552,19 +561,19 @@ "./dist/types/router.d.ts" ], "router/reg-exp-router": [ - "./dist/types/router/reg-exp-router/router.d.ts" + "./dist/types/router/reg-exp-router" ], "router/smart-router": [ - "./dist/types/router/smart-router/router.d.ts" + "./dist/types/router/smart-router" ], "router/trie-router": [ - "./dist/types/router/trie-router/router.d.ts" + "./dist/types/router/trie-router" ], "router/pattern-router": [ - "./dist/types/router/pattern-router/router.d.ts" + "./dist/types/router/pattern-router" ], "router/linear-router": [ - "./dist/types/router/linear-router/router.d.ts" + "./dist/types/router/linear-router" ], "utils/jwt": [ "./dist/types/utils/jwt/index.d.ts" @@ -596,15 +605,15 @@ "bun": [ "./dist/types/adapter/bun" ], - "nextjs": [ - "./dist/types/adapter/nextjs" - ], "aws-lambda": [ "./dist/types/adapter/aws-lambda" ], "vercel": [ "./dist/types/adapter/vercel" ], + "netlify": [ + "./dist/types/adapter/netlify" + ], "lambda-edge": [ "./dist/types/adapter/lambda-edge" ], @@ -657,19 +666,16 @@ ], "devDependencies": { "@hono/eslint-config": "^2.1.0", - "@hono/node-server": "^1.13.5", - "@types/glob": "^9.0.0", + "@hono/node-server": "^2.0.2", "@types/jsdom": "^21.1.7", "@types/node": "^24.3.0", "@types/ws": "^8.18.1", "@typescript/native-preview": "7.0.0-dev.20260210.1", - "@vitest/coverage-v8": "^3.2.4", - "arg": "^5.0.2", + "@vitest/coverage-v8": "^4.1.7", "bun-types": "^1.2.20", "editorconfig-checker": "6.1.1", "esbuild": "^0.27.1", "eslint": "^9.39.3", - "glob": "^11.0.0", "jsdom": "22.1.0", "msw": "^2.6.0", "np": "10.2.0", @@ -680,7 +686,7 @@ "typescript": "^5.9.2", "undici": "^6.21.3", "vite-plugin-fastly-js-compute": "^0.4.2", - "vitest": "^3.2.4", + "vitest": "^4.1.7", "wrangler": "4.12.0", "ws": "^8.18.0", "zod": "^3.23.8" diff --git a/runtime-tests/lambda/index.test.ts b/runtime-tests/lambda/index.test.ts index 04e536c8d2..b0e4fa2dd1 100644 --- a/runtime-tests/lambda/index.test.ts +++ b/runtime-tests/lambda/index.test.ts @@ -539,10 +539,10 @@ describe('AWS Lambda Adapter for Hono', () => { const latticeResponse = await handler(latticeProxyEvent) expect(latticeResponse.statusCode).toBe(200) - expect(latticeResponse.headers).toHaveProperty( - 'set-cookie', - [testCookie1.serialized, testCookie2.serialized].join(', ') - ) + expect(latticeResponse.headers).toHaveProperty('set-cookie', [ + testCookie1.serialized, + testCookie2.serialized, + ]) }) describe('headers', () => { @@ -785,9 +785,7 @@ describe('AWS Lambda Adapter for Hono', () => { expect(albResponse.body).toBe('Cookies Set') expect(albResponse.headers['content-type']).toMatch(/^text\/plain/) expect(albResponse.multiValueHeaders).toBeUndefined() - expect(albResponse.headers['set-cookie']).toEqual( - [testCookie1.serialized, testCookie2.serialized].join(', ') - ) + expect(albResponse.headers['set-cookie']).toEqual(testCookie1.serialized) expect(albResponse.isBase64Encoded).toBe(false) }) diff --git a/runtime-tests/node/index.test.ts b/runtime-tests/node/index.test.ts index 8cdffe72ab..47ae317581 100644 --- a/runtime-tests/node/index.test.ts +++ b/runtime-tests/node/index.test.ts @@ -133,14 +133,12 @@ describe('stream', () => { it('Should call onAbort', async () => { const controller = new AbortController() - const res = expect(agent.get('/stream', { signal: controller.signal })).rejects.toThrow( - 'This operation was aborted' - ) + const req = agent.get('/stream', { signal: controller.signal }) expect(aborted).toBe(false) await new Promise((resolve) => setTimeout(resolve, 10)) controller.abort() - await res + await req.catch(() => {}) while (!aborted) { await new Promise((resolve) => setTimeout(resolve)) } @@ -188,14 +186,12 @@ describe('streamSSE', () => { it('Should call onAbort', async () => { const controller = new AbortController() - const res = expect(agent.get('/stream', { signal: controller.signal })).rejects.toThrow( - 'This operation was aborted' - ) + const req = agent.get('/stream', { signal: controller.signal }) expect(aborted).toBe(false) await new Promise((resolve) => setTimeout(resolve, 10)) controller.abort() - await res + await req.catch(() => {}) while (!aborted) { await new Promise((resolve) => setTimeout(resolve)) } diff --git a/src/adapter/aws-lambda/handler.test.ts b/src/adapter/aws-lambda/handler.test.ts index 461ae247db..d7d76ef91b 100644 --- a/src/adapter/aws-lambda/handler.test.ts +++ b/src/adapter/aws-lambda/handler.test.ts @@ -1,5 +1,13 @@ +import { setCookie } from '../../helper/cookie' +import { Hono } from '../../hono' +import { bodyLimit } from '../../middleware/body-limit' import type { LambdaEvent, LatticeProxyEventV2 } from './handler' -import { getProcessor, isContentEncodingBinary, defaultIsContentTypeBinary } from './handler' +import { + getProcessor, + handle, + isContentEncodingBinary, + defaultIsContentTypeBinary, +} from './handler' // Base event objects to reduce duplication const baseV1Event: LambdaEvent = { @@ -290,6 +298,7 @@ describe('EventProcessor.createRequest', () => { 'https://id.execute-api.us-east-1.amazonaws.com/my/path?parameter1=value1¶meter1=value2¶meter2=value' ) expect(Object.fromEntries(request.headers)).toEqual({ + 'content-length': '17', 'content-type': 'application/json', cookie: 'cookie1; cookie2', header1: 'value1', @@ -335,6 +344,7 @@ describe('EventProcessor.createRequest', () => { 'https://my-service-a1b2c3.x1y2z3.vpc-lattice-svcs.us-east-1.on.aws/my/path?parameter1=value1¶meter1=value2¶meter2=value' ) expect(Object.fromEntries(request.headers)).toEqual({ + 'content-length': '17', 'content-type': 'application/x-www-form-urlencoded', cookie: 'cookie1=value1; cookie2=value2', header1: 'value1', @@ -360,3 +370,140 @@ describe('EventProcessor.createRequest', () => { }) }) }) + +describe('handle', () => { + it('Should return 400 when request contains invalid header names (v2)', async () => { + const app = new Hono() + app.get('/my/path', (c) => c.text('Hello')) + const handler = handle(app) + + const event: LambdaEvent = { + ...baseV2Event, + headers: { + 'valid-header': 'value', + 'a"a': 'invalid header name', + }, + requestContext: { + ...baseV2Event.requestContext, + http: { + method: 'GET', + path: '/my/path', + protocol: 'HTTP/1.1', + sourceIp: '192.0.2.1', + userAgent: 'agent', + }, + }, + } + + const result = await handler(event) + expect(result.statusCode).toBe(400) + expect(result.body).toBe('Invalid request') + }) + + it('ALB single-header: emits the first Set-Cookie intact, never comma-joined', async () => { + const app = new Hono() + app.get('/multi-cookie', (c) => { + setCookie(c, 'session', 'abc123', { expires: new Date('2026-06-09T00:00:00Z') }) + setCookie(c, 'csrf', 'xyz789', { expires: new Date('2026-06-10T00:00:00Z') }) + return c.text('ok') + }) + const handler = handle(app) + + const event = { + httpMethod: 'GET', + path: '/multi-cookie', + headers: { host: 'app.example.com' }, + body: null, + isBase64Encoded: false, + requestContext: { elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:...' } }, + } as unknown as LambdaEvent + + const result = await handler(event) + + expect(result.headers!['set-cookie']).toBe( + 'session=abc123; Path=/; Expires=Tue, 09 Jun 2026 00:00:00 GMT' + ) + }) + + it('Lattice v2: emits multiple Set-Cookie as an array', async () => { + const app = new Hono() + app.get('/multi-cookie', (c) => { + setCookie(c, 'session', 'abc123') + setCookie(c, 'csrf', 'xyz789') + return c.text('ok') + }) + const handler = handle(app) + + const event: LatticeProxyEventV2 = { + version: '2.0', + path: '/multi-cookie', + method: 'GET', + headers: { host: ['app.example.com'] }, + queryStringParameters: {}, + body: null, + isBase64Encoded: false, + requestContext: { + serviceNetworkArn: '', + serviceArn: 'arn:aws:vpc-lattice:us-east-1:123456789012:service/svc-0a40', + targetGroupArn: '', + identity: {}, + region: 'us-east-1', + timeEpoch: '1583348638390123', + }, + } + + const result = await handler(event) + + expect(result.headers!['set-cookie']).toEqual(['session=abc123; Path=/', 'csrf=xyz789; Path=/']) + }) + + it('Should return 400 when request contains invalid header names (v1)', async () => { + const app = new Hono() + app.get('/my/path', (c) => c.text('Hello')) + const handler = handle(app) + + const event: LambdaEvent = { + ...baseV1Event, + headers: { + 'a"a': 'invalid header name', + }, + multiValueHeaders: { + 'a"a': ['invalid header name'], + }, + } + + const result = await handler(event) + expect(result.statusCode).toBe(400) + expect(result.body).toBe('Invalid request') + }) + + it('Should enforce bodyLimit when the client understates Content-Length', async () => { + const app = new Hono() + app.post( + '/upload', + bodyLimit({ maxSize: 1024, onError: (c) => c.text('too large', 413) }), + async (c) => c.json({ received: (await c.req.text()).length }) + ) + const handler = handle(app) + + const event: LambdaEvent = { + ...baseV2Event, + rawPath: '/upload', + headers: { 'content-type': 'text/plain', 'content-length': '1' }, + body: 'A'.repeat(10000), + requestContext: { + ...baseV2Event.requestContext, + http: { + method: 'POST', + path: '/upload', + protocol: 'HTTP/1.1', + sourceIp: '192.0.2.1', + userAgent: 'agent', + }, + }, + } + + const result = await handler(event) + expect(result.statusCode).toBe(413) + }) +}) diff --git a/src/adapter/aws-lambda/handler.ts b/src/adapter/aws-lambda/handler.ts index ef7a89c510..4abb62bc4f 100644 --- a/src/adapter/aws-lambda/handler.ts +++ b/src/adapter/aws-lambda/handler.ts @@ -97,7 +97,7 @@ export interface ALBProxyEvent { } type WithHeaders = { - headers: Record + headers: Record multiValueHeaders?: undefined } type WithMultiValueHeaders = { @@ -252,8 +252,18 @@ export const handle = { const processor = getProcessor(event) - const req = processor.createRequest(event) - const requestContext = getRequestContext(event) + let req, requestContext + try { + req = processor.createRequest(event) + requestContext = getRequestContext(event) + } catch (error) { + console.error('Error processing request:', error) + const errorResponse = + error instanceof TypeError + ? new Response('Invalid request', { status: 400 }) + : new Response('Internal Server Error', { status: 500 }) + return processor.createResult(event, errorResponse, { isContentTypeBinary }) + } const res = await app.fetch(req, { event, @@ -321,7 +331,11 @@ export abstract class EventProcessor { } if (event.body) { - requestInit.body = event.isBase64Encoded ? decodeBase64(event.body) : event.body + const body = event.isBase64Encoded + ? decodeBase64(event.body) + : new TextEncoder().encode(event.body) + requestInit.body = body + headers.set('content-length', body.length.toString()) } return new Request(url, requestInit) @@ -567,8 +581,7 @@ export class ALBProcessor extends EventProcessor { if (result.multiValueHeaders) { result.multiValueHeaders['set-cookie'] = cookies } else { - // otherwise serialize the set-cookie - result.headers['set-cookie'] = cookies.join(', ') + result.headers['set-cookie'] = cookies[0] } } } @@ -616,7 +629,7 @@ export class LatticeV2Processor extends EventProcessor { protected setCookiesToResult(result: APIGatewayProxyResult, cookies: string[]): void { result.headers = { ...result.headers, - 'set-cookie': cookies.join(', '), + 'set-cookie': cookies, } } } diff --git a/src/adapter/bun/serve-static.ts b/src/adapter/bun/serve-static.ts index 05e9569386..4898c800fc 100644 --- a/src/adapter/bun/serve-static.ts +++ b/src/adapter/bun/serve-static.ts @@ -6,7 +6,7 @@ import type { ServeStaticOptions } from '../../middleware/serve-static' import type { Env, MiddlewareHandler } from '../../types' export const serveStatic = ( - options: ServeStaticOptions + options: ServeStaticOptions = {} ): MiddlewareHandler => { return async function serveStatic(c, next) { const getContent = async (path: string) => { diff --git a/src/adapter/cloudflare-workers/serve-static.ts b/src/adapter/cloudflare-workers/serve-static.ts index 9ebf5c3b65..251198f989 100644 --- a/src/adapter/cloudflare-workers/serve-static.ts +++ b/src/adapter/cloudflare-workers/serve-static.ts @@ -6,7 +6,7 @@ import { getContentFromKVAsset } from './utils' export type ServeStaticOptions = BaseServeStaticOptions & { // namespace is KVNamespace namespace?: unknown - manifest: object | string + manifest?: object | string } /** @@ -19,7 +19,7 @@ export type ServeStaticOptions = BaseServeStaticOptions * application with the `npm create hono@latest` command. */ export const serveStatic = ( - options: ServeStaticOptions + options: ServeStaticOptions = {} ): MiddlewareHandler => { return async function serveStatic(c, next) { const getContent = async (path: string) => { diff --git a/src/adapter/deno/serve-static.ts b/src/adapter/deno/serve-static.ts index 866ee7035a..a7cf384ac1 100644 --- a/src/adapter/deno/serve-static.ts +++ b/src/adapter/deno/serve-static.ts @@ -6,7 +6,7 @@ import type { Env, MiddlewareHandler } from '../../types' const { open, lstatSync, errors } = Deno export const serveStatic = ( - options: ServeStaticOptions + options: ServeStaticOptions = {} ): MiddlewareHandler => { return async function serveStatic(c, next) { const getContent = async (path: string) => { diff --git a/src/adapter/deno/websocket.test.ts b/src/adapter/deno/websocket.test.ts index 57d8e59d82..c5192a9161 100644 --- a/src/adapter/deno/websocket.test.ts +++ b/src/adapter/deno/websocket.test.ts @@ -76,6 +76,75 @@ describe('WebSockets', () => { ) expect(await messagePromise).toBe(data) }) + + it('Should pass the first Sec-WebSocket-Protocol value as the protocol option', async () => { + app.get( + '/ws', + upgradeWebSocket(() => ({})) + ) + const socket = new EventTarget() as WebSocket + let passedOptions: Deno.UpgradeWebSocketOptions | undefined + Deno.upgradeWebSocket = (_req, options) => { + passedOptions = options + return { + response: new Response(), + socket, + } + } + await app.request('/ws', { + headers: { + upgrade: 'websocket', + 'sec-websocket-protocol': 'rivet, rivet_target.actor, rivet_actor.19c4f9038947cdcb', + }, + }) + expect(passedOptions?.protocol).toBe('rivet') + }) + + it('Should not set the protocol option when Sec-WebSocket-Protocol is absent', async () => { + app.get( + '/ws', + upgradeWebSocket(() => ({})) + ) + const socket = new EventTarget() as WebSocket + let passedOptions: Deno.UpgradeWebSocketOptions | undefined + Deno.upgradeWebSocket = (_req, options) => { + passedOptions = options + return { + response: new Response(), + socket, + } + } + await app.request('/ws', { + headers: { + upgrade: 'websocket', + }, + }) + expect(passedOptions?.protocol).toBeUndefined() + }) + + it('Should let an explicit protocol option take precedence over the request header', async () => { + app.get( + '/ws', + upgradeWebSocket(() => ({}), { protocol: 'user-chosen' }) + ) + const socket = new EventTarget() as WebSocket + let passedOptions: Deno.UpgradeWebSocketOptions | undefined + Deno.upgradeWebSocket = (_req, options) => { + passedOptions = options + return { + response: new Response(), + socket, + } + } + await app.request('/ws', { + headers: { + upgrade: 'websocket', + 'sec-websocket-protocol': 'header-value', + }, + }) + expect(passedOptions?.protocol).toBe('user-chosen') + }) + it('Should call next() when header does not have upgrade', async () => { const next = vi.fn() await upgradeWebSocket(() => ({}))( diff --git a/src/adapter/deno/websocket.ts b/src/adapter/deno/websocket.ts index d50ad7f1e7..858c48f4cd 100644 --- a/src/adapter/deno/websocket.ts +++ b/src/adapter/deno/websocket.ts @@ -7,7 +7,15 @@ export const upgradeWebSocket: UpgradeWebSocket = new WSContext({ close: (code, reason) => socket.close(code, reason), diff --git a/src/adapter/lambda-edge/handler.test.ts b/src/adapter/lambda-edge/handler.test.ts index 84ec3c7e7b..df93033dc1 100644 --- a/src/adapter/lambda-edge/handler.test.ts +++ b/src/adapter/lambda-edge/handler.test.ts @@ -1,6 +1,7 @@ import { describe } from 'vitest' import { setCookie } from '../../helper/cookie' import { Hono } from '../../hono' +import { bodyLimit } from '../../middleware/body-limit' import { encodeBase64 } from '../../utils/encode' import type { Callback, CloudFrontEdgeEvent } from './handler' import { createBody, handle, isContentTypeBinary } from './handler' @@ -89,6 +90,36 @@ describe('handle', () => { expect(res.body).toBe('https://hono.dev/test-path') }) + it('Should preserve all values of a multi-value request header', async () => { + const app = new Hono() + app.get('/test-path', (c) => c.text(c.req.header('x-forwarded-for') ?? '')) + const handler = handle(app) + + const event: CloudFrontEdgeEvent = { + Records: [ + { + cf: { + ...cloudFrontEdgeEvent.Records[0].cf, + request: { + ...cloudFrontEdgeEvent.Records[0].cf.request, + headers: { + host: [{ key: 'Host', value: 'hono.dev' }], + 'x-forwarded-for': [ + { key: 'X-Forwarded-For', value: '10.0.0.1' }, + { key: 'X-Forwarded-For', value: '192.168.1.1' }, + { key: 'X-Forwarded-For', value: '203.0.113.55' }, + ], + }, + }, + }, + }, + ], + } + + const res = await handler(event) + expect(res.body).toBe('10.0.0.1, 192.168.1.1, 203.0.113.55') + }) + it('Should expose async handler arity compatible with NODEJS_24_X', () => { const app = new Hono() const handler = handle(app) @@ -152,4 +183,43 @@ describe('handle', () => { ], }) }) + + it('Should enforce bodyLimit when the client understates Content-Length', async () => { + const app = new Hono() + app.post( + '/upload', + bodyLimit({ maxSize: 1024, onError: (c) => c.text('too large', 413) }), + async (c) => c.json({ received: (await c.req.text()).length }) + ) + const handler = handle(app) + + const event: CloudFrontEdgeEvent = { + Records: [ + { + cf: { + ...cloudFrontEdgeEvent.Records[0].cf, + request: { + ...cloudFrontEdgeEvent.Records[0].cf.request, + method: 'POST', + uri: '/upload', + headers: { + host: [{ key: 'Host', value: 'hono.dev' }], + 'content-type': [{ key: 'Content-Type', value: 'text/plain' }], + 'content-length': [{ key: 'Content-Length', value: '1' }], + }, + body: { + inputTruncated: false, + action: 'read-only', + encoding: 'text', + data: 'A'.repeat(10000), + }, + }, + }, + }, + ], + } + + const res = await handler(event) + expect(res.status).toBe('413') + }) }) diff --git a/src/adapter/lambda-edge/handler.ts b/src/adapter/lambda-edge/handler.ts index dc9eb7d113..1b47dabb4b 100644 --- a/src/adapter/lambda-edge/handler.ts +++ b/src/adapter/lambda-edge/handler.ts @@ -159,12 +159,22 @@ const createRequest = (event: CloudFrontEdgeEvent): Request => { const headers = new Headers() Object.entries(event.Records[0].cf.request.headers).forEach(([k, v]) => { - v.forEach((header) => headers.set(k, header.value)) + v.forEach((header) => headers.append(k, header.value)) }) const requestBody = event.Records[0].cf.request.body const method = event.Records[0].cf.request.method - const body = createBody(method, requestBody) + const rawBody = createBody(method, requestBody) + + let body: string | Uint8Array | undefined = rawBody + if (rawBody !== undefined) { + const bytes = + typeof rawBody === 'string' + ? (new TextEncoder().encode(rawBody) as Uint8Array) + : rawBody + body = bytes + headers.set('content-length', bytes.length.toString()) + } return new Request(url, { headers, diff --git a/src/adapter/service-worker/index.ts b/src/adapter/service-worker/index.ts index 22574d16ab..3e1ed41394 100644 --- a/src/adapter/service-worker/index.ts +++ b/src/adapter/service-worker/index.ts @@ -27,9 +27,7 @@ import type { HandleOptions } from './handler' */ const fire = ( app: Hono, - options: HandleOptions = { - fetch: undefined, - } + options?: HandleOptions ): void => { // @ts-expect-error addEventListener is not typed well in ServiceWorker-like contexts, see: https://github.com/microsoft/TypeScript/issues/14877 addEventListener('fetch', handle(app, options)) diff --git a/src/client/client.test.ts b/src/client/client.test.ts index 72bac29da6..ed20a37df5 100644 --- a/src/client/client.test.ts +++ b/src/client/client.test.ts @@ -15,6 +15,7 @@ import type { InferRequestType, InferResponseType, ApplyGlobalResponse, + PickResponseByStatusCode, } from './types' class SafeBigInt { @@ -1843,3 +1844,101 @@ describe('ApplyGlobalResponse Type Helper', () => { type verify = Expect> }) }) + +describe('PickResponseByStatusCode Type Helper', () => { + it('Should keep only specified status code responses', () => { + const app = new Hono().get('/api/users', (c) => { + try { + return c.json({ users: ['alice', 'bob'] }, 200) + } catch { + return c.json({ error: 'Internal Server Error' }, 500) + } + }) + + type AppSuccessOnly = PickResponseByStatusCode + + const client = hc('http://localhost') + const req = client.api.users.$get + + type ResponseType = InferResponseType + type Expected = { users: string[] } + type verify = Expect> + + type Res = Awaited> + type verifyOk = Expect> + type verifyStatus = Expect> + }) + + it('Should work with ApplyGlobalResponse', () => { + const app = new Hono().get('/api/users', (c) => { + try { + return c.json({ users: ['alice', 'bob'] }, 200) + } catch { + return c.json({ error: 'Not Found' }, 404) + } + }) + + type AppWithGlobalErrors = ApplyGlobalResponse< + typeof app, + { + 401: { json: { error: string; message: string } } + 500: { json: { error: string; message: string } } + } + > + + type AppSuccessOnly = PickResponseByStatusCode + + const client = hc('http://localhost') + const req = client.api.users.$get + + type ResponseType = InferResponseType + type Expected = { users: string[] } + type verify = Expect> + }) + + it('Should work with route() paths', () => { + const users = new Hono().get('/users', (c) => { + try { + return c.json({ users: ['alice', 'bob'] }, 200) + } catch { + return c.json({ error: 'error' }, 500) + } + }) + const app = new Hono().route('/api', users) + + type AppSuccessOnly = PickResponseByStatusCode + + const client = hc('http://localhost') + const req = client.api.users.$get + + type ResponseType = InferResponseType + type Expected = { users: string[] } + type verify = Expect> + }) + + it('Should pick multiple status codes', () => { + const app = new Hono().get('/api/users', (c) => { + try { + return c.json({ users: ['alice', 'bob'] }, 200) + } catch { + return c.json({ error: 'Bad Request' }, 400) + } + }) + + type AppWithGlobalErrors = ApplyGlobalResponse< + typeof app, + { + 500: { json: { error: string } } + } + > + + type AppFiltered = PickResponseByStatusCode + + const client = hc('http://localhost') + const req = client.api.users.$get + + type ResponseType = InferResponseType + type Expected = { users: string[] } | { error: string } + type verify = Expect> + }) +}) diff --git a/src/client/index.ts b/src/client/index.ts index 9d052b9cbf..0832bafb41 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -13,4 +13,5 @@ export type { ClientRequest, ClientResponse, ApplyGlobalResponse, + PickResponseByStatusCode, } from './types' diff --git a/src/client/types.ts b/src/client/types.ts index 95d8b8358d..5ec978a500 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -361,3 +361,33 @@ export type ApplyGlobalResponse = ? Hono : never : never + +type PickRoute = R extends Endpoint + ? R extends { status: U } + ? R + : never + : R + +type PickSchema = { + [K in keyof D]: { + [M in keyof D[K]]: PickRoute + } +} + +/** + * Keep only specific status code responses from all routes of an app. + * Useful when error responses are handled centrally (e.g., via custom fetch) + * and you want the client to only expose success response types. + * + * @example + * ```ts + * type AppSuccessOnly = PickResponseByStatusCode + * const client = hc('http://localhost') + * ``` + */ +export type PickResponseByStatusCode = + App extends HonoBase + ? PickSchema, U> extends infer S extends Schema + ? Hono + : never + : never diff --git a/src/helper/css/common.ts b/src/helper/css/common.ts index 12f5433760..ece8165dc0 100644 --- a/src/helper/css/common.ts +++ b/src/helper/css/common.ts @@ -49,6 +49,29 @@ const toHash = (str: string): string => { return 'css-' + out } +const normalizeLabel = (label: string): string => { + return label.trim().replace(/\s+/g, '-') +} + +const isValidClassName = (name: string): boolean => /^-?[_a-zA-Z][_a-zA-Z0-9-]*$/.test(name) + +// CSS-wide keywords that are invalid as @keyframes names per the spec +const RESERVED_KEYFRAME_NAMES = new Set([ + 'default', + 'inherit', + 'initial', + 'none', + 'revert', + 'revert-layer', + 'unset', +]) +const isValidKeyframeName = (name: string): boolean => + isValidClassName(name) && !RESERVED_KEYFRAME_NAMES.has(name.toLowerCase()) + +const defaultOnInvalidSlug = (slug: string) => { + console.warn(`Invalid slug: ${slug}`) +} + const cssStringReStr: string = [ '"(?:(?:\\\\[\\s\\S]|[^"\\\\])*)"', // double quoted string @@ -90,6 +113,25 @@ type CssVariableAsyncType = Promise type CssVariableArrayType = (CssVariableBasicType | CssVariableAsyncType)[] export type CssVariableType = CssVariableBasicType | CssVariableAsyncType | CssVariableArrayType +/** + * A function that customizes generated CSS class names. + * + * @param hash - The default hash-based class name (e.g. `css-1234567890`) + * @param label - The comment label extracted from the CSS template, may be empty. + * Whitespace is trimmed and inner spaces are replaced with hyphens. + * @param styleString - The minified CSS style string + * @returns The custom class name to use. Must be a safe CSS identifier; + * otherwise, the default hash is used as a fallback. + */ +export type ClassNameSlug = (hash: string, label: string, styleString: string) => string + +/** + * A callback function called when an invalid slug is returned from ClassNameSlug. + * + * @param slug - The invalid slug + */ +export type OnInvalidSlug = (slug: string) => void + export const buildStyleString = ( strings: TemplateStringsArray, values: CssVariableType[] @@ -154,14 +196,30 @@ export const buildStyleString = ( export const cssCommon = ( strings: TemplateStringsArray, - values: CssVariableType[] + values: CssVariableType[], + classNameSlug?: ClassNameSlug, + onInvalidSlug?: OnInvalidSlug ): CssClassName => { let [label, thisStyleString, selectors, externalClassNames] = buildStyleString(strings, values) const isPseudoGlobal = isPseudoGlobalSelectorRe.exec(thisStyleString) if (isPseudoGlobal) { thisStyleString = isPseudoGlobal[1] } - const selector = (isPseudoGlobal ? PSEUDO_GLOBAL_SELECTOR : '') + toHash(label + thisStyleString) + const hash = toHash(label + thisStyleString) + + let customSlug: string | undefined + if (classNameSlug) { + const slug = classNameSlug(hash, normalizeLabel(label), thisStyleString) + if (slug) { + if (isValidClassName(slug)) { + customSlug = slug + } else { + ;(onInvalidSlug || defaultOnInvalidSlug)(slug) + } + } + } + + const selector = (isPseudoGlobal ? PSEUDO_GLOBAL_SELECTOR : '') + (customSlug || hash) const className = ( isPseudoGlobal ? selectors.map((s) => s[CLASS_NAME]) : [selector, ...externalClassNames] ).join(' ') @@ -196,12 +254,28 @@ export const cxCommon = ( export const keyframesCommon = ( strings: TemplateStringsArray, - ...values: CssVariableType[] + values: CssVariableType[], + classNameSlug?: ClassNameSlug, + onInvalidSlug?: OnInvalidSlug ): CssClassName => { const [label, styleString] = buildStyleString(strings, values) + const hash = toHash(label + styleString) + + let customSlug: string | undefined + if (classNameSlug) { + const slug = classNameSlug(hash, normalizeLabel(label), styleString) + if (slug) { + if (isValidKeyframeName(slug)) { + customSlug = slug + } else { + ;(onInvalidSlug || defaultOnInvalidSlug)(slug) + } + } + } + return { [SELECTOR]: '', - [CLASS_NAME]: `@keyframes ${toHash(label + styleString)}`, + [CLASS_NAME]: `@keyframes ${customSlug || hash}`, [STYLE_STRING]: styleString, [SELECTORS]: [], [EXTERNAL_CLASS_NAMES]: [], @@ -209,7 +283,12 @@ export const keyframesCommon = ( } type ViewTransitionType = { - (strings: TemplateStringsArray, values: CssVariableType[]): CssClassName + ( + strings: TemplateStringsArray, + values: CssVariableType[], + classNameSlug?: ClassNameSlug, + onInvalidSlug?: OnInvalidSlug + ): CssClassName (content: CssClassName): CssClassName (): CssClassName } @@ -217,19 +296,25 @@ type ViewTransitionType = { let viewTransitionNameIndex = 0 export const viewTransitionCommon: ViewTransitionType = (( strings: TemplateStringsArray | CssClassName | undefined, - values: CssVariableType[] + values: CssVariableType[], + classNameSlug?: ClassNameSlug, + onInvalidSlug?: OnInvalidSlug ): CssClassName => { if (!strings) { // eslint-disable-next-line @typescript-eslint/no-explicit-any strings = [`/* h-v-t ${viewTransitionNameIndex++} */`] as any } const content = Array.isArray(strings) - ? cssCommon(strings as TemplateStringsArray, values) + ? cssCommon(strings as TemplateStringsArray, values, classNameSlug, onInvalidSlug) : (strings as CssClassName) const transitionName = content[CLASS_NAME] - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const res = cssCommon(['view-transition-name:', ''] as any, [transitionName]) + const res = cssCommon( + ['view-transition-name:', ''] as any, // eslint-disable-line @typescript-eslint/no-explicit-any + [transitionName], + classNameSlug, + onInvalidSlug + ) content[CLASS_NAME] = PSEUDO_GLOBAL_SELECTOR + content[CLASS_NAME] content[STYLE_STRING] = content[STYLE_STRING].replace( diff --git a/src/helper/css/index.test.tsx b/src/helper/css/index.test.tsx index c8001d9c3a..a60f41cca5 100644 --- a/src/helper/css/index.test.tsx +++ b/src/helper/css/index.test.tsx @@ -206,6 +206,268 @@ describe('CSS Helper', () => { }) }) + describe('classNameSlug', () => { + it('Should use custom classNameSlug function', async () => { + const { css: customCss, Style: customStyle } = createCssContext({ + id: 'custom-slug', + classNameSlug: (hash, _label, _css) => `btn-${hash.split('-')[1]}`, + }) + const btnClass = customCss` + background-color: blue; + ` + const template = ( + <> + {customStyle()} + + + ) + const result = await toString(template) + expect(result).toContain('.btn-') + expect(result).not.toContain('.css-') + }) + + it('Should pass label to classNameSlug', async () => { + const { css: customCss, Style: customStyle } = createCssContext({ + id: 'label-slug', + classNameSlug: (hash, label) => (label ? `h-${label}` : hash), + }) + const headerClass = customCss` + /* hero section */ + background-color: blue; + ` + const template = ( + <> + {customStyle()} +
Hero
+ + ) + const result = await toString(template) + expect(result).toContain('.h-hero-section') + expect(result).toContain('background-color:blue') + }) + + it('Should fall back to different hash values for labels with special characters', async () => { + const { css: customCss, Style: customStyle } = createCssContext({ + id: 'label-deferent-slug', + classNameSlug: (hash, label) => (label ? `h-${label}` : hash), + }) + const headerClass1 = customCss` + /* hero section! */ + background-color: blue; + ` + const headerClass2 = customCss` + /* hero section? */ + background-color: blue; + ` + const template = ( + <> + {customStyle()} +
Hero
+
Hero
+ + ) + const result = await toString(template) + + const classes = new Set() + result.match(/class="(.*?)"/g)?.forEach((match) => { + classes.add(match) + }) + expect(classes.size).toBe(2) + }) + + it('Should fall back to hash when label is empty', async () => { + const { css: customCss, Style: customStyle } = createCssContext({ + id: 'fallback-slug', + classNameSlug: (hash, label) => (label ? `h-${label}` : hash), + }) + const noLabelClass = customCss` + color: red; + ` + const template = ( + <> + {customStyle()} +
No label
+ + ) + const result = await toString(template) + expect(result).toContain('.css-') + expect(result).toContain('color:red') + }) + + it('Should not affect default context when classNameSlug is not set', async () => { + const headerClass = css` + background-color: blue; + ` + const template = ( + <> +
red
' ) }) + + it('classNameSlug with createCssContext', async () => { + const { css: customCss, Style: CustomStyle } = createCssContext({ + id: 'custom-dom', + classNameSlug: (hash, label) => (label ? `h-${label}` : hash), + }) + const App = () => { + return ( +
+ +
+ blue +
+
+ ) + } + render(, root) + expect(root.innerHTML).toBe( + '
blue
' + ) + await Promise.resolve() + expect(root.querySelector('style')?.sheet?.cssRules[0].cssText).toBe('.h-hero {color: blue;}') + }) }) describe('render', () => { diff --git a/src/jsx/dom/css.ts b/src/jsx/dom/css.ts index 4fdaf6cabc..47d094edab 100644 --- a/src/jsx/dom/css.ts +++ b/src/jsx/dom/css.ts @@ -4,7 +4,12 @@ */ import type { FC, PropsWithChildren } from '../' -import type { CssClassName, CssVariableType } from '../../helper/css/common' +import type { + ClassNameSlug, + CssClassName, + CssVariableType, + OnInvalidSlug, +} from '../../helper/css/common' import { CLASS_NAME, DEFAULT_STYLE_ID, @@ -167,8 +172,20 @@ interface DefaultContextType { * @experimental * `createCssContext` is an experimental feature. * The API might be changed. + * + * @param options.id - The ID for the style element + * @param options.classNameSlug - Optional function to customize generated CSS class names + * @param options.onInvalidSlug - Optional callback function called when an invalid slug is returned from ClassNameSlug */ -export const createCssContext = ({ id }: { id: Readonly }): DefaultContextType => { +export const createCssContext = ({ + id, + classNameSlug, + onInvalidSlug, +}: { + id: Readonly + classNameSlug?: ClassNameSlug + onInvalidSlug?: OnInvalidSlug +}): DefaultContextType => { const [cssObject, Style] = createCssJsxDomObjects({ id }) const newCssClassNameObject = (cssClassName: CssClassName): string => { @@ -177,7 +194,7 @@ export const createCssContext = ({ id }: { id: Readonly }): DefaultConte } const css: CssType = (strings, ...values) => { - return newCssClassNameObject(cssCommon(strings, values)) + return newCssClassNameObject(cssCommon(strings, values, classNameSlug, onInvalidSlug)) } const cx: CxType = (...args) => { @@ -187,14 +204,16 @@ export const createCssContext = ({ id }: { id: Readonly }): DefaultConte return css(Array(args.length).fill('') as any, ...args) } - const keyframes: KeyframesType = keyframesCommon + const keyframes: KeyframesType = (strings, ...values) => + keyframesCommon(strings, values, classNameSlug, onInvalidSlug) const viewTransition: ViewTransitionType = (( strings: TemplateStringsArray | string | undefined, ...values: CssVariableType[] ) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return newCssClassNameObject(viewTransitionCommon(strings as any, values)) + return newCssClassNameObject( + viewTransitionCommon(strings as any, values, classNameSlug, onInvalidSlug) // eslint-disable-line @typescript-eslint/no-explicit-any + ) }) as ViewTransitionType return { diff --git a/src/jsx/dom/index.test.tsx b/src/jsx/dom/index.test.tsx index ca3b86553a..4ceb0de5a0 100644 --- a/src/jsx/dom/index.test.tsx +++ b/src/jsx/dom/index.test.tsx @@ -96,6 +96,7 @@ describe('DOM', () => { global.HTMLElement = dom.window.HTMLElement global.SVGElement = dom.window.SVGElement global.Text = dom.window.Text + global.DOMException = dom.window.DOMException root = document.getElementById('root') as HTMLElement }) @@ -194,6 +195,69 @@ describe('DOM', () => { expect(root.innerHTML).toBe('
') }) + it('ignores invalid attribute keys without interrupting updates', async () => { + const invalidKey = '" onfocus="alert(1)' + + const App = () => { + const [includeInvalid, setIncludeInvalid] = useState(true) + return includeInvalid ? ( +
setIncludeInvalid(false)}> + Hello +
+ ) : ( +
Hello
+ ) + } + + render(, root) + expect(root.innerHTML).toBe('
Hello
') + + root.querySelector('div')?.click() + await Promise.resolve() + + expect(root.innerHTML).toBe('
Hello
') + }) + + it('rethrows unexpected errors while setting attributes', () => { + const error = new Error('boom') + const originalSetAttribute = dom.window.Element.prototype.setAttribute + const setAttributeSpy = vi + .spyOn(dom.window.Element.prototype, 'setAttribute') + .mockImplementation(function (this: Element, key: string, value: string) { + if (key === 'data-boom') { + throw error + } + return originalSetAttribute.call(this, key, value) + }) + + try { + expect(() => render(
Hello
, root)).toThrow(error) + } finally { + setAttributeSpy.mockRestore() + } + }) + + it('rethrows unexpected errors while removing attributes', () => { + render(
Hello
, root) + + const error = new Error('boom') + const originalRemoveAttribute = dom.window.Element.prototype.removeAttribute + const removeAttributeSpy = vi + .spyOn(dom.window.Element.prototype, 'removeAttribute') + .mockImplementation(function (this: Element, key: string) { + if (key === 'data-boom') { + throw error + } + return originalRemoveAttribute.call(this, key) + }) + + try { + expect(() => render(
Hello
, root)).toThrow(error) + } finally { + removeAttributeSpy.mockRestore() + } + }) + it('ref', () => { const App = () => { const ref = useRef(null) @@ -904,7 +968,7 @@ describe('DOM', () => { it('assign undefined', async () => { let setValue: (value: string | undefined) => void = () => {} const App = () => { - const [value, _setValue] = useState('a') + const [value, _setValue] = useState('b') setValue = _setValue return ( + {options.map((opt) => ( + + ))} + + ) + } + render(, root) + setOptions(['option1', 'option2', 'option3']) + await Promise.resolve() + const select = root.querySelector('select') as HTMLSelectElement + expect(select.value).toBe('option2') + expect(select.selectedIndex).toBe(1) + expect(select.options[1].selected).toBe(true) + }) + + it('select the first option when undefined after options are added', async () => { + let setOptions: (options: string[]) => void = () => {} + const App = () => { + const [options, _setOptions] = useState([]) + setOptions = _setOptions + return ( + + ) + } + render(, root) + setOptions(['option1', 'option2', 'option3']) + await Promise.resolve() + const select = root.querySelector('select') as HTMLSelectElement + expect(select.value).toBe('option1') + expect(select.selectedIndex).toBe(0) + expect(select.options[0].selected).toBe(true) + }) + + it('do not select the first option for invalid multiple value', () => { + const App = () => { + return ( + + ) + } + render(, root) + const select = root.querySelector('select') as HTMLSelectElement + expect(select.value).toBe('') + expect(select.selectedIndex).toBe(-1) + expect([...select.options].every((option) => !option.selected)).toBe(true) + }) + + it('keep invalid multiple value unselected after options are added', async () => { + let setOptions: (options: string[]) => void = () => {} + const App = () => { + const [options, _setOptions] = useState([]) + setOptions = _setOptions + return ( + + ) + } + render(, root) + setOptions(['a', 'b', 'c']) + await Promise.resolve() + const select = root.querySelector('select') as HTMLSelectElement + expect(select.value).toBe('') + expect(select.selectedIndex).toBe(-1) + expect([...select.options].every((option) => !option.selected)).toBe(true) }) }) @@ -2492,6 +2646,18 @@ describe('DOM', () => { expect(document.querySelector('svg title')).toBeInstanceOf(dom.window.SVGTitleElement) }) + it('skips invalid attribute keys in SVG while preserving valid ones', () => { + const App = () => { + return ( + + ') + }) + describe('attribute', () => { describe('camelCase', () => { test.each` diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index 5d2eae62e8..c9e5f9ba5e 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -141,6 +141,26 @@ const toAttributeName = (element: SupportedElement, key: string): string => ? key.replace(/([A-Z])/g, '-$1').toLowerCase() : key +const normalizeFormValue = (value: Props['value']) => + value === null || value === undefined || value === false ? null : value + +const applySelectValue = (select: HTMLSelectElement, props: Props): void => { + if (!('value' in props)) { + return + } + + select.value = normalizeFormValue(props['value']) + if (!select.multiple && select.selectedIndex === -1) { + select.selectedIndex = 0 + } +} + +// Unlike SSR (which uses isValidAttributeName for upfront validation), +// the DOM renderer relies on try/catch for performance—invalid attribute +// names are rare at runtime, so avoiding the per-attribute regex check is faster. +const isIgnorableAttributeError = (error: unknown): boolean => + error instanceof DOMException && error.name === 'InvalidCharacterError' + const applyProps = ( container: SupportedElement, attributes: Props, @@ -188,18 +208,15 @@ const applyProps = ( } else { if (key === 'value') { const nodeName = container.nodeName - if (nodeName === 'INPUT' || nodeName === 'TEXTAREA' || nodeName === 'SELECT') { - ;(container as unknown as HTMLInputElement).value = - value === null || value === undefined || value === false ? null : value + if (nodeName === 'SELECT') { + // Deferred to applySelectValue() after children are rendered + continue + } else if (nodeName === 'INPUT' || nodeName === 'TEXTAREA') { + ;(container as unknown as HTMLInputElement).value = normalizeFormValue(value) if (nodeName === 'TEXTAREA') { container.textContent = value continue - } else if (nodeName === 'SELECT') { - if ((container as unknown as HTMLSelectElement).selectedIndex === -1) { - ;(container as unknown as HTMLSelectElement).selectedIndex = 0 - } - continue } } } else if ( @@ -212,14 +229,20 @@ const applyProps = ( const k = toAttributeName(container, key) - if (value === null || value === undefined || value === false) { - container.removeAttribute(k) - } else if (value === true) { - container.setAttribute(k, '') - } else if (typeof value === 'string' || typeof value === 'number') { - container.setAttribute(k, value as string) - } else { - container.setAttribute(k, value.toString()) + try { + if (value === null || value === undefined || value === false) { + container.removeAttribute(k) + } else if (value === true) { + container.setAttribute(k, '') + } else if (typeof value === 'string' || typeof value === 'number') { + container.setAttribute(k, value as string) + } else { + container.setAttribute(k, value.toString()) + } + } catch (e) { + if (!isIgnorableAttributeError(e)) { + throw e + } } } } @@ -235,7 +258,13 @@ const applyProps = ( } else if (key === 'ref') { refCleanupMap.get(container)?.() } else { - container.removeAttribute(toAttributeName(container, key)) + try { + container.removeAttribute(toAttributeName(container, key)) + } catch (e) { + if (!isIgnorableAttributeError(e)) { + throw e + } + } } } } @@ -404,8 +433,12 @@ const applyNodeObject = (node: NodeObject, container: Container, isNew: boolean) el = child.e ||= child.n ? (document.createElementNS(child.n, child.tag as string) as SVGElement | MathMLElement) : document.createElement(child.tag as string) + applyProps(el as HTMLElement, child.props, child.pP) applyNodeObject(child, el as HTMLElement, isNewLocal) + if (child.tag === 'select') { + applySelectValue(el as HTMLSelectElement, child.props) + } } } if (child.tag === HONO_PORTAL_ELEMENT) { diff --git a/src/jsx/index.test.tsx b/src/jsx/index.test.tsx index a830cd1827..1c8234e170 100644 --- a/src/jsx/index.test.tsx +++ b/src/jsx/index.test.tsx @@ -3,7 +3,16 @@ import { html } from '../helper/html' import { Hono } from '../hono' import { Suspense, renderToReadableStream } from './streaming' -import DefaultExport, { Fragment, StrictMode, createContext, memo, useContext, version } from '.' +import DefaultExport, { + Fragment, + StrictMode, + createContext, + createElement, + jsx, + memo, + useContext, + version, +} from '.' import type { Context, FC, PropsWithChildren } from '.' interface SiteData { @@ -170,6 +179,48 @@ describe('render to string', () => { expect(template.toString()).toBe('https://example.com') }) + describe('programmatic tag names', () => { + it('Should throw when jsx() receives a tag name that can break out of the HTML context', () => { + const payloads = [ + '> jsx(tag, {}, 'hello')).toThrow(`Invalid JSX tag name: ${tag}`) + } + }) + + it('Should throw when createElement() receives a tag name that can break out of the HTML context', () => { + expect(() => createElement('div onmouseover="alert(1)" x=', {}, 'hello')).toThrow( + 'Invalid JSX tag name: div onmouseover="alert(1)" x=' + ) + }) + + it('Should throw when jsx() receives an invalid non-primitive tag name', () => { + expect(() => jsx(new String('div') as never, {}, 'hello')).toThrow( + 'Invalid JSX tag name: div' + ) + expect(() => + jsx({ toString: () => 'div onmouseover="alert(1)" x=' } as never, {}, 'hello') + ).toThrow('Invalid JSX tag name: div onmouseover="alert(1)" x=') + }) + + it('Should allow compatible custom and namespaced tag names', () => { + expect(jsx('custom-element', {}, 'hello').toString()).toBe( + 'hello' + ) + expect(jsx('foo:bar', {}, 'hello').toString()).toBe('hello') + expect(jsx('x.foo', {}, 'hello').toString()).toBe('hello') + expect(jsx('x_bar', {}, 'hello').toString()).toBe('hello') + expect(jsx('é', {}, 'hello').toString()).toBe('<é>hello') + }) + }) + it('Props value is null', () => { const template = Hello expect(template.toString()).toBe('Hello') @@ -468,6 +519,110 @@ describe('render to string', () => { const template =

Hello

expect(template.toString()).toBe('

Hello

') }) + + describe('CSS injection prevention', () => { + it('should drop style values containing ";" to prevent CSS injection', () => { + const userInput = + 'transparent;background:url(https://attacker.example/a.png);position:fixed;top:0' + const template =
+ const out = template.toString() as string + expect(out).not.toContain('background:url') + expect(out).not.toContain('position:fixed') + expect(out).not.toContain('color:') + expect(out).toBe('
') + }) + + it('should drop only the unsafe property and keep other safe properties', () => { + const template =
+ const out = template.toString() as string + expect(out).toBe('
') + expect(out).not.toContain('background:blue') + }) + + it('should drop style values that try to hide declaration separators in CSS comments', () => { + const template = ( +
+ ) + const out = template.toString() as string + expect(out).toBe('
') + expect(out).not.toContain('position:fixed') + }) + + it('should drop style values that try to expose declarations through CSS curly blocks', () => { + const template = ( +
+ ) + const out = template.toString() as string + expect(out).toBe('
') + expect(out).not.toContain('background:blue') + }) + + it('should drop unterminated style values that can swallow following declarations', () => { + const template =
+ const out = template.toString() as string + expect(out).toBe('
') + expect(out).not.toContain('color:') + }) + + it('should drop style property names that can inject declarations', () => { + const template = ( +
+ ) + const out = template.toString() as string + expect(out).toBe('
') + expect(out).not.toContain('background-image') + }) + + it('should still HTML-escape safe style values', () => { + const template =

Hello

+ expect(template.toString()).toBe( + '

Hello

' + ) + }) + + it('should keep semicolons inside quoted style values', () => { + const template =

Hello

+ expect(template.toString()).toBe( + '

Hello

' + ) + }) + + it('should drop quoted style values that use newlines to expose declaration separators', () => { + const template = ( +
+ ) + expect(template.toString()).toBe('
') + }) + + it('should keep numeric style values working', () => { + const template =
+ expect(template.toString()).toBe('
') + }) + + it('should not throw when style values contain ";"', () => { + expect(() => (
).toString()).not.toThrow() + }) + }) }) describe('HtmlEscaped in props', () => { @@ -478,6 +633,76 @@ describe('render to string', () => { }) }) + describe('XSS prevention for attribute keys', () => { + it('Should skip attribute keys containing double quotes', () => { + const props: Record = { ['" onfocus="alert(1)']: 'x' } + const template =
Hello
+ expect(template.toString()).toBe('
Hello
') + }) + + it('Should skip attribute keys containing >', () => { + const props: Record = { ['">Hello
') + }) + + it('Should skip invalid attribute keys before style serialization', () => { + const template =
') + }) + + it('should normalize kebab-case attributes on the root element', () => { + const template = + expect(template.toString()).toBe( + '' + ) + }) + describe('attribute', () => { describe('camelCase', () => { test.each` diff --git a/src/jsx/jsx-runtime.test.tsx b/src/jsx/jsx-runtime.test.tsx index a7ba4af469..016aa907e6 100644 --- a/src/jsx/jsx-runtime.test.tsx +++ b/src/jsx/jsx-runtime.test.tsx @@ -1,6 +1,7 @@ /** @jsxRuntime automatic **/ /** @jsxImportSource . **/ import { Hono } from '../hono' +import { jsxAttr } from './jsx-runtime' describe('jsx-runtime', () => { let app: Hono @@ -19,6 +20,57 @@ describe('jsx-runtime', () => { expect(await res.text()).toBe('

Hello

') }) + it('Should skip invalid attribute keys in jsxAttr()', () => { + expect(String(jsxAttr('" onfocus="alert(1)', 'x'))).toBe('') + expect(String(jsxAttr('foo { + const invalidKey = '" onfocus="alert(1)' + + expect(String(jsxAttr(invalidKey, { fontSize: 10 }))).toBe('') + expect(String(await jsxAttr(invalidKey, Promise.resolve('/docs?q=1&lang=en')))).toBe('') + }) + + it('Should render valid attribute values in jsxAttr()', async () => { + expect(String(jsxAttr('style', { fontSize: 10, color: 'red' }))).toBe( + 'style="font-size:10px;color:red"' + ) + expect(String(await jsxAttr('href', Promise.resolve('/docs?q=1&lang=en')))).toBe( + 'href="/docs?q=1&lang=en"' + ) + }) + + it('Should drop style values containing ";" in jsxAttr() to prevent CSS injection', () => { + expect( + String(jsxAttr('style', { color: 'red;background:blue', backgroundColor: 'white' })) + ).toBe('style="background-color:white"') + }) + + it('Should drop style values that hide declaration separators in CSS comments in jsxAttr()', () => { + expect( + String( + jsxAttr('style', { + color: 'red/*(*/;background:blue;position:fixed;top:0', + backgroundColor: 'white', + }) + ) + ).toBe('style="background-color:white"') + }) + + it('Should drop style property names that can inject declarations in jsxAttr()', () => { + expect( + String( + jsxAttr('style', { + 'color;background-image': 'url(https://attacker.example/a.png)', + backgroundColor: 'white', + }) + ) + ).toBe('style="background-color:white"') + }) + // https://en.reactjs.org/docs/jsx-in-depth.html#booleans-null-and-undefined-are-ignored describe('Booleans, Null, and Undefined Are Ignored', () => { it.each([true, false, undefined, null])('%s', (item) => { diff --git a/src/jsx/jsx-runtime.ts b/src/jsx/jsx-runtime.ts index 77da8f6167..6268fa48f6 100644 --- a/src/jsx/jsx-runtime.ts +++ b/src/jsx/jsx-runtime.ts @@ -9,7 +9,7 @@ export type { JSX } from './jsx-dev-runtime' import { html, raw } from '../helper/html' import type { HtmlEscapedString, StringBuffer, HtmlEscaped } from '../utils/html' import { escapeToBuffer, stringBufferToString } from '../utils/html' -import { styleObjectForEach } from './utils' +import { isValidAttributeName, styleObjectForEach } from './utils' export { html as jsxTemplate } @@ -17,6 +17,9 @@ export const jsxAttr = ( key: string, v: string | Promise | Record ): HtmlEscapedString | Promise => { + if (!isValidAttributeName(key)) { + return raw('') + } const buffer: StringBuffer = [`${key}="`] as StringBuffer if (key === 'style' && typeof v === 'object') { // object to style strings diff --git a/src/jsx/utils.test.ts b/src/jsx/utils.test.ts index 4045ba9763..a6c15d3c6a 100644 --- a/src/jsx/utils.test.ts +++ b/src/jsx/utils.test.ts @@ -1,4 +1,9 @@ -import { normalizeIntrinsicElementKey, styleObjectForEach } from './utils' +import { + isValidAttributeName, + isValidTagName, + normalizeIntrinsicElementKey, + styleObjectForEach, +} from './utils' describe('normalizeIntrinsicElementKey', () => { test.each` @@ -17,6 +22,110 @@ describe('normalizeIntrinsicElementKey', () => { }) }) +describe('isValidAttributeName', () => { + test.each` + name + ${'class'} + ${'id'} + ${'href'} + ${'data-foo'} + ${'aria-label'} + ${'onclick'} + ${'viewBox'} + ${'xml:lang'} + `('should return true for valid name "$name"', ({ name }) => { + expect(isValidAttributeName(name)).toBe(true) + }) + + test.each` + name | description + ${''} | ${'empty string'} + ${'" onfocus="alert(1)'} | ${'double quote'} + ${"' onfocus='alert(1)"} | ${'single quote'} + ${'foo'} | ${'greater than'} + ${'foo bar'} | ${'space'} + ${'foo=bar'} | ${'equals sign'} + ${'foo/bar'} | ${'slash'} + ${'foo\\bar'} | ${'backslash'} + ${'foo`bar'} | ${'backtick'} + ${'a\x00b'} | ${'null byte'} + ${'a\x1fb'} | ${'control character'} + ${'a\x7fb'} | ${'DEL character'} + `('should return false for "$description"', ({ name }) => { + expect(isValidAttributeName(name)).toBe(false) + }) + + test.each` + name | description + ${'xlink:href'} | ${'namespace separator'} + ${'data.foo'} | ${'dot'} + ${'data_foo'} | ${'underscore'} + ${'é'} | ${'non-ascii'} + `('should return true for valid "$description" names', ({ name }) => { + expect(isValidAttributeName(name)).toBe(true) + }) + + it('should keep validating names after the valid attribute name cache is reset', () => { + for (let i = 0; i < 1025; i++) { + expect(isValidAttributeName(`data-k${i}`)).toBe(true) + } + + expect(isValidAttributeName('class')).toBe(true) + expect(isValidAttributeName('" onfocus="alert(1)')).toBe(false) + }) +}) + +describe('isValidTagName', () => { + test.each` + name + ${''} + ${'div'} + ${'h1'} + ${'custom-element'} + ${'clipPath'} + ${'foo:bar'} + ${'x.foo'} + ${'x_bar'} + ${'é'} + `('should return true for valid tag name "$name"', ({ name }) => { + expect(isValidTagName(name)).toBe(true) + }) + + test.each` + name | description + ${'foo bar'} | ${'space'} + ${'foo\nbar'} | ${'newline'} + ${'" onfocus="alert(1)'} | ${'double quote'} + ${"' onfocus='alert(1)"} | ${'single quote'} + ${'foo'} | ${'greater than'} + ${'foo=bar'} | ${'equals sign'} + ${'foo/bar'} | ${'slash'} + ${'foo\\bar'} | ${'backslash'} + ${'foo`bar'} | ${'backtick'} + ${'a\x00b'} | ${'null byte'} + ${'a\x1fb'} | ${'control character'} + ${'a\x7fb'} | ${'DEL character'} + ${'!'} | ${'single bang'} + ${'!--'} | ${'parser-control bang prefix'} + ${'!DOCTYPE'} | ${'doctype-like name'} + ${'?'} | ${'single question mark'} + ${'?xml'} | ${'processing instruction'} + `('should return false for "$description"', ({ name }) => { + expect(isValidTagName(name)).toBe(false) + }) + + it('should keep validating names after the valid tag name cache is reset', () => { + for (let i = 0; i < 257; i++) { + expect(isValidTagName(`custom-${i}`)).toBe(true) + } + + expect(isValidTagName('div')).toBe(true) + expect(isValidTagName('div onmouseover="alert(1)"')).toBe(false) + }) +}) + describe('styleObjectForEach', () => { describe('Should output the number as it is, when a number type is passed', () => { test.each` @@ -105,4 +214,150 @@ describe('styleObjectForEach', () => { ) }) }) + + describe('Should skip values containing ";" to prevent CSS injection', () => { + it('skips a value with ";"', () => { + const fn = vi.fn() + styleObjectForEach({ color: 'red;background:blue' }, fn) + expect(fn).not.toBeCalled() + }) + + it('keeps safe siblings while skipping unsafe ones', () => { + const fn = vi.fn() + styleObjectForEach({ color: 'red;background:blue', backgroundColor: 'white' }, fn) + expect(fn).toBeCalledTimes(1) + expect(fn).toBeCalledWith('background-color', 'white') + }) + + it('does not throw for unsafe values', () => { + expect(() => styleObjectForEach({ color: 'a;b' }, () => {})).not.toThrow() + }) + + it('keeps semicolons inside quoted strings', () => { + const fn = vi.fn() + styleObjectForEach({ fontFamily: '"a;b", sans-serif' }, fn) + expect(fn).toBeCalledWith('font-family', '"a;b", sans-serif') + }) + + test.each([ + ['LF', '\n'], + ['CR', '\r'], + ['FF', '\f'], + ])('skips quoted strings that contain %s before a declaration separator', (_, newline) => { + const fn = vi.fn() + styleObjectForEach( + { fontFamily: `"${newline};background:url(https://example.com/a.png)` }, + fn + ) + expect(fn).not.toBeCalled() + }) + + it('keeps semicolons inside CSS functions', () => { + const fn = vi.fn() + styleObjectForEach({ backgroundImage: 'url("data:image/svg+xml;utf8,")' }, fn) + expect(fn).toBeCalledWith('background-image', 'url("data:image/svg+xml;utf8,")') + }) + + test.each([['square brackets', 'red[;background:blue]']])( + 'keeps semicolons inside CSS simple blocks with %s', + (_, value) => { + const fn = vi.fn() + styleObjectForEach({ color: value }, fn) + expect(fn).toBeCalledWith('color', value) + } + ) + + test.each([ + ['opening curly block', 'red{;background:blue}'], + ['closing curly block', 'red};background:blue'], + ])('skips CSS values containing %s', (_, value) => { + const fn = vi.fn() + styleObjectForEach({ color: value, backgroundColor: 'white' }, fn) + expect(fn).toBeCalledTimes(1) + expect(fn).toBeCalledWith('background-color', 'white') + }) + + test.each([ + ['square brackets', 'red[;background:blue];position:fixed'], + ['curly braces', 'red{;background:blue};position:fixed'], + ])('skips top-level semicolons after CSS simple blocks with %s', (_, value) => { + const fn = vi.fn() + styleObjectForEach({ color: value }, fn) + expect(fn).not.toBeCalled() + }) + + it('skips top-level semicolons after CSS comments', () => { + const fn = vi.fn() + styleObjectForEach({ color: 'red/*(*/;background:blue;position:fixed;top:0' }, fn) + expect(fn).not.toBeCalled() + }) + + test.each([ + ['comment', 'red/*'], + ['double-quoted string', '"abc'], + ['single-quoted string', "'abc"], + ['function block', 'red('], + ['square block', 'red['], + ['curly block', 'red{'], + ['unmatched function closer', 'red)'], + ['unmatched square closer', 'red]'], + ['unmatched curly closer', 'red}'], + ['mismatched simple block closer', 'red[)'], + ['escape', 'red\\'], + ])('skips unterminated CSS %s that can swallow following declarations', (_, value) => { + const fn = vi.fn() + styleObjectForEach({ color: value, display: 'none' }, fn) + expect(fn).toBeCalledTimes(1) + expect(fn).toBeCalledWith('display', 'none') + }) + + test.each([ + ['comment that closes exactly at end of value', 'red/* hi */'], + ['CSS hex escape that decodes to a delimiter byte', 'red\\3b '], + ['vertical tab inside a quoted string', '"\vfoo"'], + ['trailing solidus that is not a comment start', 'a/'], + ])('keeps CSS-safe edge case — %s', (_, value) => { + const fn = vi.fn() + styleObjectForEach({ color: value }, fn) + expect(fn).toBeCalledWith('color', value) + }) + + it('skips style property names that can break declaration boundaries', () => { + const fn = vi.fn() + styleObjectForEach({ 'color;background-image': 'url(https://example.com/a.png)' }, fn) + styleObjectForEach({ 'color:background': 'red' }, fn) + expect(fn).not.toBeCalled() + }) + + it('keeps safe property names that use digits', () => { + const fn = vi.fn() + styleObjectForEach({ '--my-var-1': '15px', '--myVar-2': '20px' }, fn) + expect(fn).toHaveBeenNthCalledWith(1, '--my-var-1', '15px') + expect(fn).toHaveBeenNthCalledWith(2, '--myVar-2', '20px') + }) + + it('keeps vendor-prefixed property names', () => { + const fn = vi.fn() + styleObjectForEach({ WebkitLineClamp: '2' }, fn) + expect(fn).toBeCalledWith('-webkit-line-clamp', '2') + }) + + it('keeps validating style property names after the valid style property name cache is reset', () => { + for (let i = 0; i < 1025; i++) { + styleObjectForEach({ [`--cache-${i}`]: '1px' }, () => {}) + } + + const fn = vi.fn() + styleObjectForEach({ '--after-reset-1': '2px', 'color:background': 'red' }, fn) + expect(fn).toBeCalledTimes(1) + expect(fn).toBeCalledWith('--after-reset-1', '2px') + }) + + it('skips unsupported runtime values without throwing', () => { + const fn = vi.fn() + expect(() => styleObjectForEach({ color: false, backgroundColor: 'white' }, fn)).not.toThrow() + expect(fn).toBeCalledTimes(1) + expect(fn).toBeCalledWith('background-color', 'white') + }) + }) }) diff --git a/src/jsx/utils.ts b/src/jsx/utils.ts index f64fa75875..2b3b4ab26e 100644 --- a/src/jsx/utils.ts +++ b/src/jsx/utils.ts @@ -11,8 +11,163 @@ const normalizeElementKeyMap: Map = new Map([ export const normalizeIntrinsicElementKey = (key: string): string => normalizeElementKeyMap.get(key) || key +// eslint-disable-next-line no-control-regex +const invalidAttributeNameCharRe = /[\s"'<>/=`\\\x00-\x1f\x7f-\x9f]/ +const validAttributeNameCache = new Set() +const validAttributeNameCacheMax = 1024 +// reject HTML parser-control tag starters ('!' for comments/doctype, '?' for +// processing instructions) in addition to the shared invalid-char set +// eslint-disable-next-line no-control-regex +const invalidTagNameCharRe = /^[!?]|[\s"'<>/=`\\\x00-\x1f\x7f-\x9f]/ +const validTagNameCache = new Set() +const validTagNameCacheMax = 256 + +const cacheValidName = (cache: Set, max: number, name: string): void => { + if (cache.size >= max) { + cache.clear() + } + cache.add(name) +} + +export const isValidTagName = (name: unknown): name is string => { + if (validTagNameCache.has(name)) { + return true + } + if (typeof name !== 'string') { + return false + } + if (name.length === 0) { + return true + } + if (invalidTagNameCharRe.test(name)) { + return false + } + cacheValidName(validTagNameCache, validTagNameCacheMax, name) + return true +} + +export const isValidAttributeName = (name: string): boolean => { + if (validAttributeNameCache.has(name)) { + return true + } + const len = name.length + if (len === 0) { + return false + } + for (let i = 0; i < len; i++) { + const c = name.charCodeAt(i) + if ( + !( + (c >= 0x61 && c <= 0x7a) || // a-z + (c >= 0x41 && c <= 0x5a) || // A-Z + (c >= 0x30 && c <= 0x39) || // 0-9 + c === 0x2d || // - + c === 0x5f || // _ + c === 0x2e || // . + c === 0x3a // : + ) + ) { + // non-fast-path character found — fall back to regex for the full name + if (!invalidAttributeNameCharRe.test(name)) { + cacheValidName(validAttributeNameCache, validAttributeNameCacheMax, name) + return true + } else { + return false + } + } + } + cacheValidName(validAttributeNameCache, validAttributeNameCacheMax, name) + return true +} + +// eslint-disable-next-line no-control-regex +const invalidStylePropertyNameCharRe = /[\s"'():;\\/\[\]{}\x00-\x1f\x7f-\x9f]/ +const validStylePropertyNameCache = new Set() +const validStylePropertyNameCacheMax = 1024 + +const isValidStylePropertyName = (name: string): boolean => { + if (validStylePropertyNameCache.has(name)) { + return true + } + const len = name.length + if (len === 0) { + return false + } + for (let i = 0; i < len; i++) { + const c = name.charCodeAt(i) + if ( + !( + (c >= 0x61 && c <= 0x7a) || // a-z + (c >= 0x41 && c <= 0x5a) || // A-Z + (c >= 0x30 && c <= 0x39) || // 0-9 + c === 0x2d || // - + c === 0x5f // _ + ) + ) { + // non-fast-path character found — fall back to regex for the full name + if (!invalidStylePropertyNameCharRe.test(name)) { + cacheValidName(validStylePropertyNameCache, validStylePropertyNameCacheMax, name) + return true + } else { + return false + } + } + } + cacheValidName(validStylePropertyNameCache, validStylePropertyNameCacheMax, name) + return true +} + +const unsafeStyleValueCharRe = /[;"'\\/\[\](){}]/ + +const hasUnsafeStyleValue = (value: string): boolean => { + if (!unsafeStyleValueCharRe.test(value)) { + return false + } + + let quote = 0 + const blockStack: number[] = [] + for (let i = 0, len = value.length; i < len; i++) { + const c = value.charCodeAt(i) + if (c === 0x5c) { + if (i === len - 1) { + return true + } + i++ + } else if (quote !== 0) { + if (c === 0x0a || c === 0x0c || c === 0x0d) { + return true + } + if (c === quote) { + quote = 0 + } + } else if (c === 0x2f && value.charCodeAt(i + 1) === 0x2a) { + const end = value.indexOf('*/', i + 2) + if (end === -1) { + return true + } + i = end + 1 + } else if (c === 0x22 || c === 0x27) { + quote = c + } else if (c === 0x28) { + blockStack.push(0x29) + } else if (c === 0x5b) { + blockStack.push(0x5d) + } else if (c === 0x7b || c === 0x7d) { + return true + } else if (c === 0x29 || c === 0x5d) { + if (blockStack[blockStack.length - 1] !== c) { + return true + } + blockStack.pop() + } else if (c === 0x3b && blockStack.length === 0) { + return true + } + } + return quote !== 0 || blockStack.length !== 0 +} + export const styleObjectForEach = ( - style: Record, + style: Record, fn: (key: string, value: string | null) => void ): void => { for (const [k, v] of Object.entries(style)) { @@ -20,17 +175,28 @@ export const styleObjectForEach = ( k[0] === '-' || !/[A-Z]/.test(k) ? k // a CSS variable or a lowercase only property : k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`) // a camelCase property. convert to kebab-case - fn( - key, - v == null - ? null - : typeof v === 'number' - ? !key.match( - /^(?:a|border-im|column(?:-c|s)|flex(?:$|-[^b])|grid-(?:ar|[^a])|font-w|li|or|sca|st|ta|wido|z)|ty$/ - ) - ? `${v}px` - : `${v}` - : v - ) + if (!isValidStylePropertyName(key)) { + continue + } + if (v == null) { + fn(key, null) + continue + } + let value: string + if (typeof v === 'number') { + value = !key.match( + /^(?:a|border-im|column(?:-c|s)|flex(?:$|-[^b])|grid-(?:ar|[^a])|font-w|li|or|sca|st|ta|wido|z)|ty$/ + ) + ? `${v}px` + : `${v}` + } else if (typeof v === 'string') { + if (hasUnsafeStyleValue(v)) { + continue + } + value = v + } else { + continue + } + fn(key, value) } } diff --git a/src/middleware/bearer-auth/index.test.ts b/src/middleware/bearer-auth/index.test.ts index 350bdb0720..f853659eb4 100644 --- a/src/middleware/bearer-auth/index.test.ts +++ b/src/middleware/bearer-auth/index.test.ts @@ -1,4 +1,6 @@ +import { expectTypeOf } from 'vitest' import { Hono } from '../../hono' +import type { Context, MiddlewareHandler } from '../../index' import { bearerAuth } from '.' describe('Bearer Auth by Middleware', () => { @@ -982,3 +984,35 @@ describe('Bearer Auth with prefix containing regex metacharacters', () => { expect(handlerExecuted).toBeFalsy() }) }) + +describe('Bearer Auth types', () => { + interface TestEnv { + Bindings: { API_KEY: string } + } + + it('returns MiddlewareHandler with explicit generic', () => { + const middleware = bearerAuth({ + verifyToken: (token, ctx) => { + expectTypeOf(ctx.env.API_KEY).toEqualTypeOf() + return token === ctx.env.API_KEY + }, + }) + expectTypeOf(middleware).toEqualTypeOf>() + }) + + it('infers E from ctx annotation', () => { + const middleware = bearerAuth({ + verifyToken: (token, ctx: Context) => token === ctx.env.API_KEY, + }) + expectTypeOf(middleware).toEqualTypeOf>() + }) +}) + +describe('Bearer Auth options validation', () => { + it('Should throw an error mentioning both "token" and "verifyToken" when neither is provided', () => { + expect(() => { + // @ts-expect-error testing runtime guard with no valid options + bearerAuth({}) + }).toThrow('bearer auth middleware requires options for "token" or "verifyToken"') + }) +}) diff --git a/src/middleware/bearer-auth/index.ts b/src/middleware/bearer-auth/index.ts index 1a7e7854ce..bfe3b77839 100644 --- a/src/middleware/bearer-auth/index.ts +++ b/src/middleware/bearer-auth/index.ts @@ -5,7 +5,7 @@ import type { Context } from '../../context' import { HTTPException } from '../../http-exception' -import type { MiddlewareHandler } from '../../types' +import type { Env, MiddlewareHandler } from '../../types' import { timingSafeEqual } from '../../utils/buffer' import type { ContentfulStatusCode } from '../../utils/http-status' @@ -19,7 +19,7 @@ type CustomizedErrorResponseOptions = { message?: string | object | MessageFunction } -type BearerAuthOptions = +type BearerAuthOptions = | { token: string | string[] realm?: string @@ -46,7 +46,7 @@ type BearerAuthOptions = realm?: string prefix?: string headerName?: string - verifyToken: (token: string, c: Context) => boolean | Promise + verifyToken: (token: string, c: Context) => boolean | Promise hashFunction?: Function /** * @deprecated Use noAuthenticationHeader.message instead @@ -70,6 +70,7 @@ type BearerAuthOptions = * * @see {@link https://hono.dev/docs/middleware/builtin/bearer-auth} * + * @template E - The environment type. * @param {BearerAuthOptions} options - The options for the bearer authentication middleware. * @param {string | string[]} [options.token] - The string or array of strings to validate the incoming bearer token against. * @param {Function} [options.verifyToken] - The function to verify the token. @@ -83,7 +84,7 @@ type BearerAuthOptions = * @param {string | object | MessageFunction} [options.invalidAuthenticationHeader.wwwAuthenticateHeader="Bearer error=\"invalid_request\""] - The response header value for the WWW-Authenticate header when authentication header is invalid. * @param {string | object | MessageFunction} [options.invalidToken.message="Unauthorized"] - The invalid token message. * @param {string | object | MessageFunction} [options.invalidToken.wwwAuthenticateHeader="Bearer error=\"invalid_token\""] - The response header value for the WWW-Authenticate header when token is invalid. - * @returns {MiddlewareHandler} The middleware handler function. + * @returns {MiddlewareHandler} The middleware handler function. * @throws {Error} If neither "token" nor "verifyToken" options are provided. * @throws {HTTPException} If authentication fails, with 401 status code for missing or invalid token, or 400 status code for invalid request. * @@ -100,9 +101,11 @@ type BearerAuthOptions = * }) * ``` */ -export const bearerAuth = (options: BearerAuthOptions): MiddlewareHandler => { +export const bearerAuth = ( + options: BearerAuthOptions +): MiddlewareHandler => { if (!('token' in options || 'verifyToken' in options)) { - throw new Error('bearer auth middleware requires options for "token"') + throw new Error('bearer auth middleware requires options for "token" or "verifyToken"') } if (!options.realm) { options.realm = '' diff --git a/src/middleware/body-limit/index.test.ts b/src/middleware/body-limit/index.test.ts index 44b6d28b1e..142e5b7fe1 100644 --- a/src/middleware/body-limit/index.test.ts +++ b/src/middleware/body-limit/index.test.ts @@ -198,4 +198,76 @@ describe('Body Limit Middleware', () => { expect(await res.text()).toBe(content) }) }) + + describe('chunked body bypass scenarios', () => { + const handler = vi.fn() + + const buildOversizedRequestInit = (): RequestInit & { duplex: 'half' } => ({ + method: 'POST', + headers: { 'Transfer-Encoding': 'chunked' }, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('1234')) + controller.enqueue(new TextEncoder().encode('5678901234567890')) + controller.close() + }, + }), + duplex: 'half', + }) + + beforeEach(() => { + handler.mockReset() + app = new Hono() + app.use('*', bodyLimit({ maxSize: 8 })) + app.post('/no-read', (c) => { + handler() + return c.text('NO-READ-OK') + }) + app.post('/partial-read', async (c) => { + handler() + const reader = c.req.raw.body?.getReader() + const chunk = reader ? await reader.read() : { value: undefined } + return c.text(new TextDecoder().decode(chunk.value)) + }) + app.post('/swallow-error', async (c) => { + handler() + const body = c.req.raw.body + if (!body) { + return c.text('no body') + } + const reader = body.getReader() + let seen = '' + try { + for (;;) { + const { done, value } = await reader.read() + if (done) { + break + } + seen += new TextDecoder().decode(value) + } + } catch { + // intentionally swallow read errors + } + return c.text(`processed:${seen}`) + }) + }) + + it('should reject when handler does not read the body', async () => { + const res = await app.request('/no-read', buildOversizedRequestInit()) + expect(res.status).toBe(413) + expect(handler).not.toHaveBeenCalled() + }) + + it('should reject when handler reads only the first chunk', async () => { + const res = await app.request('/partial-read', buildOversizedRequestInit()) + expect(res.status).toBe(413) + expect(handler).not.toHaveBeenCalled() + }) + + it('should reject when handler swallows body read errors', async () => { + const res = await app.request('/swallow-error', buildOversizedRequestInit()) + expect(res.status).toBe(413) + expect(handler).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/middleware/body-limit/index.ts b/src/middleware/body-limit/index.ts index e00a7fbf1f..39fff71007 100644 --- a/src/middleware/body-limit/index.ts +++ b/src/middleware/body-limit/index.ts @@ -15,13 +15,6 @@ type BodyLimitOptions = { onError?: OnError } -class BodyLimitError extends Error { - constructor(message: string) { - super(message) - this.name = 'BodyLimitError' - } -} - /** * Body Limit Middleware for Hono. * @@ -74,52 +67,45 @@ export const bodyLimit = (options: BodyLimitOptions): MiddlewareHandler => { const hasTransferEncoding = c.req.raw.headers.has('transfer-encoding') const hasContentLength = c.req.raw.headers.has('content-length') - // RFC 7230: If both Transfer-Encoding and Content-Length are present, - // Transfer-Encoding takes precedence and Content-Length should be ignored - if (hasTransferEncoding && hasContentLength) { - // Both headers present - follow RFC 7230 and ignore Content-Length - // This might indicate request smuggling attempt - } - if (hasContentLength && !hasTransferEncoding) { // Only Content-Length present - we can trust it const contentLength = parseInt(c.req.raw.headers.get('content-length') || '0', 10) return contentLength > maxSize ? onError(c) : next() } - // Transfer-Encoding present (chunked) or no length headers - + // Transfer-Encoding present (chunked) or no length headers. + // Per RFC 7230, when both are present Transfer-Encoding takes precedence + // and Content-Length is ignored. Read the body up-front so the size check + // is final before the handler runs, regardless of how (or whether) the + // handler reads the body. let size = 0 + const chunks: Uint8Array[] = [] const rawReader = c.req.raw.body.getReader() - const reader = new ReadableStream({ - async start(controller) { - try { - for (;;) { - const { done, value } = await rawReader.read() - if (done) { - break - } - size += value.length - if (size > maxSize) { - controller.error(new BodyLimitError(ERROR_MESSAGE)) - break - } + for (;;) { + const { done, value } = await rawReader.read() + if (done) { + break + } + size += value.length + if (size > maxSize) { + return onError(c) + } + chunks.push(value) + } - controller.enqueue(value) + const requestInit: RequestInit & { duplex: 'half' } = { + body: new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk) } - } finally { controller.close() - } - }, - }) - - const requestInit: RequestInit & { duplex: 'half' } = { body: reader, duplex: 'half' } + }, + }), + duplex: 'half', + } c.req.raw = new Request(c.req.raw, requestInit as RequestInit) - await next() - - if (c.error instanceof BodyLimitError) { - c.res = await onError(c) - } + return next() } } diff --git a/src/middleware/cache/index.test.ts b/src/middleware/cache/index.test.ts index 31461af61d..056dd61e78 100644 --- a/src/middleware/cache/index.test.ts +++ b/src/middleware/cache/index.test.ts @@ -1,8 +1,41 @@ +import { vi } from 'vitest' import type { ExecutionContext } from '../../context' import { Hono } from '../../hono' import { cache } from '.' // Mock +type StoreMap = Map + +class MockCache { + name: string + store: StoreMap + + constructor(name: string, store: StoreMap) { + this.name = name + this.store = store + } + + async match(key: Request | string): Promise { + return this.store.get(key) || null + } + + async keys() { + return this.store.keys() + } + + async put(key: Request | string, response: Response): Promise { + this.store.set(key, response) + } +} + +const globalStore: StoreMap = new Map() + +vi.stubGlobal('caches', { + open: (name: string) => { + return new MockCache(name, globalStore) + }, +}) + class Context implements ExecutionContext { passThroughOnException(): void { throw new Error('Method not implemented.') @@ -170,47 +203,6 @@ describe('Cache Middleware', () => { return c.text('cached') }) - let varyWildcardOnlyCount = 0 - app.use('/vary-wildcard/*', cache({ cacheName: 'vary-wildcard-test', wait: true })) - app.get('/vary-wildcard/only', (c) => { - varyWildcardOnlyCount++ - c.header('X-Count', `${varyWildcardOnlyCount}`) - c.header('Vary', '*') - return c.text('response') - }) - - let varyWildcardFirstCount = 0 - app.get('/vary-wildcard/first', (c) => { - varyWildcardFirstCount++ - c.header('X-Count', `${varyWildcardFirstCount}`) - c.header('Vary', '*, Accept') - return c.text('response') - }) - - let varyWildcardMiddleCount = 0 - app.get('/vary-wildcard/middle', (c) => { - varyWildcardMiddleCount++ - c.header('X-Count', `${varyWildcardMiddleCount}`) - c.header('Vary', 'Accept, *') - return c.text('response') - }) - - let varyWildcardComplexCount = 0 - app.get('/vary-wildcard/complex', (c) => { - varyWildcardComplexCount++ - c.header('X-Count', `${varyWildcardComplexCount}`) - c.header('Vary', 'Accept, *, Accept-Encoding') - return c.text('response') - }) - - let varyWildcardSpacesCount = 0 - app.get('/vary-wildcard/spaces', (c) => { - varyWildcardSpacesCount++ - c.header('X-Count', `${varyWildcardSpacesCount}`) - c.header('Vary', ' * ') - return c.text('response') - }) - app.use('/default/*', cache({ cacheName: 'my-app-v1', wait: true, cacheControl: 'max-age=10' })) app.all('/default/:code/', (c) => { const code = parseInt(c.req.param('code')) @@ -333,36 +325,6 @@ describe('Cache Middleware', () => { expect(() => cache({ cacheName: 'my-app-v1', wait: true, vary: '*' })).toThrow() }) - it('Should not cache response when Vary: * is set', async () => { - await app.request('http://localhost/vary-wildcard/only') - const res = await app.request('http://localhost/vary-wildcard/only') - expect(res.headers.get('x-count')).toBe('2') - }) - - it('Should not cache response when Vary: *, Accept is set', async () => { - await app.request('http://localhost/vary-wildcard/first') - const res = await app.request('http://localhost/vary-wildcard/first') - expect(res.headers.get('x-count')).toBe('2') - }) - - it('Should not cache response when Vary: Accept, * is set', async () => { - await app.request('http://localhost/vary-wildcard/middle') - const res = await app.request('http://localhost/vary-wildcard/middle') - expect(res.headers.get('x-count')).toBe('2') - }) - - it('Should not cache response when Vary: Accept, *, Accept-Encoding is set', async () => { - await app.request('http://localhost/vary-wildcard/complex') - const res = await app.request('http://localhost/vary-wildcard/complex') - expect(res.headers.get('x-count')).toBe('2') - }) - - it('Should not cache response when Vary contains * with extra spaces', async () => { - await app.request('http://localhost/vary-wildcard/spaces') - const res = await app.request('http://localhost/vary-wildcard/spaces') - expect(res.headers.get('x-count')).toBe('2') - }) - it.each([200])('Should cache %i in default cacheable status codes', async (code) => { await app.request(`http://localhost/default/${code}/`) const res = await app.request(`http://localhost/default/${code}/`) @@ -416,15 +378,66 @@ describe('Cache Middleware', () => { expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe(null) }) + + it('Should call onCacheNotAvailable when caches is not defined', async () => { + vi.stubGlobal('caches', undefined) + const spy = vi.fn() + const app = new Hono() + app.use(cache({ cacheName: 'my-app-v1', onCacheNotAvailable: spy })) + app.get('/', (c) => { + return c.text('cached') + }) + const res = await app.request('/') + expect(res.status).toBe(200) + expect(spy).toHaveBeenCalledOnce() + vi.unstubAllGlobals() + }) + + it('Should suppress default log when onCacheNotAvailable is false', async () => { + vi.stubGlobal('caches', undefined) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const app = new Hono() + app.use(cache({ cacheName: 'my-app-v1', onCacheNotAvailable: false })) + app.get('/', (c) => { + return c.text('cached') + }) + const res = await app.request('/') + expect(res.status).toBe(200) + expect(logSpy).not.toHaveBeenCalledWith( + 'Cache Middleware is not enabled because caches is not defined.' + ) + logSpy.mockRestore() + vi.unstubAllGlobals() + }) }) describe('Cache Skipping Logic', () => { + const stubStoreBackedCache = () => { + const store = new Map() + const mockCache = { + match: vi.fn((key: string | Request) => Promise.resolve(store.get(key)?.clone())), + put: vi.fn((key: string | Request, response: Response) => { + store.set(key, response.clone()) + return Promise.resolve() + }), + keys: vi.fn(() => Promise.resolve(Array.from(store.keys()))), + } + + vi.stubGlobal('caches', { + open: vi.fn().mockResolvedValue(mockCache), + }) + + return { mockCache } + } + let putSpy: ReturnType + let matchSpy: ReturnType beforeEach(() => { + matchSpy = vi.fn().mockResolvedValue(undefined) putSpy = vi.fn() const mockCache = { - match: vi.fn().mockResolvedValue(undefined), // Always miss + match: matchSpy, // Always miss unless a test overrides it put: putSpy, // We spy on this keys: vi.fn().mockResolvedValue([]), } @@ -499,4 +512,242 @@ describe('Cache Skipping Logic', () => { // IMPORTANT: put() SHOULD be called for normal responses expect(putSpy).toHaveBeenCalled() }) + + it.each(['Accept-Language', 'Authorization', 'X-Tenant'])( + 'Should NOT cache response if Vary: %s is set', + async (vary) => { + const app = new Hono() + app.use('*', cache({ cacheName: 'skip-test', wait: true })) + app.get('/', (c) => { + c.header('Vary', vary) + return c.text('response') + }) + + await app.request('/') + expect(putSpy).not.toHaveBeenCalled() + } + ) + + it('Should set configured Vary header and cache by the configured request header value', async () => { + const { mockCache } = stubStoreBackedCache() + const app = new Hono() + let count = 0 + app.use('*', cache({ cacheName: 'skip-test', wait: true, vary: 'Accept' })) + app.get('/', (c) => { + count++ + c.header('X-Count', `${count}`) + return c.text('response') + }) + + await app.request('/') + const res = await app.request('/') + expect(res.headers.get('Vary')).toBe('accept') + expect(res.headers.get('X-Count')).toBe('1') + expect(mockCache.put).toHaveBeenCalledOnce() + }) + + it('Should store separate variants for different Accept-Language request values', async () => { + const { mockCache } = stubStoreBackedCache() + const app = new Hono() + let count = 0 + app.use('*', cache({ cacheName: 'vary-language-test', wait: true, vary: 'Accept-Language' })) + app.get('/', (c) => { + count++ + c.header('X-Count', `${count}`) + return c.text(c.req.header('Accept-Language') ?? 'missing') + }) + + await app.request('/', { + headers: { + 'Accept-Language': 'en', + }, + }) + const en = await app.request('/', { + headers: { + 'Accept-Language': 'en', + }, + }) + await app.request('/', { + headers: { + 'Accept-Language': 'ja', + }, + }) + const ja = await app.request('/', { + headers: { + 'Accept-Language': 'ja', + }, + }) + + expect(await en.text()).toBe('en') + expect(en.headers.get('X-Count')).toBe('1') + expect(await ja.text()).toBe('ja') + expect(ja.headers.get('X-Count')).toBe('2') + expect(mockCache.put).toHaveBeenCalledTimes(2) + }) + + it('Should keep missing and present Vary request header values in separate variants', async () => { + stubStoreBackedCache() + const app = new Hono() + let count = 0 + app.use('*', cache({ cacheName: 'vary-missing-test', wait: true, vary: 'Accept-Language' })) + app.get('/', (c) => { + count++ + c.header('X-Count', `${count}`) + return c.text(c.req.header('Accept-Language') ?? 'missing') + }) + + await app.request('/') + await app.request('/', { + headers: { + 'Accept-Language': 'en', + }, + }) + const missing = await app.request('/') + const present = await app.request('/', { + headers: { + 'Accept-Language': 'en', + }, + }) + + expect(await missing.text()).toBe('missing') + expect(missing.headers.get('X-Count')).toBe('1') + expect(await present.text()).toBe('en') + expect(present.headers.get('X-Count')).toBe('2') + }) + + it('Should store separate variants using multiple configured request headers', async () => { + const { mockCache } = stubStoreBackedCache() + const app = new Hono() + let count = 0 + app.use( + '*', + cache({ cacheName: 'vary-multiple-test', wait: true, vary: ['Accept', 'Accept-Encoding'] }) + ) + app.get('/', (c) => { + count++ + c.header('X-Count', `${count}`) + return c.text(`${c.req.header('Accept')}:${c.req.header('Accept-Encoding')}`) + }) + + await app.request('/', { + headers: { + Accept: 'text/plain', + 'Accept-Encoding': 'gzip', + }, + }) + const same = await app.request('/', { + headers: { + Accept: 'text/plain', + 'Accept-Encoding': 'gzip', + }, + }) + await app.request('/', { + headers: { + Accept: 'application/json', + 'Accept-Encoding': 'gzip', + }, + }) + const differentAccept = await app.request('/', { + headers: { + Accept: 'application/json', + 'Accept-Encoding': 'gzip', + }, + }) + await app.request('/', { + headers: { + Accept: 'text/plain', + 'Accept-Encoding': 'br', + }, + }) + const differentEncoding = await app.request('/', { + headers: { + Accept: 'text/plain', + 'Accept-Encoding': 'br', + }, + }) + + expect(same.headers.get('X-Count')).toBe('1') + expect(differentAccept.headers.get('X-Count')).toBe('2') + expect(differentEncoding.headers.get('X-Count')).toBe('3') + expect(mockCache.put).toHaveBeenCalledTimes(3) + }) + + it('Should store when the handler Vary header is covered by configured Vary headers', async () => { + const { mockCache } = stubStoreBackedCache() + const app = new Hono() + app.use('*', cache({ cacheName: 'vary-covered-test', wait: true, vary: 'Accept-Language' })) + app.get('/', (c) => { + c.header('Vary', 'Accept-Language') + return c.text('response') + }) + + await app.request('/') + expect(mockCache.put).toHaveBeenCalledOnce() + }) + + it('Should not store when the handler adds Vary headers not covered by configured Vary headers', async () => { + const app = new Hono() + app.use('*', cache({ cacheName: 'skip-test', wait: true, vary: 'Accept-Language' })) + app.get('/', (c) => { + c.header('Vary', 'Accept-Language, Cookie') + return c.text('response') + }) + + await app.request('/') + expect(putSpy).not.toHaveBeenCalled() + }) + + it('Should not store Vary: * responses even when Vary headers are configured', async () => { + const app = new Hono() + app.use('*', cache({ cacheName: 'skip-test', wait: true, vary: 'Accept-Language' })) + app.get('/', (c) => { + c.header('Vary', '*') + return c.text('response') + }) + + await app.request('/') + expect(putSpy).not.toHaveBeenCalled() + }) + + it('Should bypass cache for Authorization requests', async () => { + const app = new Hono() + app.use('*', cache({ cacheName: 'skip-test', wait: true, cacheControl: 'max-age=10' })) + app.get('/', (c) => { + return c.text('fresh') + }) + + const res = await app.request('/', { + headers: { + Authorization: 'Bearer token', + }, + }) + expect(await res.text()).toBe('fresh') + expect(caches.open).not.toHaveBeenCalled() + expect(putSpy).not.toHaveBeenCalled() + }) + + it('Should not use or store cache for POST requests', async () => { + const app = new Hono() + let postCount = 0 + app.use('*', cache({ cacheName: 'skip-test', wait: true, cacheControl: 'max-age=10' })) + app.get('/resource', (c) => { + return c.text('get response') + }) + app.post('/resource', (c) => { + postCount++ + c.header('X-Count', `${postCount}`) + return c.text('post response') + }) + + await app.request('/resource') + putSpy.mockClear() + matchSpy.mockClear() + + await app.request('/resource', { method: 'POST' }) + const res = await app.request('/resource', { method: 'POST' }) + expect(res.headers.get('X-Count')).toBe('2') + expect(res.headers.get('Cache-Control')).toBeNull() + expect(matchSpy).not.toHaveBeenCalled() + expect(putSpy).not.toHaveBeenCalled() + }) }) diff --git a/src/middleware/cache/index.ts b/src/middleware/cache/index.ts index 1f4c311a3b..c058d90653 100644 --- a/src/middleware/cache/index.ts +++ b/src/middleware/cache/index.ts @@ -12,27 +12,28 @@ import type { StatusCode } from '../../utils/http-status' */ const defaultCacheableStatusCodes: ReadonlyArray = [200] -const shouldSkipCache = (res: Response) => { - const vary = res.headers.get('Vary') - if (vary && vary.includes('*')) { - return true - } - - const cacheControl = res.headers.get('Cache-Control') - if ( - cacheControl && - /(?:^|,\s*)(?:private|no-(?:store|cache))(?:\s*(?:=|,|$))/i.test(cacheControl) - ) { - return true - } +const shouldSkipCacheControl = (cacheControl: string | null): boolean => + !!cacheControl && /(?:^|,\s*)(?:private|no-(?:store|cache))(?:\s*(?:=|,|$))/i.test(cacheControl) - if (res.headers.has('Set-Cookie')) { - return true +const parseVaryDirectives = (vary: string | string[] | null | undefined): string[] => { + if (vary == null) { + return [] } - - return false + return (Array.isArray(vary) ? vary : vary.split(',')) + .map((directive) => directive.trim().toLowerCase()) + .filter(Boolean) } +const shouldSkipCache = ( + res: Response, + optionsVaryDirectives: Set | undefined, + responseVary: string[] +): boolean => + (responseVary.length && + (!optionsVaryDirectives || responseVary.some((name) => !optionsVaryDirectives.has(name)))) || + shouldSkipCacheControl(res.headers.get('Cache-Control')) || + res.headers.has('Set-Cookie') + /** * Cache Middleware for Hono. * @@ -42,9 +43,10 @@ const shouldSkipCache = (res: Response) => { * @param {string | Function} options.cacheName - The name of the cache. Can be used to store multiple caches with different identifiers. * @param {boolean} [options.wait=false] - A boolean indicating if Hono should wait for the Promise of the `cache.put` function to resolve before continuing with the request. Required to be true for the Deno environment. * @param {string} [options.cacheControl] - A string of directives for the `Cache-Control` header. - * @param {string | string[]} [options.vary] - Sets the `Vary` header in the response. If the original response header already contains a `Vary` header, the values are merged, removing any duplicates. + * @param {string | string[]} [options.vary] - Adds the configured request headers to the cache key variants and sets the `Vary` header in the response. If the original response header already contains a `Vary` header, the values are merged, removing any duplicates. * @param {Function} [options.keyGenerator] - Generates keys for every request in the `cacheName` store. This can be used to cache data based on request parameters or context parameters. * @param {number[]} [options.cacheableStatusCodes=[200]] - An array of status codes that can be cached. + * @param {Function | false} [options.onCacheNotAvailable] - A callback invoked when `globalThis.caches` is not available. By default, a message is logged to the console. Set to `false` to suppress the log, or provide a custom function. * @returns {MiddlewareHandler} The middleware handler function. * @throws {Error} If the `vary` option includes "*". * @@ -66,9 +68,16 @@ export const cache = (options: { vary?: string | string[] keyGenerator?: (c: Context) => Promise | string cacheableStatusCodes?: StatusCode[] + onCacheNotAvailable?: (() => void) | false }): MiddlewareHandler => { if (!globalThis.caches) { - console.log('Cache Middleware is not enabled because caches is not defined.') + if (options.onCacheNotAvailable === false) { + // suppress log + } else if (options.onCacheNotAvailable) { + options.onCacheNotAvailable() + } else { + console.log('Cache Middleware is not enabled because caches is not defined.') + } return async (_c, next) => await next() } @@ -79,12 +88,11 @@ export const cache = (options: { const cacheControlDirectives = options.cacheControl ?.split(',') .map((directive) => directive.toLowerCase()) - const varyDirectives = Array.isArray(options.vary) - ? options.vary - : options.vary?.split(',').map((directive) => directive.trim()) + const optionsVaryList = parseVaryDirectives(options.vary) + const varyDirectives = optionsVaryList.length ? new Set(optionsVaryList) : undefined // RFC 7231 Section 7.1.4 specifies that "*" is not allowed in Vary header. // See: https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.4 - if (options.vary?.includes('*')) { + if (varyDirectives?.has('*')) { throw new Error( 'Middleware vary configuration cannot include "*", as it disallows effective caching.' ) @@ -94,7 +102,7 @@ export const cache = (options: { options.cacheableStatusCodes ?? defaultCacheableStatusCodes ) - const addHeader = (c: Context) => { + const addHeader = (c: Context, responseVary: string[]) => { if (cacheControlDirectives) { const existingDirectives = c.res.headers @@ -111,31 +119,38 @@ export const cache = (options: { } if (varyDirectives) { - const existingDirectives = - c.res.headers - .get('Vary') - ?.split(',') - .map((d) => d.trim()) ?? [] - - const vary = Array.from( - new Set( - [...existingDirectives, ...varyDirectives].map((directive) => directive.toLowerCase()) - ) - ).sort() - - if (vary.includes('*')) { - c.header('Vary', '*') + if (responseVary.length === 0) { + c.header('Vary', Array.from(varyDirectives).join(', ')) } else { - c.header('Vary', vary.join(', ')) + const merged = new Set(varyDirectives) + for (const directive of responseVary) { + merged.add(directive) + } + if (merged.has('*')) { + c.header('Vary', '*') + } else { + c.header('Vary', Array.from(merged).join(', ')) + } } } } return async function cache(c, next) { + if (c.req.method !== 'GET' || c.req.raw.headers.has('Authorization')) { + await next() + return + } + let key = c.req.url if (options.keyGenerator) { key = await options.keyGenerator(c) } + if (varyDirectives) { + for (const directive of varyDirectives) { + const value = c.req.raw.headers.get(directive) ?? '' + key += `::${directive}=${encodeURIComponent(value)}` + } + } const cacheName = typeof options.cacheName === 'function' ? await options.cacheName(c) : options.cacheName @@ -149,9 +164,10 @@ export const cache = (options: { if (!cacheableStatusCodes.has(c.res.status)) { return } - addHeader(c) + const responseVary = parseVaryDirectives(c.res.headers.get('Vary')) + addHeader(c, responseVary) - if (shouldSkipCache(c.res)) { + if (shouldSkipCache(c.res, varyDirectives, responseVary)) { return } diff --git a/src/middleware/compress/index.test.ts b/src/middleware/compress/index.test.ts index fe28e607b7..a020ec2be0 100644 --- a/src/middleware/compress/index.test.ts +++ b/src/middleware/compress/index.test.ts @@ -193,6 +193,102 @@ describe('Compress Middleware', () => { }) }) + describe('ETag Handling', () => { + const app = new Hono() + app.use('*', compress()) + app.get('/strong-etag', (c) => { + c.header('Content-Type', 'text/plain') + c.header('Content-Length', '1024') + c.header('ETag', '"strong-etag"') + return c.text('a'.repeat(1024)) + }) + app.get('/weak-etag', (c) => { + c.header('Content-Type', 'text/plain') + c.header('Content-Length', '1024') + c.header('ETag', 'W/"weak-etag"') + return c.text('a'.repeat(1024)) + }) + app.get('/no-etag', (c) => { + c.header('Content-Type', 'text/plain') + c.header('Content-Length', '1024') + return c.text('a'.repeat(1024)) + }) + + it('should convert strong ETag to weak ETag when compressing', async () => { + const res = await app.request('/strong-etag', { + headers: { 'Accept-Encoding': 'gzip' }, + }) + expect(res.headers.get('Content-Encoding')).toBe('gzip') + expect(res.headers.get('ETag')).toBe('W/"strong-etag"') + }) + + it('should keep strong ETag when not compressing', async () => { + const res = await app.request('/strong-etag') + expect(res.headers.get('Content-Encoding')).toBeNull() + expect(res.headers.get('ETag')).toBe('"strong-etag"') + }) + + it('should not modify weak ETag when compressing', async () => { + const res = await app.request('/weak-etag', { + headers: { 'Accept-Encoding': 'gzip' }, + }) + expect(res.headers.get('Content-Encoding')).toBe('gzip') + expect(res.headers.get('ETag')).toBe('W/"weak-etag"') + }) + + it('should not add ETag when none exists', async () => { + const res = await app.request('/no-etag', { + headers: { 'Accept-Encoding': 'gzip' }, + }) + expect(res.headers.get('Content-Encoding')).toBe('gzip') + expect(res.headers.get('ETag')).toBeNull() + }) + }) + + describe('contentTypeFilter', () => { + describe('RegExp', () => { + const app = new Hono() + app.use('*', compress({ contentTypeFilter: /^application\/json/, threshold: 0 })) + app.get('/json', (c) => c.json({ data: 'a'.repeat(1024) })) + app.get('/text', (c) => c.text('a'.repeat(1024))) + + it('should compress when Content-Type matches', async () => { + const res = await app.request('/json', { + headers: { 'Accept-Encoding': 'gzip' }, + }) + expect(res.headers.get('Content-Encoding')).toBe('gzip') + }) + + it('should not compress when Content-Type does not match', async () => { + const res = await app.request('/text', { + headers: { 'Accept-Encoding': 'gzip' }, + }) + expect(res.headers.get('Content-Encoding')).toBeNull() + }) + }) + + describe('function', () => { + const app = new Hono() + app.use('*', compress({ contentTypeFilter: (type) => type.includes('json'), threshold: 0 })) + app.get('/json', (c) => c.json({ data: 'a'.repeat(1024) })) + app.get('/text', (c) => c.text('a'.repeat(1024))) + + it('should compress when Content-Type matches', async () => { + const res = await app.request('/json', { + headers: { 'Accept-Encoding': 'gzip' }, + }) + expect(res.headers.get('Content-Encoding')).toBe('gzip') + }) + + it('should not compress when Content-Type does not match', async () => { + const res = await app.request('/text', { + headers: { 'Accept-Encoding': 'gzip' }, + }) + expect(res.headers.get('Content-Encoding')).toBeNull() + }) + }) + }) + describe('Edge Cases', () => { it('should not compress responses with Cache-Control: no-transform', async () => { await testCompression('/no-transform', 'gzip', null) @@ -214,6 +310,121 @@ describe('Compress Middleware', () => { expect(decompressed).toBe('Custom NotFound') }) }) + + describe('Encoding Option', () => { + const buildApp = (encoding: 'gzip' | 'deflate') => { + const app = new Hono() + app.use('*', compress({ encoding })) + app.get('/large', (c) => { + c.header('Content-Type', 'text/plain') + c.header('Content-Length', '1024') + return c.text('a'.repeat(1024)) + }) + return app + } + + it('should compress when configured encoding is accepted by the client', async () => { + const app = buildApp('gzip') + const res = await app.request('/large', { + headers: { 'Accept-Encoding': 'gzip' }, + }) + expect(res.headers.get('Content-Encoding')).toBe('gzip') + }) + + it('should not compress when configured encoding is not in Accept-Encoding', async () => { + const app = buildApp('gzip') + const res = await app.request('/large', { + headers: { 'Accept-Encoding': 'deflate' }, + }) + expect(res.headers.get('Content-Encoding')).toBeNull() + }) + + it('should not compress when Accept-Encoding header is absent', async () => { + const app = buildApp('gzip') + const res = await app.request('/large') + expect(res.headers.get('Content-Encoding')).toBeNull() + }) + }) + + describe('Accept-Encoding parsing', () => { + const buildAppDefault = () => { + const app = new Hono() + app.use('*', compress()) + app.get('/large', (c) => { + c.header('Content-Type', 'text/plain') + c.header('Content-Length', '1024') + return c.text('a'.repeat(1024)) + }) + return app + } + + const buildAppWithEncoding = (encoding: 'gzip' | 'deflate') => { + const app = new Hono() + app.use('*', compress({ encoding })) + app.get('/large', (c) => { + c.header('Content-Type', 'text/plain') + c.header('Content-Length', '1024') + return c.text('a'.repeat(1024)) + }) + return app + } + + it('should not compress when configured encoding has q=0', async () => { + const app = buildAppWithEncoding('gzip') + const res = await app.request('/large', { + headers: { 'Accept-Encoding': 'gzip;q=0' }, + }) + expect(res.headers.get('Content-Encoding')).toBeNull() + }) + + it('should fall back to another encoding when the preferred one has q=0', async () => { + const app = buildAppDefault() + const res = await app.request('/large', { + headers: { 'Accept-Encoding': 'gzip;q=0, deflate' }, + }) + expect(res.headers.get('Content-Encoding')).toBe('deflate') + }) + + it('should compress when Accept-Encoding is the wildcard *', async () => { + const app = buildAppWithEncoding('gzip') + const res = await app.request('/large', { + headers: { 'Accept-Encoding': '*' }, + }) + expect(res.headers.get('Content-Encoding')).toBe('gzip') + }) + + it('should compress when Accept-Encoding token differs only in case', async () => { + const app = buildAppWithEncoding('gzip') + const res = await app.request('/large', { + headers: { 'Accept-Encoding': 'GZIP' }, + }) + expect(res.headers.get('Content-Encoding')).toBe('gzip') + }) + + it('should not treat x-gzip as gzip', async () => { + const app = buildAppWithEncoding('gzip') + const res = await app.request('/large', { + headers: { 'Accept-Encoding': 'x-gzip' }, + }) + expect(res.headers.get('Content-Encoding')).toBeNull() + }) + + it('should pick the encoding with the highest q value', async () => { + const app = buildAppDefault() + const res = await app.request('/large', { + headers: { 'Accept-Encoding': 'gzip;q=0.5, deflate;q=0.9' }, + }) + expect(res.headers.get('Content-Encoding')).toBe('deflate') + }) + + it('should prefer gzip over deflate when q values are equal', async () => { + const app = buildAppDefault() + const res = await app.request('/large', { + headers: { 'Accept-Encoding': 'gzip;q=0.5, deflate;q=0.5' }, + }) + expect(res.headers.get('Content-Encoding')).toBe('gzip') + }) + }) }) async function decompressResponse(res: Response): Promise { diff --git a/src/middleware/compress/index.ts b/src/middleware/compress/index.ts index 34dd9cb48a..8f25bb8101 100644 --- a/src/middleware/compress/index.ts +++ b/src/middleware/compress/index.ts @@ -4,14 +4,43 @@ */ import type { MiddlewareHandler } from '../../types' +import { parseAccept } from '../../utils/accept' import { COMPRESSIBLE_CONTENT_TYPE_REGEX } from '../../utils/compress' +export { COMPRESSIBLE_CONTENT_TYPE_REGEX } + const ENCODING_TYPES = ['gzip', 'deflate'] as const +type Encoding = (typeof ENCODING_TYPES)[number] const cacheControlNoTransformRegExp = /(?:^|,)\s*?no-transform\s*?(?:,|$)/i +type ContentTypeFilter = RegExp | ((contentType: string) => boolean) + interface CompressionOptions { - encoding?: (typeof ENCODING_TYPES)[number] + encoding?: Encoding threshold?: number + contentTypeFilter?: ContentTypeFilter +} + +const selectEncoding = ( + header: string | undefined, + candidates: readonly Encoding[] +): Encoding | undefined => { + if (header === undefined) { + return undefined + } + const accepts = parseAccept(header) + const wildcardQ = accepts.find((a) => a.type === '*')?.q + let best: { encoding: Encoding; q: number } | undefined + for (const enc of candidates) { + const explicit = accepts.find((a) => a.type.toLowerCase() === enc) + const q = explicit ? explicit.q : (wildcardQ ?? 0) + if (q === 1) { + return enc + } else if (q > 0 && (!best || q > best.q)) { + best = { encoding: enc, q } + } + } + return best?.encoding } /** @@ -22,6 +51,7 @@ interface CompressionOptions { * @param {CompressionOptions} [options] - The options for the compress middleware. * @param {'gzip' | 'deflate'} [options.encoding] - The compression scheme to allow for response compression. Either 'gzip' or 'deflate'. If not defined, both are allowed and will be used based on the Accept-Encoding header. 'gzip' is prioritized if this option is not provided and the client provides both in the Accept-Encoding header. * @param {number} [options.threshold=1024] - The minimum size in bytes to compress. Defaults to 1024 bytes. + * @param {RegExp | Function} [options.contentTypeFilter=COMPRESSIBLE_CONTENT_TYPE_REGEX] - A RegExp or function to determine if the response Content-Type should be compressed. * @returns {MiddlewareHandler} The middleware handler function. * * @example @@ -29,10 +59,29 @@ interface CompressionOptions { * const app = new Hono() * * app.use(compress()) + * + * // Compress only JSON responses + * app.use(compress({ contentTypeFilter: /^application\/json/ })) + * + * // Compress based on custom Content-Type logic + * app.use(compress({ contentTypeFilter: (type) => COMPRESSIBLE_CONTENT_TYPE_REGEX.test(type) || type === "application/x-myformat" })) * ``` */ export const compress = (options?: CompressionOptions): MiddlewareHandler => { const threshold = options?.threshold ?? 1024 + const candidates: readonly Encoding[] = options?.encoding ? [options.encoding] : ENCODING_TYPES + + const contentTypeFilter = options?.contentTypeFilter ?? COMPRESSIBLE_CONTENT_TYPE_REGEX + const shouldCompress = + typeof contentTypeFilter === 'function' + ? (res: Response) => { + const type = res.headers.get('Content-Type') + return type && contentTypeFilter(type) + } + : (res: Response) => { + const type = res.headers.get('Content-Type') + return type && contentTypeFilter.test(type) + } return async function compress(ctx, next) { await next() @@ -52,8 +101,7 @@ export const compress = (options?: CompressionOptions): MiddlewareHandler => { } const accepted = ctx.req.header('Accept-Encoding') - const encoding = - options?.encoding ?? ENCODING_TYPES.find((encoding) => accepted?.includes(encoding)) + const encoding = selectEncoding(accepted, candidates) if (!encoding || !ctx.res.body) { return } @@ -63,12 +111,13 @@ export const compress = (options?: CompressionOptions): MiddlewareHandler => { ctx.res = new Response(ctx.res.body.pipeThrough(stream), ctx.res) ctx.res.headers.delete('Content-Length') ctx.res.headers.set('Content-Encoding', encoding) - } -} -const shouldCompress = (res: Response) => { - const type = res.headers.get('Content-Type') - return type && COMPRESSIBLE_CONTENT_TYPE_REGEX.test(type) + // Convert strong ETag to weak ETag since compressed content is not byte-identical + const etag = ctx.res.headers.get('ETag') + if (etag && !etag.startsWith('W/')) { + ctx.res.headers.set('ETag', `W/${etag}`) + } + } } const shouldTransform = (res: Response) => { diff --git a/src/middleware/cors/index.test.ts b/src/middleware/cors/index.test.ts index fbd64a613c..037841da35 100644 --- a/src/middleware/cors/index.test.ts +++ b/src/middleware/cors/index.test.ts @@ -84,6 +84,14 @@ describe('CORS by Middleware', () => { }) ) + app.use( + '/api10/*', + cors({ + origin: '*', + credentials: true, + }) + ) + app.get('/api/abc', (c) => { return c.json({ success: true }) }) @@ -124,6 +132,10 @@ describe('CORS by Middleware', () => { return new Response(JSON.stringify({ success: true })) }) + app.get('/api10/abc', (c) => { + return c.json({ success: true }) + }) + it('GET default', async () => { const res = await app.request('http://localhost/api/abc') @@ -349,4 +361,113 @@ describe('CORS by Middleware', () => { expect(res2.headers.get('Access-Control-Allow-Origin')).toBe('*') expect(res2.headers.get('Access-Control-Allow-Methods')).toBe('GET,HEAD') }) + + it('Emits the wildcard, not the reflected origin, when credentials is true with wildcard origin', async () => { + const res = await app.request('http://localhost/api10/abc', { + headers: { + Origin: 'http://example.com', + }, + }) + + expect(res.status).toBe(200) + // Must not reflect the request origin; the wildcard + credentials combination + // is rejected by the browser (fail closed). A wildcard response does not vary + // by Origin, so no Vary header is set. + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*') + expect(res.headers.get('Access-Control-Allow-Credentials')).toBe('true') + expect(res.headers.get('Vary')).toBeNull() + }) + + it('Preflight emits the wildcard, not the reflected origin, when credentials is true with wildcard origin', async () => { + const req = new Request('http://localhost/api10/abc', { + method: 'OPTIONS', + headers: { + Origin: 'http://example.com', + }, + }) + const res = await app.request(req) + + expect(res.status).toBe(204) + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*') + expect(res.headers.get('Access-Control-Allow-Credentials')).toBe('true') + }) + + it('Emits the wildcard with no Origin header when credentials is true with wildcard origin', async () => { + const res = await app.request('http://localhost/api10/abc') + + expect(res.status).toBe(200) + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*') + expect(res.headers.get('Access-Control-Allow-Credentials')).toBe('true') + }) + + it('Emits the wildcard for every origin (never reflects) when credentials is true with wildcard origin', async () => { + const res1 = await app.request('http://localhost/api10/abc', { + headers: { Origin: 'http://example.com' }, + }) + const res2 = await app.request('http://localhost/api10/abc', { + headers: { Origin: 'http://other.com' }, + }) + + expect(res1.headers.get('Access-Control-Allow-Origin')).toBe('*') + expect(res2.headers.get('Access-Control-Allow-Origin')).toBe('*') + }) + + it('Origin: null gets the wildcard, not a reflected null origin, with credentials', async () => { + const res = await app.request('http://localhost/api10/abc', { + headers: { Origin: 'null' }, + }) + + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*') + expect(res.headers.get('Access-Control-Allow-Credentials')).toBe('true') + }) + + it('Should not reflect an arbitrary Origin when credentials is true with default origin', async () => { + const app = new Hono() + app.use('*', cors({ credentials: true })) + app.get('/api/me', (c) => c.json({ secret: 'private user data' })) + + // A wildcard/default origin must not be turned into a reflect-any-origin + // credentialed policy; the browser must keep rejecting it (fail closed). + for (const origin of ['https://attacker.example', 'http://evil.test', 'null']) { + const res = await app.request('http://localhost/api/me', { headers: { Origin: origin } }) + expect(res.headers.get('Access-Control-Allow-Origin')).not.toBe(origin) + } + }) + + it('Preflight should not approve an arbitrary Origin when credentials is true with default origin', async () => { + const app = new Hono() + app.use('*', cors({ credentials: true })) + app.delete('/api/account', (c) => c.json({ deleted: true })) + + const res = await app.request( + new Request('http://localhost/api/account', { + method: 'OPTIONS', + headers: { + Origin: 'https://attacker.example', + 'Access-Control-Request-Method': 'DELETE', + 'Access-Control-Request-Headers': 'X-CSRF', + }, + }) + ) + + expect(res.headers.get('Access-Control-Allow-Origin')).not.toBe('https://attacker.example') + }) + + it('Options without origin fall back to wildcard default', async () => { + const subApp = new Hono() + subApp.use('/api/*', cors({ allowMethods: ['GET', 'POST'] })) + subApp.get('/api/abc', (c) => c.json({ ok: true })) + + const res = await subApp.request('http://localhost/api/abc') + expect(res.status).toBe(200) + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*') + + const preflight = await subApp.request( + new Request('http://localhost/api/abc', { method: 'OPTIONS' }) + ) + expect(preflight.headers.get('Access-Control-Allow-Methods')?.split(/\s*,\s*/)).toEqual([ + 'GET', + 'POST', + ]) + }) }) diff --git a/src/middleware/cors/index.ts b/src/middleware/cors/index.ts index d0565e12cb..a2333e0d65 100644 --- a/src/middleware/cors/index.ts +++ b/src/middleware/cors/index.ts @@ -7,7 +7,7 @@ import type { Context } from '../../context' import type { MiddlewareHandler } from '../../types' type CORSOptions = { - origin: + origin?: | string | string[] | (( @@ -61,16 +61,13 @@ type CORSOptions = { * ``` */ export const cors = (options?: CORSOptions): MiddlewareHandler => { - const defaults: CORSOptions = { + const opts = { origin: '*', allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH'], allowHeaders: [], exposeHeaders: [], - } - const opts = { - ...defaults, ...options, - } + } satisfies CORSOptions const findAllowOrigin = ((optsOrigin) => { if (typeof optsOrigin === 'string') { diff --git a/src/middleware/ip-restriction/index.test.ts b/src/middleware/ip-restriction/index.test.ts index 6c9530cefd..9f31105c7a 100644 --- a/src/middleware/ip-restriction/index.test.ts +++ b/src/middleware/ip-restriction/index.test.ts @@ -60,6 +60,60 @@ describe('ipRestriction middleware', () => { expect(await res.text()).toBe('error') } }) + + test.each(['999.999.999.999', '2001:db8::1%eth0', '1234:::5678'])( + 'Should reject invalid remote address: %s', + async (ip) => { + const app = new Hono<{ + Bindings: { + ip: string + } + }>() + app.use( + '/invalid', + ipRestriction( + (c) => ({ + remote: { + address: c.env.ip, + }, + }), + { + allowList: ['127.0.0.1'], + } + ) + ) + app.get('/invalid', (c) => c.text('Hello World!')) + + expect((await app.request('/invalid', {}, { ip })).status).toBe(403) + } + ) + + it('Should not call onError for invalid remote addresses', async () => { + const app = new Hono<{ + Bindings: { + ip: string + } + }>() + app.use( + '/invalid', + ipRestriction( + (c) => ({ + remote: { + address: c.env.ip, + }, + }), + { + allowList: ['127.0.0.1'], + }, + () => new Response('custom error', { status: 418 }) + ) + ) + app.get('/invalid', (c) => c.text('Hello World!')) + + const res = await app.request('/invalid', {}, { ip: '1234:::5678' }) + expect(res.status).toBe(403) + expect(await res.text()).toBe('Forbidden') + }) }) describe('isMatchForRule', () => { @@ -84,6 +138,17 @@ describe('isMatchForRule', () => { return true } + test.each(['192.168.0.0/33', '::/129', '127.0.0.1/', '::ffff:127.0.0.1/129'])( + 'Should throw for invalid CIDR rule: %s', + (rule) => { + expect(() => + ipRestriction(() => '127.0.0.1', { + allowList: [rule], + }) + ).toThrow(`Invalid rule: ${rule}`) + } + ) + it('star', async () => { expect(await isMatch({ addr: '192.168.2.0', type: 'IPv4' }, '*')).toBeTruthy() expect(await isMatch({ addr: '192.168.2.1', type: 'IPv4' }, '*')).toBeTruthy() @@ -96,6 +161,24 @@ describe('isMatchForRule', () => { expect(await isMatch({ addr: '192.168.2.1', type: 'IPv4' }, '192.168.2.2/32')).toBeFalsy() expect(await isMatch({ addr: '::0', type: 'IPv6' }, '::0/1')).toBeTruthy() + expect(await isMatch({ addr: '::1', type: 'IPv6' }, '0.0.0.0/24')).toBeFalsy() + expect(await isMatch({ addr: '::abcd:1', type: 'IPv6' }, '127.0.0.0/8')).toBeFalsy() + expect(await isMatch({ addr: '::1', type: 'IPv6' }, '::ffff:1.0.0.0/96')).toBeFalsy() + + expect( + await isMatch({ addr: '::ffff:192.168.1.1', type: 'IPv6' }, '::ffff:192.168.1.0/120') + ).toBeTruthy() + expect( + await isMatch({ addr: '192.168.1.1', type: 'IPv4' }, '::ffff:192.168.1.0/120') + ).toBeTruthy() + expect( + await isMatch({ addr: '::ffff:192.168.1.1', type: 'IPv6' }, '192.168.1.0/24') + ).toBeTruthy() + expect(await isMatch({ addr: '::ffff:10.0.0.1', type: 'IPv6' }, '192.168.1.0/24')).toBeFalsy() + expect(await isMatch({ addr: '::ffff:192.168.1.1', type: 'IPv6' }, '::/0')).toBeTruthy() + expect( + await isMatch({ addr: '::ffff:192.168.1.1', type: 'IPv6' }, '::ffff:0:0/95') + ).toBeTruthy() }) it('Static Rules', async () => { expect(await isMatch({ addr: '192.168.2.1', type: 'IPv4' }, '192.168.2.1')).toBeTruthy() @@ -104,6 +187,22 @@ describe('isMatchForRule', () => { await isMatch({ addr: '::ffff:127.0.0.1', type: 'IPv6' }, '::ffff:127.0.0.1') ).toBeTruthy() expect(await isMatch({ addr: '::ffff:127.0.0.1', type: 'IPv6' }, '::ffff:7f00:1')).toBeTruthy() + expect(await isMatch({ addr: '127.0.0.1', type: 'IPv4' }, '::ffff:127.0.0.1')).toBeTruthy() + // IPv4-mapped IPv6 addresses are canonicalized to IPv4 + expect(await isMatch({ addr: '::ffff:127.0.0.1', type: 'IPv6' }, '127.0.0.1')).toBeTruthy() + expect(await isMatch({ addr: '::ffff:127.0.0.1', type: 'IPv6' }, '127.0.0.2')).toBeFalsy() + expect(await isMatch({ addr: '::ffff:7f00:1', type: 'IPv6' }, '127.0.0.1')).toBeTruthy() + expect(await isMatch({ addr: '0:0:0:0:0:ffff:7f00:1', type: 'IPv6' }, '127.0.0.1')).toBeTruthy() + // regular IPv6 is matched numerically without being treated as IPv4 + expect(await isMatch({ addr: '::1', type: 'IPv6' }, '::1')).toBeTruthy() + expect( + await isMatch({ addr: '2001:db8:0:0:0:0:0:1', type: 'IPv6' }, '2001:db8::1') + ).toBeTruthy() + expect( + await isMatch({ addr: '2001:db8::1', type: 'IPv6' }, '2001:db8:0:0:0:0:0:1') + ).toBeTruthy() + expect(await isMatch({ addr: '::ffff:127.0.0.2', type: 'IPv6' }, '127.0.0.1')).toBeFalsy() + expect(await isMatch({ addr: '::7f00:1', type: 'IPv6' }, '127.0.0.1')).toBeFalsy() }) it('Function Rules', async () => { expect(await isMatch({ addr: '0.0.0.0', type: 'IPv4' }, () => true)).toBeTruthy() diff --git a/src/middleware/ip-restriction/index.ts b/src/middleware/ip-restriction/index.ts index 5f741c7e74..37a9115521 100644 --- a/src/middleware/ip-restriction/index.ts +++ b/src/middleware/ip-restriction/index.ts @@ -6,11 +6,15 @@ import type { Context, MiddlewareHandler } from '../..' import type { AddressType, GetConnInfo } from '../../helper/conninfo' import { HTTPException } from '../../http-exception' +import type { InvalidIPAddressError } from '../../utils/ipaddr' import { + convertIPv4MappedIPv6ToIPv4, convertIPv4ToBinary, convertIPv6BinaryToString, convertIPv6ToBinary, distinctRemoteAddr, + isIPv4MappedIPv6, + INVALID_IP_ADDRESS_ERROR_CODE, } from '../../utils/ipaddr' /** @@ -33,13 +37,47 @@ type GetIPAddr = GetConnInfo | ((c: Context) => string) type IPRestrictionRuleFunction = (addr: { addr: string; type: AddressType }) => boolean export type IPRestrictionRule = string | ((addr: { addr: string; type: AddressType }) => boolean) -const IS_CIDR_NOTATION_REGEX = /\/[0-9]{0,3}$/ +const IS_CIDR_NOTATION_REGEX = /\/[^/]*$/ +const parseCidrPrefix = (rule: string, prefix: string, max: number): number => { + if (!/^[0-9]{1,3}$/.test(prefix)) { + throw new TypeError(`Invalid rule: ${rule}`) + } + const parsedPrefix = parseInt(prefix) + if (parsedPrefix > max) { + throw new TypeError(`Invalid rule: ${rule}`) + } + return parsedPrefix +} const buildMatcher = ( rules: IPRestrictionRule[] ): ((addr: { addr: string; type: AddressType; isIPv4: boolean }) => boolean) => { const functionRules: IPRestrictionRuleFunction[] = [] const staticRules: Set = new Set() + const staticIPv4Rules: Set = new Set() + const staticIPv6Rules: Set = new Set() const cidrRules: [boolean, bigint, bigint][] = [] + const registerStaticRule = (rule: string): void => { + const type = distinctRemoteAddr(rule) + if (type === undefined) { + throw new TypeError(`Invalid rule: ${rule}`) + } + if (type === 'IPv4') { + const ipv4binary = convertIPv4ToBinary(rule) + staticRules.add(rule) + staticRules.add(`::ffff:${rule}`) + staticIPv4Rules.add(ipv4binary) + staticIPv6Rules.add((0xffffn << 32n) | ipv4binary) + } else { + const ipv6binary = convertIPv6ToBinary(rule) + const ipv6Addr = convertIPv6BinaryToString(ipv6binary) + staticRules.add(ipv6Addr) + staticIPv6Rules.add(ipv6binary) + if (isIPv4MappedIPv6(ipv6binary)) { + staticRules.add(ipv6Addr.substring(7)) // remove ::ffff: prefix + staticIPv4Rules.add(convertIPv4MappedIPv6ToIPv4(ipv6binary)) + } + } + } for (let rule of rules) { if (rule === '*') { @@ -56,14 +94,20 @@ const buildMatcher = ( throw new TypeError(`Invalid rule: ${rule}`) } - const isIPv4 = type === 'IPv4' - const prefix = parseInt(separatedRule[1]) + let isIPv4 = type === 'IPv4' + let prefix = parseCidrPrefix(rule, separatedRule[1], isIPv4 ? 32 : 128) if (isIPv4 ? prefix === 32 : prefix === 128) { // this rule is a static rule rule = addrStr } else { - const addr = (isIPv4 ? convertIPv4ToBinary : convertIPv6ToBinary)(addrStr) + let addr = (isIPv4 ? convertIPv4ToBinary : convertIPv6ToBinary)(addrStr) + if (type === 'IPv6' && isIPv4MappedIPv6(addr) && prefix >= 96) { + isIPv4 = true + addr = convertIPv4MappedIPv6ToIPv4(addr) + prefix -= 96 + } + const mask = ((1n << BigInt(prefix)) - 1n) << BigInt((isIPv4 ? 32 : 128) - prefix) cidrRules.push([isIPv4, addr & mask, mask] as [boolean, bigint, bigint]) @@ -71,15 +115,7 @@ const buildMatcher = ( } } - const type = distinctRemoteAddr(rule) - if (type === undefined) { - throw new TypeError(`Invalid rule: ${rule}`) - } - staticRules.add( - type === 'IPv4' - ? rule // IPv4 address is already normalized, so it is registered as is. - : convertIPv6BinaryToString(convertIPv6ToBinary(rule)) // normalize IPv6 address (e.g. 0000:0000:0000:0000:0000:0000:0000:0001 => ::1) - ) + registerStaticRule(rule) } } @@ -92,13 +128,31 @@ const buildMatcher = ( if (staticRules.has(remote.addr)) { return true } + const remoteAddr = (remote.binaryAddr ||= ( + remote.isIPv4 ? convertIPv4ToBinary : convertIPv6ToBinary + )(remote.addr)) + const remoteIPv4Addr = + remote.isIPv4 || isIPv4MappedIPv6(remoteAddr) + ? remote.isIPv4 + ? remoteAddr + : convertIPv4MappedIPv6ToIPv4(remoteAddr) + : undefined + if ((remote.isIPv4 ? staticIPv4Rules : staticIPv6Rules).has(remoteAddr)) { + return true + } for (const [isIPv4, addr, mask] of cidrRules) { - if (isIPv4 !== remote.isIPv4) { + if (isIPv4) { + if (remoteIPv4Addr === undefined) { + continue + } + if ((remoteIPv4Addr & mask) === addr) { + return true + } + continue + } + if (remote.isIPv4) { continue } - const remoteAddr = (remote.binaryAddr ||= ( - isIPv4 ? convertIPv4ToBinary : convertIPv6ToBinary - )(remote.addr)) if ((remoteAddr & mask) === addr) { return true } @@ -121,9 +175,45 @@ export interface IPRestrictionRules { } /** - * IP Restriction Middleware + * IP Restriction Middleware for Hono. + * + * @see {@link https://hono.dev/docs/middleware/builtin/ip-restriction} + * + * @param {GetConnInfo | ((c: Context) => string)} getIP - A function to retrieve the client IP address. Use `getConnInfo` from the appropriate runtime adapter. + * @param {IPRestrictionRules} rules - An object with optional `denyList` and `allowList` arrays of IP rules. Each rule can be a static IP, a CIDR range, or a custom function. + * @param {(remote: { addr: string; type: AddressType }, c: Context) => Response | Promise} [onError] - Optional custom handler invoked when a request is blocked. Defaults to returning a 403 Forbidden response. + * @returns {MiddlewareHandler} The middleware handler function. + * + * @example + * ```ts + * import { Hono } from 'hono' + * import { ipRestriction } from 'hono/ip-restriction' + * import { getConnInfo } from 'hono/cloudflare-workers' + * + * const app = new Hono() + * + * app.use( + * '*', + * ipRestriction(getConnInfo, { + * // Block a specific IP and an entire subnet + * denyList: ['192.168.0.5', '10.0.0.0/8'], + * // Only allow requests from localhost and a private range + * allowList: ['127.0.0.1', '::1', '192.168.1.0/24'], + * }) + * ) + * + * // With a custom error handler + * app.use( + * '/admin/*', + * ipRestriction( + * getConnInfo, + * { allowList: ['203.0.113.0/24'] }, + * (remote, c) => c.text(`Access denied for ${remote.addr}`, 403) + * ) + * ) * - * @param getIP function to get IP Address + * app.get('/', (c) => c.text('Hello!')) + * ``` */ export const ipRestriction = ( getIP: GetIPAddr, @@ -156,14 +246,25 @@ export const ipRestriction = ( const remoteData = { addr, type, isIPv4: type === 'IPv4' } - if (denyMatcher(remoteData)) { - if (onError) { - return onError({ addr, type }, c) + try { + if (denyMatcher(remoteData)) { + if (onError) { + return onError({ addr, type }, c) + } + throw blockError(c) } - throw blockError(c) - } - if (allowMatcher(remoteData)) { - return await next() + if (allowMatcher(remoteData)) { + return await next() + } + } catch (e) { + if ( + e instanceof TypeError && + (e as InvalidIPAddressError).code === INVALID_IP_ADDRESS_ERROR_CODE + ) { + // If an invalid IP address is specified, treat it as if no IP address was specified + throw blockError(c) + } + throw e } if (allowLength === 0) { diff --git a/src/middleware/jwk/index.test.ts b/src/middleware/jwk/index.test.ts index 51c223052a..5ab0c3cbeb 100644 --- a/src/middleware/jwk/index.test.ts +++ b/src/middleware/jwk/index.test.ts @@ -80,12 +80,26 @@ describe('JWK', () => { expect(handlerExecuted).toBeTruthy() }) - it('Should not authorize if bad token is present', async () => { + it('Should not authorize if a non-Bearer scheme is present', async () => { + const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0]) + const url = 'http://localhost/backend-auth-or-anon/a' + const req = new Request(url) + req.headers.set('Authorization', `Basic ${credential}`) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(401) + expect(res.headers.get('www-authenticate')).toEqual( + `Bearer realm="${url}",error="invalid_request",error_description="invalid credentials structure"` + ) + expect(handlerExecuted).toBeFalsy() + }) + + it('Should not authorize if an invalid Bearer token is present', async () => { const invalidToken = 'ssyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8gd29ybGQifQ.B54pAqIiLbu170tGQ1rY06Twv__0qSHTA0ioQPIOvFE' const url = 'http://localhost/backend-auth-or-anon/a' const req = new Request(url) - req.headers.set('Authorization', `Basic ${invalidToken}`) + req.headers.set('Authorization', `Bearer ${invalidToken}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(401) @@ -226,7 +240,7 @@ describe('JWK', () => { it('Should return a server error if options.jwks_uri returns a 404', async () => { const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0]) const req = new Request('http://localhost/auth-with-404-jwks_uri/a') - req.headers.set('Authorization', `Basic ${credential}`) + req.headers.set('Authorization', `Bearer ${credential}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(500) @@ -235,7 +249,7 @@ describe('JWK', () => { it('Should return a server error if the remotely fetched keys from options.jwks_uri are missing', async () => { const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0]) const req = new Request('http://localhost/auth-with-missing-jwks_uri/a') - req.headers.set('Authorization', `Basic ${credential}`) + req.headers.set('Authorization', `Bearer ${credential}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(500) @@ -244,7 +258,7 @@ describe('JWK', () => { it('Should return a server error if the remotely fetched keys from options.jwks_uri are malformed', async () => { const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0]) const req = new Request('http://localhost/auth-with-bad-jwks_uri/a') - req.headers.set('Authorization', `Basic ${credential}`) + req.headers.set('Authorization', `Bearer ${credential}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(500) @@ -352,7 +366,18 @@ describe('JWK', () => { it('Should authorize with Unicode payload from a static array passed to options.keys', async () => { const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0]) const req = new Request('http://localhost/auth-with-keys-unicode/a') - req.headers.set('Authorization', `Basic ${credential}`) + req.headers.set('Authorization', `bearer ${credential}`) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ message: 'hello world' }) + expect(handlerExecuted).toBeTruthy() + }) + + it('Should authorize with mixed-case Bearer scheme', async () => { + const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0]) + const req = new Request('http://localhost/auth-with-keys-unicode/a') + req.headers.set('Authorization', `bEaReR ${credential}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(200) @@ -363,7 +388,7 @@ describe('JWK', () => { it('Should authorize from a function passed to options.keys', async () => { const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0]) const req = new Request('http://localhost/auth-with-keys-fn/a') - req.headers.set('Authorization', `Basic ${credential}`) + req.headers.set('Authorization', `Bearer ${credential}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(200) @@ -374,7 +399,7 @@ describe('JWK', () => { it('Should authorize from keys remotely fetched from options.jwks_uri', async () => { const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0]) const req = new Request('http://localhost/auth-with-jwks_uri/a') - req.headers.set('Authorization', `Basic ${credential}`) + req.headers.set('Authorization', `Bearer ${credential}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(200) @@ -385,7 +410,7 @@ describe('JWK', () => { it('Should authorize from keys and hard-coded and remotely fetched from options.jwks_uri', async () => { const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0]) const req = new Request('http://localhost/auth-with-keys-and-jwks_uri/a') - req.headers.set('Authorization', `Basic ${credential}`) + req.headers.set('Authorization', `Bearer ${credential}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(200) @@ -393,12 +418,27 @@ describe('JWK', () => { expect(handlerExecuted).toBeTruthy() }) - it('Should not authorize requests with invalid Unicode payload in header', async () => { + it('Should not authorize requests with non-Bearer scheme in header', async () => { + const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0]) + const url = 'http://localhost/auth-with-keys-unicode/a' + const req = new Request(url) + req.headers.set('Authorization', `Basic ${credential}`) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(401) + expect(res.headers.get('www-authenticate')).toEqual( + `Bearer realm="${url}",error="invalid_request",error_description="invalid credentials structure"` + ) + expect(handlerExecuted).toBeFalsy() + }) + + it('Should not authorize requests with invalid token in Bearer header', async () => { const invalidToken = 'ssyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8gd29ybGQifQ.B54pAqIiLbu170tGQ1rY06Twv__0qSHTA0ioQPIOvFE' + const url = 'http://localhost/auth-with-keys-unicode/a' const req = new Request(url) - req.headers.set('Authorization', `Basic ${invalidToken}`) + req.headers.set('Authorization', `Bearer ${invalidToken}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(401) @@ -495,6 +535,49 @@ describe('JWK', () => { expect(await res.json()).toEqual({ message: 'hello world' }) expect(handlerExecuted).toBeTruthy() }) + + it('Should authorize with mixed-case Bearer scheme in custom header', async () => { + const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[1]) + + const req = new Request('http://localhost/auth-with-keys/a') + req.headers.set('x-custom-auth-header', `bEaReR ${credential}`) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ message: 'hello world' }) + expect(handlerExecuted).toBeTruthy() + }) + + it('Should not authorize with non-Bearer scheme in custom header', async () => { + const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[1]) + const url = 'http://localhost/auth-with-keys/a' + + const req = new Request(url) + req.headers.set('x-custom-auth-header', `Basic ${credential}`) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(401) + expect(res.headers.get('www-authenticate')).toEqual( + `Bearer realm="${url}",error="invalid_request",error_description="invalid credentials structure"` + ) + expect(handlerExecuted).toBeFalsy() + }) + + it('Should not authorize invalid token with Bearer scheme in custom header', async () => { + const invalidToken = + 'ssyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8gd29ybGQifQ.B54pAqIiLbu170tGQ1rY06Twv__0qSHTA0ioQPIOvFE' + const url = 'http://localhost/auth-with-keys/a' + + const req = new Request(url) + req.headers.set('x-custom-auth-header', `Bearer ${invalidToken}`) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(401) + expect(res.headers.get('www-authenticate')).toEqual( + `Bearer realm="${url}",error="invalid_token",error_description="token verification failure"` + ) + expect(handlerExecuted).toBeFalsy() + }) }) describe('Credentials in cookie', () => { @@ -811,7 +894,7 @@ describe('JWK', () => { } const credential = await Jwt.sign(payload, test_keys.private_keys[0]) const req = new Request('http://localhost/auth-with-keys-default/a') - req.headers.set('Authorization', `Basic ${credential}`) + req.headers.set('Authorization', `Bearer ${credential}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(200) @@ -825,7 +908,7 @@ describe('JWK', () => { } const credential = await Jwt.sign(payload, test_keys.private_keys[0]) const req = new Request('http://localhost/auth-with-keys-default/a') - req.headers.set('Authorization', `Basic ${credential}`) + req.headers.set('Authorization', `Bearer ${credential}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(401) @@ -838,7 +921,7 @@ describe('JWK', () => { } const credential = await Jwt.sign(payload, test_keys.private_keys[0]) const req = new Request('http://localhost/auth-with-keys-default/a') - req.headers.set('Authorization', `Basic ${credential}`) + req.headers.set('Authorization', `Bearer ${credential}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(401) @@ -851,7 +934,7 @@ describe('JWK', () => { } const credential = await Jwt.sign(payload, test_keys.private_keys[0]) const req = new Request('http://localhost/auth-with-keys-default/a') - req.headers.set('Authorization', `Basic ${credential}`) + req.headers.set('Authorization', `Bearer ${credential}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(401) @@ -864,7 +947,7 @@ describe('JWK', () => { } const credential = await Jwt.sign(payload, test_keys.private_keys[0]) const req = new Request('http://localhost/auth-with-keys-and-issuer/a') - req.headers.set('Authorization', `Basic ${credential}`) + req.headers.set('Authorization', `Bearer ${credential}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(200) @@ -878,7 +961,7 @@ describe('JWK', () => { } const credential = await Jwt.sign(payload, test_keys.private_keys[0]) const req = new Request('http://localhost/auth-with-keys-and-issuer/a') - req.headers.set('Authorization', `Basic ${credential}`) + req.headers.set('Authorization', `Bearer ${credential}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(401) @@ -891,7 +974,7 @@ describe('JWK', () => { } const credential = await Jwt.sign(payload, test_keys.private_keys[0]) const req = new Request('http://localhost/auth-with-keys-and-issuer/a') - req.headers.set('Authorization', `Basic ${credential}`) + req.headers.set('Authorization', `Bearer ${credential}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(401) diff --git a/src/middleware/jwk/jwk.ts b/src/middleware/jwk/jwk.ts index aa425ee2b3..80ac63500b 100644 --- a/src/middleware/jwk/jwk.ts +++ b/src/middleware/jwk/jwk.ts @@ -79,7 +79,7 @@ export const jwk = ( let token if (credentials) { const parts = credentials.split(/\s+/) - if (parts.length !== 2) { + if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') { const errDescription = 'invalid credentials structure' throw new HTTPException(401, { message: errDescription, diff --git a/src/middleware/jwt/index.test.ts b/src/middleware/jwt/index.test.ts index 049d5ea3ca..65d7598b66 100644 --- a/src/middleware/jwt/index.test.ts +++ b/src/middleware/jwt/index.test.ts @@ -58,12 +58,12 @@ describe('JWT', () => { expect(handlerExecuted).toBeTruthy() }) - it('Should authorize Unicode', async () => { + it('Should authorize with lowercase bearer scheme', async () => { const credential = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8gd29ybGQifQ.B54pAqIiLbu170tGQ1rY06Twv__0qSHTA0ioQPIOvFE' const req = new Request('http://localhost/auth-unicode/a') - req.headers.set('Authorization', `Basic ${credential}`) + req.headers.set('Authorization', `bearer ${credential}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(200) @@ -71,13 +71,42 @@ describe('JWT', () => { expect(handlerExecuted).toBeTruthy() }) - it('Should not authorize Unicode', async () => { + it('Should authorize with mixed-case Bearer scheme', async () => { + const credential = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8gd29ybGQifQ.B54pAqIiLbu170tGQ1rY06Twv__0qSHTA0ioQPIOvFE' + + const req = new Request('http://localhost/auth-unicode/a') + req.headers.set('Authorization', `bEaReR ${credential}`) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ message: 'hello world' }) + expect(handlerExecuted).toBeTruthy() + }) + + it('Should not authorize with non-Bearer scheme', async () => { + const credential = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8gd29ybGQifQ.B54pAqIiLbu170tGQ1rY06Twv__0qSHTA0ioQPIOvFE' + + const url = 'http://localhost/auth-unicode/a' + const req = new Request(url) + req.headers.set('Authorization', `Basic ${credential}`) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(401) + expect(res.headers.get('www-authenticate')).toEqual( + `Bearer realm="${url}",error="invalid_request",error_description="invalid credentials structure"` + ) + expect(handlerExecuted).toBeFalsy() + }) + + it('Should not authorize invalid token with Bearer scheme', async () => { const invalidToken = 'ssyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8gd29ybGQifQ.B54pAqIiLbu170tGQ1rY06Twv__0qSHTA0ioQPIOvFE' const url = 'http://localhost/auth-unicode/a' const req = new Request(url) - req.headers.set('Authorization', `Basic ${invalidToken}`) + req.headers.set('Authorization', `Bearer ${invalidToken}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(401) @@ -194,12 +223,12 @@ describe('JWT', () => { expect(handlerExecuted).toBeTruthy() }) - it('Should authorize Unicode', async () => { + it('Should authorize with lowercase bearer scheme', async () => { const credential = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8gd29ybGQifQ.B54pAqIiLbu170tGQ1rY06Twv__0qSHTA0ioQPIOvFE' const req = new Request('http://localhost/auth-unicode/a') - req.headers.set('x-custom-auth-header', `Basic ${credential}`) + req.headers.set('x-custom-auth-header', `bearer ${credential}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(200) @@ -207,13 +236,42 @@ describe('JWT', () => { expect(handlerExecuted).toBeTruthy() }) - it('Should not authorize Unicode', async () => { + it('Should authorize with mixed-case Bearer scheme in custom header', async () => { + const credential = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8gd29ybGQifQ.B54pAqIiLbu170tGQ1rY06Twv__0qSHTA0ioQPIOvFE' + + const req = new Request('http://localhost/auth-unicode/a') + req.headers.set('x-custom-auth-header', `bEaReR ${credential}`) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ message: 'hello world' }) + expect(handlerExecuted).toBeTruthy() + }) + + it('Should not authorize with non-Bearer scheme in custom header', async () => { + const credential = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8gd29ybGQifQ.B54pAqIiLbu170tGQ1rY06Twv__0qSHTA0ioQPIOvFE' + + const url = 'http://localhost/auth-unicode/a' + const req = new Request(url) + req.headers.set('x-custom-auth-header', `Basic ${credential}`) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(401) + expect(res.headers.get('www-authenticate')).toEqual( + `Bearer realm="${url}",error="invalid_request",error_description="invalid credentials structure"` + ) + expect(handlerExecuted).toBeFalsy() + }) + + it('Should not authorize invalid token with Bearer scheme in custom header', async () => { const invalidToken = 'ssyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8gd29ybGQifQ.B54pAqIiLbu170tGQ1rY06Twv__0qSHTA0ioQPIOvFE' const url = 'http://localhost/auth-unicode/a' const req = new Request(url) - req.headers.set('x-custom-auth-header', `Basic ${invalidToken}`) + req.headers.set('x-custom-auth-header', `Bearer ${invalidToken}`) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(401) diff --git a/src/middleware/jwt/jwt.ts b/src/middleware/jwt/jwt.ts index c7d88da52e..9827231c54 100644 --- a/src/middleware/jwt/jwt.ts +++ b/src/middleware/jwt/jwt.ts @@ -80,7 +80,7 @@ export const jwt = (options: { let token if (credentials) { const parts = credentials.split(/\s+/) - if (parts.length !== 2) { + if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') { const errDescription = 'invalid credentials structure' throw new HTTPException(401, { message: errDescription, diff --git a/src/middleware/language/index.test.ts b/src/middleware/language/index.test.ts index 8bd7d2cb63..a921f6cd2d 100644 --- a/src/middleware/language/index.test.ts +++ b/src/middleware/language/index.test.ts @@ -166,6 +166,23 @@ describe('languageDetector', () => { }) expect(await res.text()).toBe('en') }) + + it('should handle invalid header lookup key gracefully', async () => { + const app = createTestApp({ + order: ['header'], + lookupFromHeaderKey: 'accept\nlanguage', + supportedLanguages: ['en', 'fr'], + fallbackLanguage: 'en', + }) + + const res = await app.request('/', { + headers: { + 'accept-language': 'fr', + }, + }) + + expect(await res.text()).toBe('en') + }) }) describe('Path Detection', () => { @@ -304,6 +321,22 @@ describe('languageDetector', () => { const res = await app.request('/') expect(await res.text()).toBe('en') }) + + it('should handle errors in convertDetectedLanguage function', async () => { + const app = createTestApp({ + supportedLanguages: ['en', 'fr'], + fallbackLanguage: 'en', + convertDetectedLanguage: (lang: string) => { + if (lang === 'fr') { + throw new Error('Simulated error in convertDetectedLanguage') + } + return lang + }, + }) + + const res = await app.request('/?lang=fr') + expect(await res.text()).toBe('en') + }) }) describe('Configuration Validation', () => { @@ -331,6 +364,26 @@ describe('languageDetector', () => { }) }).toThrow() }) + + it('should handle invalid order option', async () => { + expect(() => { + createTestApp({ + order: ['invalidDetector' as any], + }) + }).toThrow() + }) + + it('should handle early return for caches', async () => { + const app = createTestApp({ + caches: ['test'], + supportedLanguages: ['en'], + }) + + const res = await app.request('/?lang=en') + + expect(await res.text()).toBe('en') + expect(res.headers.get('set-cookie')).toBeNull() + }) }) describe('Debug Mode', () => { @@ -376,5 +429,43 @@ describe('languageDetector', () => { consoleSpy.mockRestore() }) + + it('should handle cookie cache errors in debug mode', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error') + + const app = createTestApp({ + supportedLanguages: ['en', 'fr'], + fallbackLanguage: 'en', + caches: ['cookie'], + lookupCookie: 'bad cookie', + debug: true, + }) + + const res = await app.request('/?lang=fr') + + expect(await res.text()).toBe('fr') + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to cache language:', expect.any(Error)) + + consoleErrorSpy.mockRestore() + }) + + it('should silently handle cookie cache errors when debug mode is disabled', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const app = createTestApp({ + supportedLanguages: ['en', 'fr'], + fallbackLanguage: 'en', + caches: ['cookie'], + lookupCookie: 'bad cookie', + debug: false, + }) + + const res = await app.request('/?lang=fr') + + expect(await res.text()).toBe('fr') + expect(consoleErrorSpy).not.toHaveBeenCalled() + + consoleErrorSpy.mockRestore() + }) }) }) diff --git a/src/middleware/language/language.ts b/src/middleware/language/language.ts index df3b0a7611..15ebd50d1f 100644 --- a/src/middleware/language/language.ts +++ b/src/middleware/language/language.ts @@ -126,24 +126,16 @@ export const normalizeLanguage = ( * Detects language from query parameter */ export const detectFromQuery = (c: Context, options: DetectorOptions): string | undefined => { - try { - const query = c.req.query(options.lookupQueryString) - return normalizeLanguage(query, options) - } catch { - return undefined - } + const query = c.req.query(options.lookupQueryString) + return normalizeLanguage(query, options) } /** * Detects language from cookie */ export const detectFromCookie = (c: Context, options: DetectorOptions): string | undefined => { - try { - const cookie = getCookie(c, options.lookupCookie) - return normalizeLanguage(cookie, options) - } catch { - return undefined - } + const cookie = getCookie(c, options.lookupCookie) + return normalizeLanguage(cookie, options) } /** @@ -173,14 +165,10 @@ export function detectFromHeader(c: Context, options: DetectorOptions): string | * Detects language from URL path */ export function detectFromPath(c: Context, options: DetectorOptions): string | undefined { - try { - const url = new URL(c.req.url) - const pathSegments = url.pathname.split('/').filter(Boolean) - const langSegment = pathSegments[options.lookupFromPathIndex] - return normalizeLanguage(langSegment, options) - } catch { - return undefined - } + const url = new URL(c.req.url) + const pathSegments = url.pathname.split('/').filter(Boolean) + const langSegment = pathSegments[options.lookupFromPathIndex] + return normalizeLanguage(langSegment, options) } /** @@ -243,9 +231,6 @@ const detectLanguage = (c: Context, options: DetectorOptions): string => { for (const detectorName of options.order) { const detector = detectors[detectorName] - if (!detector) { - continue - } try { detectedLang = detector(c, options) @@ -290,16 +275,8 @@ export const languageDetector = (userOptions: Partial): Middlew validateOptions(options) return async function languageDetector(ctx, next) { - try { - const lang = detectLanguage(ctx, options) - ctx.set('language', lang) - } catch (error) { - if (options.debug) { - console.error('Language detection failed:', error) - } - ctx.set('language', options.fallbackLanguage) - } - + const lang = detectLanguage(ctx, options) + ctx.set('language', lang) await next() } } diff --git a/src/middleware/method-override/index.test.ts b/src/middleware/method-override/index.test.ts index 519aca0f48..7969e2f7d1 100644 --- a/src/middleware/method-override/index.test.ts +++ b/src/middleware/method-override/index.test.ts @@ -95,6 +95,23 @@ describe('Method Override Middleware', () => { expect(data.contentType).toBe('application/x-www-form-urlencoded') }) + it('Should override POST to DELETE - with charset in Content-Type', async () => { + const params = new URLSearchParams() + params.append('message', 'Hello') + params.append('_method', 'DELETE') + const res = await app.request('/posts', { + body: params, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + method: 'POST', + }) + expect(res.status).toBe(200) + const data = await res.json() + expect(data.method).toBe('DELETE') + expect(data.message).toBe('Hello') + }) + it('Should override POST to PATCH - not found', async () => { const form = new FormData() form.append('message', 'Hello') diff --git a/src/middleware/method-override/index.ts b/src/middleware/method-override/index.ts index a2cd9bdb97..f08ee3ce7e 100644 --- a/src/middleware/method-override/index.ts +++ b/src/middleware/method-override/index.ts @@ -89,7 +89,7 @@ export const methodOverride = (options: MethodOverrideOptions): MiddlewareHandle } } // Content-Type is `application/x-www-form-urlencoded` - if (contentType === 'application/x-www-form-urlencoded') { + if (contentType?.startsWith('application/x-www-form-urlencoded')) { const params = await parseBody>(clonedRequest) const method = params[methodFormName] if (method) { diff --git a/src/middleware/serve-static/index.test.ts b/src/middleware/serve-static/index.test.ts index 000a82f69c..b24f7bebb9 100644 --- a/src/middleware/serve-static/index.test.ts +++ b/src/middleware/serve-static/index.test.ts @@ -79,6 +79,15 @@ describe('Serve Static Middleware', () => { expect(await res.text()).toBe('404 Not Found') }) + it('Should not allow a backslash separator injection - /static/admin%5Csecret.txt', async () => { + const res = await app.fetch({ + method: 'GET', + url: 'http://localhost/static/admin%5Csecret.txt', + } as Request) + expect(res.status).toBe(404) + expect(await res.text()).toBe('404 Not Found') + }) + it('Should return a pre-compressed zstd response - /static/hello.html', async () => { const app = new Hono().use( '*', @@ -264,7 +273,7 @@ describe('Serve Static Middleware', () => { }) app.get('*', serveStatic) - const res = await app.request('///etc/passwd') + const res = await app.request('/etc/passwd') expect(await res.text()).toBe('Hello in etc/passwd') }) @@ -289,6 +298,9 @@ describe('Serve Static Middleware', () => { const res2 = await app.request('/admin%2Fsecret.txt') expect(res2.headers.get('X-Authorized')).toBeNull() expect(await res2.text()).toBe('Hello in admin%2Fsecret.txt') + + const res3 = await app.request('//admin/secret.txt') + expect(res3.status).toBe(404) }) }) }) diff --git a/src/middleware/serve-static/index.ts b/src/middleware/serve-static/index.ts index f2c6cba015..aadfddb226 100644 --- a/src/middleware/serve-static/index.ts +++ b/src/middleware/serve-static/index.ts @@ -64,7 +64,7 @@ export const serveStatic = ( } else { try { filename = tryDecodeURI(c.req.path) - if (/(?:^|[\/\\])\.\.(?:$|[\/\\])/.test(filename)) { + if (/(?:^|[\/\\])\.{1,2}(?:$|[\/\\])|[\/\\]{2,}|\\/.test(filename)) { throw new Error() } } catch { diff --git a/src/middleware/timing/index.test.ts b/src/middleware/timing/index.test.ts index 8b71c428a0..ab928b88aa 100644 --- a/src/middleware/timing/index.test.ts +++ b/src/middleware/timing/index.test.ts @@ -103,6 +103,65 @@ describe('Server-Timing API', () => { consoleWarnSpy.mockRestore() }) + describe('Should handle timing options', async () => { + it('Should handle autoEnd option', async () => { + const autoEndApp = new Hono() + + autoEndApp.use('*', timing()) + autoEndApp.get('/', (c) => { + startTime(c, 'test') + return c.text('/') + }) + + const res = await autoEndApp.request('/') + + expect(res.status).toBe(200) + expect(res.headers.get('server-timing')).toContain('test;dur=') + + const disabledAutoEndApp = new Hono() + + disabledAutoEndApp.use('*', timing({ autoEnd: false })) + disabledAutoEndApp.get('/', (c) => { + startTime(c, 'test') + return c.text('/') + }) + + const disabledRes = await disabledAutoEndApp.request('/') + + expect(disabledRes.status).toBe(200) + expect(disabledRes.headers.get('server-timing')).not.toContain('test;dur=') + }) + + it('Should use enabled function return value', async () => { + const enabledApp = new Hono() + const enabled = vi.fn(() => false) + + enabledApp.use('*', timing({ enabled })) + enabledApp.get('/', (c) => c.text('/')) + + const res = await enabledApp.request('/') + + expect(res.status).toBe(200) + expect(enabled).toHaveBeenCalled() + expect(res.headers.has('server-timing')).toBeFalsy() + }) + + it('Should handle total false and value-less metrics without description', async () => { + const metricApp = new Hono() + + metricApp.use('*', timing({ total: false })) + metricApp.get('/', (c) => { + setMetric(c, 'test') + return c.text('/') + }) + + const res = await metricApp.request('/') + + expect(res.status).toBe(200) + expect(res.headers.get('server-timing')).toBe('test') + }) + }) + describe('Should handle crossOrigin setting', async () => { it('Should do nothing when crossOrigin is falsy', async () => { const crossOriginApp = new Hono() @@ -186,5 +245,66 @@ describe('Server-Timing API', () => { expect(res.headers.has('timing-allow-origin')).toBeTruthy() expect(res.headers.get('timing-allow-origin')).toBe('https://example.com') }) + + it("Should use Date.now as backup if performance API isn't available", async () => { + const originalPerformance = globalThis.performance + // @ts-expect-error disable performance API for testing + delete globalThis.performance + const DateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(123456789) + + const app = new Hono().use(timing()).get('/', (c) => { + startTime(c, 'test') + endTime(c, 'test') + return c.text('hello') + }) + + const res = await app.request('/') + expect(res.status).toBe(200) + + expect(DateNowSpy).toHaveBeenCalled() + + DateNowSpy.mockRestore() + globalThis.performance = originalPerformance + }) + }) + + describe('Configuration validation', async () => { + it('Should silently warn when no timing metrics are available', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const app = new Hono().get('/', (c) => { + setMetric(c, 'test', 123) + startTime(c, 'test') + endTime(c, 'test') + return c.text('hello') + }) + + const res = await app.request('/') + expect(res.status).toBe(200) + + expect(consoleWarnSpy).toHaveBeenNthCalledWith( + 2, + 'Metrics not initialized! Please add the `timing()` middleware to this route!' + ) + + consoleWarnSpy.mockRestore() + }) + + it('Should silently warn when trying to end a non-existent timer', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const app = new Hono().use(timing()).get('/', (c) => { + setMetric(c, 'region', 'europe-west3') + endTime(c, 'nonExistentTimer') + return c.text('hello') + }) + + const res = await app.request('/') + expect(res.status).toBe(200) + + expect(consoleWarnSpy).toHaveBeenCalledWith('Timer "nonExistentTimer" does not exist!') + + consoleWarnSpy.mockRestore() + }) }) }) diff --git a/src/middleware/timing/timing.ts b/src/middleware/timing/timing.ts index 02fa87bbe9..bae9b5a6d9 100644 --- a/src/middleware/timing/timing.ts +++ b/src/middleware/timing/timing.ts @@ -102,7 +102,9 @@ export const timing = (config?: TimingOptions): MiddlewareHandler => { } if (options.autoEnd) { - timers.forEach((_, key) => endTime(c, key)) + timers.forEach((_, key) => { + endTime(c, key) + }) } const enabled = typeof options.enabled === 'function' ? options.enabled(c) : options.enabled diff --git a/src/middleware/trailing-slash/index.test.ts b/src/middleware/trailing-slash/index.test.ts index e5e7ba7d1a..a6d255c594 100644 --- a/src/middleware/trailing-slash/index.test.ts +++ b/src/middleware/trailing-slash/index.test.ts @@ -255,6 +255,63 @@ describe('Resolve trailing slash', () => { }) }) + describe('appendTrailingSlash middleware with skip option', () => { + const hasExtension = (path: string): boolean => /\.\w+$/.test(path.split('/').at(-1) ?? '') + const app = new Hono({ strict: true }) + app.use('*', appendTrailingSlash({ skip: hasExtension })) + + app.get('/page/', (c) => c.text('ok')) + + it('should not redirect paths with file extensions', async () => { + const resp = await app.request('/foo.html') + + expect(resp.status).toBe(404) + }) + + it('should redirect paths without file extensions', async () => { + const resp = await app.request('/page') + + expect(resp.status).toBe(301) + const loc = new URL(resp.headers.get('location')!) + expect(loc.pathname).toBe('/page/') + }) + }) + + describe('appendTrailingSlash middleware with alwaysRedirect and skip options', () => { + const hasExtension = (path: string): boolean => /\.\w+$/.test(path.split('/').at(-1) ?? '') + const app = new Hono() + app.use('*', appendTrailingSlash({ alwaysRedirect: true, skip: hasExtension })) + + app.get('/', (c) => c.text('ok')) + app.get('/my-path/*', (c) => c.text('wildcard')) + + it('should redirect paths without extensions', async () => { + const resp = await app.request('/my-path/something') + const loc = new URL(resp.headers.get('location')!) + + expect(resp.status).toBe(301) + expect(loc.pathname).toBe('/my-path/something/') + }) + + it('should not redirect paths with file extensions', async () => { + const resp = await app.request('/my-path/style.css') + + expect(resp.status).toBe(200) + }) + + it('should not redirect paths with extensions like .html', async () => { + const resp = await app.request('/my-path/page.html') + + expect(resp.status).toBe(200) + }) + + it('should handle HEAD request for paths with extensions', async () => { + const resp = await app.request('/my-path/script.js', { method: 'HEAD' }) + + expect(resp.status).toBe(200) + }) + }) + describe('appendTrailingSlash middleware with alwaysRedirect option', () => { const app = new Hono() app.use('*', appendTrailingSlash({ alwaysRedirect: true })) diff --git a/src/middleware/trailing-slash/index.ts b/src/middleware/trailing-slash/index.ts index 683d17bf78..8f38216152 100644 --- a/src/middleware/trailing-slash/index.ts +++ b/src/middleware/trailing-slash/index.ts @@ -82,6 +82,22 @@ type AppendTrailingSlashOptions = { * @default false */ alwaysRedirect?: boolean + /** + * A function that determines whether to skip the redirect for a given path. + * If the function returns `true`, the redirect will be skipped. + * @param path - The request path + * @returns `true` to skip the redirect + * + * @example + * ```ts + * // Skip redirect for paths with file extensions + * app.use(appendTrailingSlash({ + * alwaysRedirect: true, + * skip: (path) => /\.\w+$/.test(path), + * })) + * ``` + */ + skip?: (path: string) => boolean } /** @@ -112,7 +128,11 @@ type AppendTrailingSlashOptions = { export const appendTrailingSlash = (options?: AppendTrailingSlashOptions): MiddlewareHandler => { return async function appendTrailingSlash(c, next) { if (options?.alwaysRedirect) { - if ((c.req.method === 'GET' || c.req.method === 'HEAD') && c.req.path.at(-1) !== '/') { + if ( + (c.req.method === 'GET' || c.req.method === 'HEAD') && + c.req.path.at(-1) !== '/' && + !options.skip?.(c.req.path) + ) { const url = new URL(c.req.url) url.pathname += '/' @@ -126,7 +146,8 @@ export const appendTrailingSlash = (options?: AppendTrailingSlashOptions): Middl !options?.alwaysRedirect && c.res.status === 404 && (c.req.method === 'GET' || c.req.method === 'HEAD') && - c.req.path.at(-1) !== '/' + c.req.path.at(-1) !== '/' && + !options?.skip?.(c.req.path) ) { const url = new URL(c.req.url) url.pathname += '/' diff --git a/src/request.test.ts b/src/request.test.ts index 37f968a823..d9ae581c1b 100644 --- a/src/request.test.ts +++ b/src/request.test.ts @@ -253,6 +253,7 @@ describe('Body methods with caching', () => { expect(await req.text()).toEqual(text) expect(await req.json()).toEqual(json) expect(await req.arrayBuffer()).toEqual(buffer) + expect(await req.bytes()).toEqual(new Uint8Array(buffer)) expect(await req.blob()).toEqual( new Blob([text], { type: 'text/plain;charset=utf-8', @@ -270,6 +271,7 @@ describe('Body methods with caching', () => { expect(await req.json()).toEqual(json) expect(await req.text()).toEqual(text) expect(await req.arrayBuffer()).toEqual(buffer) + expect(await req.bytes()).toEqual(new Uint8Array(buffer)) expect(await req.blob()).toEqual( new Blob([text], { type: 'text/plain;charset=utf-8', @@ -300,6 +302,28 @@ describe('Body methods with caching', () => { expect(await req.arrayBuffer()).toEqual(buffer) expect(await req.text()).toEqual(text) expect(await req.json()).toEqual(json) + expect(await req.bytes()).toEqual(new Uint8Array(buffer)) + expect(await req.blob()).toEqual( + new Blob([text], { + type: '', + }) + ) + }) + + test('req.bytes()', async () => { + const bytes = new TextEncoder().encode('{"foo":"bar"}') + const req = new HonoRequest( + new Request('http://localhost', { + method: 'POST', + body: bytes, + }) + ) + const result = await req.bytes() + expect(result).toBeInstanceOf(Uint8Array) + expect(result).toEqual(bytes) + expect(await req.text()).toEqual(text) + expect(await req.json()).toEqual(json) + expect(await req.arrayBuffer()).toEqual(buffer) expect(await req.blob()).toEqual( new Blob([text], { type: '', @@ -321,6 +345,7 @@ describe('Body methods with caching', () => { expect(await req.text()).toEqual(text) expect(await req.json()).toEqual(json) expect(await req.arrayBuffer()).toEqual(buffer) + expect(await req.bytes()).toEqual(new Uint8Array(buffer)) }) test('req.formData()', async () => { @@ -335,6 +360,7 @@ describe('Body methods with caching', () => { expect((await req.formData()).get('foo')).toBe('bar') expect(async () => await req.text()).not.toThrow() expect(async () => await req.arrayBuffer()).not.toThrow() + expect(async () => await req.bytes()).not.toThrow() expect(async () => await req.blob()).not.toThrow() }) @@ -351,9 +377,58 @@ describe('Body methods with caching', () => { expect((await req.parseBody())['foo']).toBe('bar') expect(async () => await req.text()).not.toThrow() expect(async () => await req.arrayBuffer()).not.toThrow() + expect(async () => await req.bytes()).not.toThrow() expect(async () => await req.blob()).not.toThrow() }) + describe('should not break body methods after parseBody() with non-form content-type', () => { + const createReq = () => + new HonoRequest( + new Request('http://localhost', { + method: 'POST', + body: JSON.stringify({ event: 'push' }), + headers: { 'Content-Type': 'application/json' }, + }) + ) + + it('text()', async () => { + const req = createReq() + await req.parseBody() + expect(await req.text()).toBe(JSON.stringify({ event: 'push' })) + }) + + it('json()', async () => { + const req = createReq() + await req.parseBody() + expect(await req.json()).toEqual({ event: 'push' }) + }) + + it('arrayBuffer()', async () => { + const req = createReq() + await req.parseBody() + expect(await req.arrayBuffer()).toBeInstanceOf(ArrayBuffer) + }) + + it('bytes()', async () => { + const req = createReq() + await req.parseBody() + expect(await req.bytes()).toBeInstanceOf(Uint8Array) + }) + + it('blob()', async () => { + const req = createReq() + await req.parseBody() + expect(await req.blob()).toBeInstanceOf(Blob) + }) + + it('formData()', async () => { + const req = createReq() + await req.parseBody() + // application/json is not a valid formData content-type, so this should throw + expect(req.formData()).rejects.toThrow() + }) + }) + describe('Return type', () => { let req: HonoRequest beforeEach(() => { diff --git a/src/request.ts b/src/request.ts index 3d3e1fc630..41fde0d2b7 100644 --- a/src/request.ts +++ b/src/request.ts @@ -24,7 +24,7 @@ type Body = { blob: Blob formData: FormData } -type BodyCache = Partial +type BodyCache = Partial type OptionalRequestInitProperties = 'window' | 'priority' type RequiredRequestInit = Required> & { @@ -214,7 +214,7 @@ export class HonoRequest

{ ): Promise async parseBody(options?: Partial): Promise async parseBody(options?: Partial) { - return (this.bodyCache.parsedBody ??= await parseBody(this, options)) + return parseBody(this, options) } #cachedBody = (key: keyof Body) => { @@ -286,6 +286,22 @@ export class HonoRequest

{ return this.#cachedBody('arrayBuffer') } + /** + * `.bytes()` parses the request body as a `Uint8Array`. + * + * @see {@link https://hono.dev/docs/api/request#bytes} + * + * @example + * ```ts + * app.post('/entry', async (c) => { + * const body = await c.req.bytes() + * }) + * ``` + */ + bytes(): Promise { + return this.#cachedBody('arrayBuffer').then((buffer: ArrayBuffer) => new Uint8Array(buffer)) + } + /** * Parses the request body as a `Blob`. * @example diff --git a/src/router/common.case.test.ts b/src/router/common.case.test.ts index b9d8a443bf..de3bc69343 100644 --- a/src/router/common.case.test.ts +++ b/src/router/common.case.test.ts @@ -777,7 +777,7 @@ export const runTest = ({ describe('Capture Group', () => { describe('Simple capturing group', () => { beforeEach(() => { - router.add('get', '/foo/:capture{(?:bar|baz)}', 'ok') + router.add('get', '/foo/:capture{(bar|baz)}', 'ok') }) it('GET /foo/bar', () => { diff --git a/src/types.test.ts b/src/types.test.ts index 0e2aff6e69..fb93582c2d 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -401,6 +401,53 @@ describe('OnHandlerInterface', () => { } type verify = Expect> }) + + test('app.on(method, path, ...10 handlers) - last handler response type should be inferred', () => { + const noop: MiddlewareHandler = async (_c, next) => { + await next() + } + const route = app.on('GET', '/x10', noop, noop, noop, noop, noop, noop, noop, noop, noop, (c) => + c.json({ success: true }) + ) + type Actual = ExtractSchema['/x10']['$get']['output'] + type Expected = { success: true } + type verify = Expect> + }) + + test('app.on(method[], path, ...9 handlers) - last handler response type should be inferred', () => { + const noop: MiddlewareHandler = async (_c, next) => { + await next() + } + const route = app.on(['GET'], '/x9-arr', noop, noop, noop, noop, noop, noop, noop, noop, (c) => + c.json({ success: true }) + ) + type Actual = ExtractSchema['/x9-arr']['$get']['output'] + type Expected = { success: true } + type verify = Expect> + }) + + test('app.on(method[], path, ...10 handlers) - last handler response type should be inferred', () => { + const noop: MiddlewareHandler = async (_c, next) => { + await next() + } + const route = app.on( + ['GET'], + '/x10-arr', + noop, + noop, + noop, + noop, + noop, + noop, + noop, + noop, + noop, + (c) => c.json({ success: true }) + ) + type Actual = ExtractSchema['/x10-arr']['$get']['output'] + type Expected = { success: true } + type verify = Expect> + }) }) describe('TypedResponse', () => { @@ -3451,6 +3498,282 @@ describe('RPC supports Middleware responses', () => { type verify = Expect> }) }) + + describe('Merge responses from app.on handlers, single method', () => { + test('merge responses from 1 middleware', async () => { + const middleware = createMiddleware(async (c) => c.json({ '400': true }, 400)) + + const app = new Hono().on('GET', '/test', middleware, (c) => c.json({ '200': true }, 200)) + const client = hc('http://localhost', { fetch: app.request }) + const res = await client.test.$get() + + if (res.status === 200) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 200: true }>() + } + if (res.status === 400) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 400: true }>() + } + }) + + test('merge responses from 2 middlewares', async () => { + const middleware1 = createMiddleware(async (c) => c.json({ '400': true }, 400)) + const middleware2 = createMiddleware(async (c) => c.json({ '401': true }, 401)) + + const app = new Hono().on('GET', '/test', middleware1, middleware2, (c) => + c.json({ '200': true }, 200) + ) + const client = hc('http://localhost', { fetch: app.request }) + const res = await client.test.$get() + + if (res.status === 200) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 200: true }>() + } + if (res.status === 400) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 400: true }>() + } + if (res.status === 401) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 401: true }>() + } + }) + + test('merge responses from 5 middlewares', async () => { + const middleware1 = createMiddleware(async (c) => c.json({ '400': true }, 400)) + const middleware2 = createMiddleware(async (c) => c.json({ '401': true }, 401)) + const middleware3 = createMiddleware(async (c) => c.json({ '403': true }, 403)) + const middleware4 = createMiddleware(async (c) => c.json({ '404': true }, 404)) + const middleware5 = createMiddleware(async (c) => c.json({ '300': true }, 300)) + + const app = new Hono().on( + 'GET', + '/test', + middleware1, + middleware2, + middleware3, + middleware4, + middleware5, + (c) => c.json({ '200': true }, 200) + ) + const client = hc('http://localhost', { fetch: app.request }) + const res = await client.test.$get() + + if (res.status === 200) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 200: true }>() + } + if (res.status === 400) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 400: true }>() + } + if (res.status === 401) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 401: true }>() + } + if (res.status === 403) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 403: true }>() + } + if (res.status === 404) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 404: true }>() + } + if (res.status === 300) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 300: true }>() + } + }) + + test('merge responses from 9 middlewares', async () => { + const middleware1 = createMiddleware(async (c) => c.json({ '400': true }, 400)) + const middleware2 = createMiddleware(async (c) => c.json({ '401': true }, 401)) + const middleware3 = createMiddleware(async (c) => c.json({ '403': true }, 403)) + const middleware4 = createMiddleware(async (c) => c.json({ '404': true }, 404)) + const middleware5 = createMiddleware(async (c) => c.json({ '300': true }, 300)) + const middleware6 = createMiddleware(async (c) => c.json({ '201': true }, 201)) + const middleware7 = createMiddleware(async (c) => c.json({ '202': true }, 202)) + const middleware8 = createMiddleware(async (c) => c.json({ '203': true }, 203)) + const middleware9 = createMiddleware(async (c) => c.json({ '301': true }, 301)) + + const app = new Hono().on( + 'GET', + '/test', + middleware1, + middleware2, + middleware3, + middleware4, + middleware5, + middleware6, + middleware7, + middleware8, + middleware9, + (c) => c.json({ '200': true }, 200) + ) + const client = hc('http://localhost', { fetch: app.request }) + const res = await client.test.$get() + + if (res.status === 200) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 200: true }>() + } + if (res.status === 400) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 400: true }>() + } + if (res.status === 401) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 401: true }>() + } + if (res.status === 403) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 403: true }>() + } + if (res.status === 404) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 404: true }>() + } + if (res.status === 300) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 300: true }>() + } + if (res.status === 201) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 201: true }>() + } + if (res.status === 202) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 202: true }>() + } + if (res.status === 203) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 203: true }>() + } + if (res.status === 301) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 301: true }>() + } + }) + }) + + describe('Merge responses from app.on handlers, method[]', () => { + test('merge responses from 1 middleware', async () => { + const middleware = createMiddleware(async (c) => c.json({ '400': true }, 400)) + + const app = new Hono().on(['GET'], '/test', middleware, (c) => c.json({ '200': true }, 200)) + const client = hc('http://localhost', { fetch: app.request }) + const res = await client.test.$get() + + if (res.status === 200) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 200: true }>() + } + if (res.status === 400) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 400: true }>() + } + }) + + test('merge responses from 2 middlewares', async () => { + const middleware1 = createMiddleware(async (c) => c.json({ '400': true }, 400)) + const middleware2 = createMiddleware(async (c) => c.json({ '401': true }, 401)) + + const app = new Hono().on(['GET'], '/test', middleware1, middleware2, (c) => + c.json({ '200': true }, 200) + ) + const client = hc('http://localhost', { fetch: app.request }) + const res = await client.test.$get() + + if (res.status === 200) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 200: true }>() + } + if (res.status === 400) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 400: true }>() + } + if (res.status === 401) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 401: true }>() + } + }) + + test('merge responses from 5 middlewares', async () => { + const middleware1 = createMiddleware(async (c) => c.json({ '400': true }, 400)) + const middleware2 = createMiddleware(async (c) => c.json({ '401': true }, 401)) + const middleware3 = createMiddleware(async (c) => c.json({ '403': true }, 403)) + const middleware4 = createMiddleware(async (c) => c.json({ '404': true }, 404)) + const middleware5 = createMiddleware(async (c) => c.json({ '300': true }, 300)) + + const app = new Hono().on( + ['GET'], + '/test', + middleware1, + middleware2, + middleware3, + middleware4, + middleware5, + (c) => c.json({ '200': true }, 200) + ) + const client = hc('http://localhost', { fetch: app.request }) + const res = await client.test.$get() + + if (res.status === 200) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 200: true }>() + } + if (res.status === 400) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 400: true }>() + } + if (res.status === 401) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 401: true }>() + } + if (res.status === 403) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 403: true }>() + } + if (res.status === 404) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 404: true }>() + } + if (res.status === 300) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 300: true }>() + } + }) + + test('merge responses from 9 middlewares', async () => { + const middleware1 = createMiddleware(async (c) => c.json({ '400': true }, 400)) + const middleware2 = createMiddleware(async (c) => c.json({ '401': true }, 401)) + const middleware3 = createMiddleware(async (c) => c.json({ '403': true }, 403)) + const middleware4 = createMiddleware(async (c) => c.json({ '404': true }, 404)) + const middleware5 = createMiddleware(async (c) => c.json({ '300': true }, 300)) + const middleware6 = createMiddleware(async (c) => c.json({ '201': true }, 201)) + const middleware7 = createMiddleware(async (c) => c.json({ '202': true }, 202)) + const middleware8 = createMiddleware(async (c) => c.json({ '203': true }, 203)) + const middleware9 = createMiddleware(async (c) => c.json({ '301': true }, 301)) + + const app = new Hono().on( + ['GET'], + '/test', + middleware1, + middleware2, + middleware3, + middleware4, + middleware5, + middleware6, + middleware7, + middleware8, + middleware9, + (c) => c.json({ '200': true }, 200) + ) + const client = hc('http://localhost', { fetch: app.request }) + const res = await client.test.$get() + + if (res.status === 200) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 200: true }>() + } + if (res.status === 400) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 400: true }>() + } + if (res.status === 401) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 401: true }>() + } + if (res.status === 403) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 403: true }>() + } + if (res.status === 404) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 404: true }>() + } + if (res.status === 300) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 300: true }>() + } + if (res.status === 201) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 201: true }>() + } + if (res.status === 202) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 202: true }>() + } + if (res.status === 203) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 203: true }>() + } + if (res.status === 301) { + expectTypeOf(await res.json()).toEqualTypeOf<{ 301: true }>() + } + }) + }) }) describe('Handlers returning Promise', () => { diff --git a/src/types.ts b/src/types.ts index ab9f479877..240dc9bf8d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1488,13 +1488,16 @@ export interface OnHandlerInterface< I2 extends Input = I, E2 extends Env = E, E3 extends Env = IntersectNonAnyTypes<[E, E2]>, + // Middleware + M1 extends H = H, >( method: M, path: P, - ...handlers: [H, H] + ...handlers: [H & M1, H] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3]>, - S & ToSchema, I2, MergeTypedResponse>, + S & + ToSchema, I2, MergeTypedResponse | MergeMiddlewareResponse>, BasePath, MergePath > @@ -1511,13 +1514,22 @@ export interface OnHandlerInterface< E2 extends Env = E, E3 extends Env = IntersectNonAnyTypes<[E, E2]>, E4 extends Env = IntersectNonAnyTypes<[E, E2, E3]>, + // Middleware + M1 extends H = H, + M2 extends H = H, >( method: M, path: P, - ...handlers: [H, H, H] + ...handlers: [H & M1, H & M2, H] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3, E4]>, - S & ToSchema, I3, MergeTypedResponse>, + S & + ToSchema< + M, + MergePath, + I3, + MergeTypedResponse | MergeMiddlewareResponse | MergeMiddlewareResponse + >, BasePath, MergePath > @@ -1536,18 +1548,31 @@ export interface OnHandlerInterface< E3 extends Env = IntersectNonAnyTypes<[E, E2]>, E4 extends Env = IntersectNonAnyTypes<[E, E2, E3]>, E5 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4]>, + // Middleware + M1 extends H = H, + M2 extends H = H, + M3 extends H = H, >( method: M, path: P, ...handlers: [ - H, - H, - H, + H & M1, + H & M2, + H & M3, H, ] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3, E4, E5]>, - S & ToSchema, I4, MergeTypedResponse>, + S & + ToSchema< + M, + MergePath, + I4, + | MergeTypedResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + >, BasePath, MergePath > @@ -1568,19 +1593,34 @@ export interface OnHandlerInterface< E4 extends Env = IntersectNonAnyTypes<[E, E2, E3]>, E5 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4]>, E6 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5]>, + // Middleware + M1 extends H = H, + M2 extends H = H, + M3 extends H = H, + M4 extends H = H, >( method: M, path: P, ...handlers: [ - H, - H, - H, - H, + H & M1, + H & M2, + H & M3, + H & M4, H, ] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6]>, - S & ToSchema, I5, MergeTypedResponse>, + S & + ToSchema< + M, + MergePath, + I5, + | MergeTypedResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + >, BasePath, MergePath > @@ -1603,20 +1643,37 @@ export interface OnHandlerInterface< E5 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4]>, E6 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5]>, E7 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6]>, + // Middleware + M1 extends H = H, + M2 extends H = H, + M3 extends H = H, + M4 extends H = H, + M5 extends H = H, >( method: M, path: P, ...handlers: [ - H, - H, - H, - H, - H, + H & M1, + H & M2, + H & M3, + H & M4, + H & M5, H, ] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7]>, - S & ToSchema, I6, MergeTypedResponse>, + S & + ToSchema< + M, + MergePath, + I6, + | MergeTypedResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + >, BasePath, MergePath > @@ -1641,21 +1698,40 @@ export interface OnHandlerInterface< E6 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5]>, E7 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6]>, E8 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7]>, + // Middleware + M1 extends H = H, + M2 extends H = H, + M3 extends H = H, + M4 extends H = H, + M5 extends H = H, + M6 extends H = H, >( method: M, path: P, ...handlers: [ - H, - H, - H, - H, - H, - H, + H & M1, + H & M2, + H & M3, + H & M4, + H & M5, + H & M6, H, ] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8]>, - S & ToSchema, I7, MergeTypedResponse>, + S & + ToSchema< + M, + MergePath, + I7, + | MergeTypedResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + >, BasePath, MergePath > @@ -1682,22 +1758,43 @@ export interface OnHandlerInterface< E7 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6]>, E8 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7]>, E9 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8]>, + // Middleware + M1 extends H = H, + M2 extends H = H, + M3 extends H = H, + M4 extends H = H, + M5 extends H = H, + M6 extends H = H, + M7 extends H = H, >( method: M, path: P, ...handlers: [ - H, - H, - H, - H, - H, - H, - H, + H & M1, + H & M2, + H & M3, + H & M4, + H & M5, + H & M6, + H & M7, H, ] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8, E9]>, - S & ToSchema, I8, MergeTypedResponse>, + S & + ToSchema< + M, + MergePath, + I8, + | MergeTypedResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + >, BasePath, MergePath > @@ -1726,23 +1823,46 @@ export interface OnHandlerInterface< E8 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7]>, E9 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8]>, E10 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8, E9]>, + // Middleware + M1 extends H = H, + M2 extends H = H, + M3 extends H = H, + M4 extends H = H, + M5 extends H = H, + M6 extends H = H, + M7 extends H = H, + M8 extends H = H, >( method: M, path: P, ...handlers: [ - H, - H, - H, - H, - H, - H, - H, - H, + H & M1, + H & M2, + H & M3, + H & M4, + H & M5, + H & M6, + H & M7, + H & M8, H, ] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8, E9, E10]>, - S & ToSchema, I9, MergeTypedResponse>, + S & + ToSchema< + M, + MergePath, + I9, + | MergeTypedResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + >, BasePath, MergePath > @@ -1773,24 +1893,49 @@ export interface OnHandlerInterface< E9 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8]>, E10 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8, E9]>, E11 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8, E9, E10]>, + // Middleware + M1 extends H = H, + M2 extends H = H, + M3 extends H = H, + M4 extends H = H, + M5 extends H = H, + M6 extends H = H, + M7 extends H = H, + M8 extends H = H, + M9 extends H = H, >( method: M, path: P, ...handlers: [ - H, - H, - H, - H, - H, - H, - H, - H, - H, + H & M1, + H & M2, + H & M3, + H & M4, + H & M5, + H & M6, + H & M7, + H & M8, + H & M9, H, ] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8, E9, E10, E11]>, - S & ToSchema, I10, MergeTypedResponse>>, + S & + ToSchema< + M, + MergePath, + I10, + | MergeTypedResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + >, BasePath, MergePath > @@ -1841,13 +1986,16 @@ export interface OnHandlerInterface< I2 extends Input = I, E2 extends Env = E, E3 extends Env = IntersectNonAnyTypes<[E, E2]>, + // Middleware + M1 extends H = H, >( methods: M[], path: P, - ...handlers: [H, H] + ...handlers: [H & M1, H] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3]>, - S & ToSchema, I2, MergeTypedResponse>, + S & + ToSchema, I2, MergeTypedResponse | MergeMiddlewareResponse>, BasePath, MergePath > @@ -1864,13 +2012,22 @@ export interface OnHandlerInterface< E2 extends Env = E, E3 extends Env = IntersectNonAnyTypes<[E, E2]>, E4 extends Env = IntersectNonAnyTypes<[E, E2, E3]>, + // Middleware + M1 extends H = H, + M2 extends H = H, >( methods: M[], path: P, - ...handlers: [H, H, H] + ...handlers: [H & M1, H & M2, H] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3, E4]>, - S & ToSchema, I3, MergeTypedResponse>, + S & + ToSchema< + M, + MergePath, + I3, + MergeTypedResponse | MergeMiddlewareResponse | MergeMiddlewareResponse + >, BasePath, MergePath > @@ -1889,18 +2046,31 @@ export interface OnHandlerInterface< E3 extends Env = IntersectNonAnyTypes<[E, E2]>, E4 extends Env = IntersectNonAnyTypes<[E, E2, E3]>, E5 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4]>, + // Middleware + M1 extends H = H, + M2 extends H = H, + M3 extends H = H, >( methods: M[], path: P, ...handlers: [ - H, - H, - H, + H & M1, + H & M2, + H & M3, H, ] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3, E4, E5]>, - S & ToSchema, I4, MergeTypedResponse>, + S & + ToSchema< + M, + MergePath, + I4, + | MergeTypedResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + >, BasePath, MergePath > @@ -1921,19 +2091,34 @@ export interface OnHandlerInterface< E4 extends Env = IntersectNonAnyTypes<[E, E2, E3]>, E5 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4]>, E6 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5]>, + // Middleware + M1 extends H = H, + M2 extends H = H, + M3 extends H = H, + M4 extends H = H, >( methods: M[], path: P, ...handlers: [ - H, - H, - H, - H, + H & M1, + H & M2, + H & M3, + H & M4, H, ] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6]>, - S & ToSchema, I5, MergeTypedResponse>, + S & + ToSchema< + M, + MergePath, + I5, + | MergeTypedResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + >, BasePath, MergePath > @@ -1956,20 +2141,37 @@ export interface OnHandlerInterface< E5 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4]>, E6 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5]>, E7 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6]>, + // Middleware + M1 extends H = H, + M2 extends H = H, + M3 extends H = H, + M4 extends H = H, + M5 extends H = H, >( methods: M[], path: P, ...handlers: [ - H, - H, - H, - H, - H, + H & M1, + H & M2, + H & M3, + H & M4, + H & M5, H, ] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7]>, - S & ToSchema, I6, MergeTypedResponse>, + S & + ToSchema< + M, + MergePath, + I6, + | MergeTypedResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + >, BasePath, MergePath > @@ -1994,21 +2196,40 @@ export interface OnHandlerInterface< E6 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5]>, E7 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6]>, E8 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7]>, + // Middleware + M1 extends H = H, + M2 extends H = H, + M3 extends H = H, + M4 extends H = H, + M5 extends H = H, + M6 extends H = H, >( methods: M[], path: P, ...handlers: [ - H, - H, - H, - H, - H, - H, + H & M1, + H & M2, + H & M3, + H & M4, + H & M5, + H & M6, H, ] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8]>, - S & ToSchema, I7, MergeTypedResponse>, + S & + ToSchema< + M, + MergePath, + I7, + | MergeTypedResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + >, BasePath, MergePath > @@ -2035,22 +2256,43 @@ export interface OnHandlerInterface< E7 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6]>, E8 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7]>, E9 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8]>, + // Middleware + M1 extends H = H, + M2 extends H = H, + M3 extends H = H, + M4 extends H = H, + M5 extends H = H, + M6 extends H = H, + M7 extends H = H, >( methods: M[], path: P, ...handlers: [ - H, - H, - H, - H, - H, - H, - H, + H & M1, + H & M2, + H & M3, + H & M4, + H & M5, + H & M6, + H & M7, H, ] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8, E9]>, - S & ToSchema, I8, MergeTypedResponse>, + S & + ToSchema< + M, + MergePath, + I8, + | MergeTypedResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + >, BasePath, MergePath > @@ -2079,23 +2321,46 @@ export interface OnHandlerInterface< E8 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7]>, E9 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8]>, E10 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8, E9]>, + // Middleware + M1 extends H = H, + M2 extends H = H, + M3 extends H = H, + M4 extends H = H, + M5 extends H = H, + M6 extends H = H, + M7 extends H = H, + M8 extends H = H, >( methods: M[], path: P, ...handlers: [ - H, - H, - H, - H, - H, - H, - H, - H, + H & M1, + H & M2, + H & M3, + H & M4, + H & M5, + H & M6, + H & M7, + H & M8, H, ] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8, E9, E10]>, - S & ToSchema, I9, MergeTypedResponse>>, + S & + ToSchema< + M, + MergePath, + I9, + | MergeTypedResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + >, BasePath, MergePath > @@ -2126,24 +2391,49 @@ export interface OnHandlerInterface< E9 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8]>, E10 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8, E9]>, E11 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8, E9, E10]>, + // Middleware + M1 extends H = H, + M2 extends H = H, + M3 extends H = H, + M4 extends H = H, + M5 extends H = H, + M6 extends H = H, + M7 extends H = H, + M8 extends H = H, + M9 extends H = H, >( methods: M[], path: P, ...handlers: [ - H, - H, - H, - H, - H, - H, - H, - H, - H, + H & M1, + H & M2, + H & M3, + H & M4, + H & M5, + H & M6, + H & M7, + H & M8, + H & M9, H, ] ): HonoBase< IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8, E9, E10, E11]>, - S & ToSchema, I10, MergeTypedResponse>>, + S & + ToSchema< + M, + MergePath, + I10, + | MergeTypedResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + | MergeMiddlewareResponse + >, BasePath, MergePath > diff --git a/src/utils/compress.ts b/src/utils/compress.ts index 93a660976c..439817386b 100644 --- a/src/utils/compress.ts +++ b/src/utils/compress.ts @@ -7,4 +7,4 @@ * Match for compressible content type. */ export const COMPRESSIBLE_CONTENT_TYPE_REGEX = - /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i + /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|msgpack|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|vnd\.msgpack|wasm|x-httpd-php|x-javascript|x-msgpack|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml|msgpack))(?:[;\s]|$)/i diff --git a/src/utils/cookie.test.ts b/src/utils/cookie.test.ts index 27e20ea405..10365b57f6 100644 --- a/src/utils/cookie.test.ts +++ b/src/utils/cookie.test.ts @@ -9,6 +9,13 @@ describe('Parse cookie', () => { expect(cookie['tasty_cookie']).toBe('strawberry') }) + it('Should trim only SP and HTAB around cookie pairs', () => { + const cookieString = '\tyummy_cookie=choco;\t tasty_cookie = strawberry \t' + const cookie: Cookie = parse(cookieString) + expect(cookie['yummy_cookie']).toBe('choco') + expect(cookie['tasty_cookie']).toBe('strawberry') + }) + it('Should parse quoted cookie values', () => { const cookieString = 'yummy_cookie="choco"; tasty_cookie = " strawberry " ; best_cookie="%20sugar%20";' @@ -81,6 +88,56 @@ describe('Parse cookie', () => { expect(cookie['best_cookie\\']).toBeUndefined() }) + it('Should ignore NBSP-prefixed cookie names when parsing one cookie by name', () => { + const cookieString = '\u00a0dummy-cookie=evil; dummy-cookie=victim' + const cookie: Cookie = parse(cookieString, 'dummy-cookie') + expect(cookie['dummy-cookie']).toBe('victim') + }) + + it('Should not collapse NBSP-prefixed cookie names when parsing all cookies', () => { + const cookieString = 'dummy-cookie=victim; \u00a0dummy-cookie=evil' + const cookie: Cookie = parse(cookieString) + expect(cookie['dummy-cookie']).toBe('victim') + expect(cookie['\u00a0dummy-cookie']).toBeUndefined() + }) + + it('Should return the first value for duplicate cookie names', () => { + const cookieString = 'a=first; a=last' + const cookie: Cookie = parse(cookieString) + expect(cookie['a']).toBe('first') + }) + + it('Should return the first value for duplicate cookie names when parsed by name', () => { + const cookieString = 'a=first; a=last' + const cookie: Cookie = parse(cookieString, 'a') + expect(cookie['a']).toBe('first') + }) + + it('Should return consistent values between parse() and parse(name) for duplicates', () => { + const cookieString = 'session=legit; other=x; session=evil' + expect(parse(cookieString)['session']).toBe(parse(cookieString, 'session')['session']) + }) + + it('Should parse cookies whose names collide with Object.prototype properties', () => { + const cookieString = 'toString=foo; hasOwnProperty=bar; constructor=baz' + const cookie: Cookie = parse(cookieString) + expect(cookie['toString']).toBe('foo') + expect(cookie['hasOwnProperty']).toBe('bar') + expect(cookie['constructor']).toBe('baz') + }) + + it('Should parse a cookie whose name collides with Object.prototype when parsed by name', () => { + const cookieString = 'toString=foo' + const cookie: Cookie = parse(cookieString, 'toString') + expect(cookie['toString']).toBe('foo') + }) + + it('Should keep first-wins behavior for cookies whose names collide with Object.prototype', () => { + const cookieString = 'toString=first; toString=last' + const cookie: Cookie = parse(cookieString) + expect(cookie['toString']).toBe('first') + }) + it('Should parse signed cookies', async () => { const secret = 'secret ingredient' const cookieString = @@ -159,6 +216,29 @@ describe('Parse cookie', () => { expect(cookie['tasty_cookie']).toBe('strawberry') expect(cookie['great_cookie']).toBeUndefined() }) + + it('Should ignore NBSP-prefixed signed cookie names when parsing one cookie by name', async () => { + const secret = 'secret ingredient' + const cookieString = + '\u00a0dummy-cookie=evil.UdFR2rBpS1GsHfGlUiYyMIdqxqwuEgplyQIgTJgpGWY%3D; dummy-cookie=choco.UdFR2rBpS1GsHfGlUiYyMIdqxqwuEgplyQIgTJgpGWY%3D' + const cookie: SignedCookie = await parseSigned(cookieString, secret, 'dummy-cookie') + expect(cookie['dummy-cookie']).toBe('choco') + }) + + it('Should return the first signed cookie when there are duplicate names', async () => { + const secret = 'secret ingredient' + const cookieString = + 'yummy_cookie=choco.UdFR2rBpS1GsHfGlUiYyMIdqxqwuEgplyQIgTJgpGWY%3D; yummy_cookie=strawberry.I9qAeGQOvWjCEJgRPmrw90JjYpnnX2C9zoOiGSxh1Ig%3D' + const cookie: SignedCookie = await parseSigned(cookieString, secret) + expect(cookie['yummy_cookie']).toBe('choco') + }) + + it('Should parse signed cookies whose names collide with Object.prototype properties', async () => { + const secret = 'secret ingredient' + const cookieString = 'toString=choco.UdFR2rBpS1GsHfGlUiYyMIdqxqwuEgplyQIgTJgpGWY%3D' + const cookie: SignedCookie = await parseSigned(cookieString, secret) + expect(cookie['toString']).toBe('choco') + }) }) describe('Set cookie', () => { @@ -270,7 +350,7 @@ describe('Set cookie', () => { }).toThrowError('Partitioned Cookie must have Secure attributes') }) - it('Should throw Error cookie with domain or path containing ";", "\\r", or "\\n"', () => { + it('Should throw Error cookie with domain, path, sameSite, or priority containing ";", "\\r", or "\\n"', () => { // domain expect(() => { serialize('great_cookie', 'banana', { domain: 'example.com;evil' }) @@ -292,6 +372,40 @@ describe('Set cookie', () => { expect(() => { serialize('great_cookie', 'banana', { path: '/\nevil' }) }).toThrowError('path must not contain ";", "\\r", or "\\n"') + + // sameSite + expect(() => { + serialize('great_cookie', 'banana', { sameSite: 'Strict;evil' as 'Strict' }) + }).toThrowError('sameSite must not contain ";", "\\r", or "\\n"') + expect(() => { + serialize('great_cookie', 'banana', { sameSite: 'Strict\revil' as 'Strict' }) + }).toThrowError('sameSite must not contain ";", "\\r", or "\\n"') + expect(() => { + serialize('great_cookie', 'banana', { sameSite: 'Strict\nevil' as 'Strict' }) + }).toThrowError('sameSite must not contain ";", "\\r", or "\\n"') + + // priority + expect(() => { + serialize('great_cookie', 'banana', { priority: 'High;evil' as 'High' }) + }).toThrowError('priority must not contain ";", "\\r", or "\\n"') + expect(() => { + serialize('great_cookie', 'banana', { priority: 'High\revil' as 'High' }) + }).toThrowError('priority must not contain ";", "\\r", or "\\n"') + expect(() => { + serialize('great_cookie', 'banana', { priority: 'High\nevil' as 'High' }) + }).toThrowError('priority must not contain ";", "\\r", or "\\n"') + }) + + it('Should throw Error for invalid cookie name', () => { + expect(() => { + serialize('legit\r\nX-Injected: evil', 'value') + }).toThrowError('Invalid cookie name') + expect(() => { + serialize('bad;name', 'value') + }).toThrowError('Invalid cookie name') + expect(() => { + serialize('bad=name', 'value') + }).toThrowError('Invalid cookie name') }) it('Should serialize cookie with lowercase priority values', () => { diff --git a/src/utils/cookie.ts b/src/utils/cookie.ts index c72af768a3..20ba90c5ae 100644 --- a/src/utils/cookie.ts +++ b/src/utils/cookie.ts @@ -76,26 +76,52 @@ const validCookieNameRegEx = /^[\w!#$%&'*.^`|~+-]+$/ // (see: https://github.com/golang/go/issues/7243) const validCookieValueRegEx = /^[ !#-:<-[\]-~]*$/ +const trimCookieWhitespace = (value: string): string => { + let start = 0 + let end = value.length + + while (start < end) { + const charCode = value.charCodeAt(start) + if (charCode !== 0x20 && charCode !== 0x09) { + break + } + start++ + } + + while (end > start) { + const charCode = value.charCodeAt(end - 1) + if (charCode !== 0x20 && charCode !== 0x09) { + break + } + end-- + } + + return start === 0 && end === value.length ? value : value.slice(start, end) +} + export const parse = (cookie: string, name?: string): Cookie => { if (name && cookie.indexOf(name) === -1) { // Fast-path: return immediately if the demanded-key is not in the cookie string return {} } - const pairs = cookie.trim().split(';') - const parsedCookie: Cookie = {} - for (let pairStr of pairs) { - pairStr = pairStr.trim() + const pairs = cookie.split(';') + const parsedCookie: Cookie = Object.create(null) + for (const pairStr of pairs) { const valueStartPos = pairStr.indexOf('=') if (valueStartPos === -1) { continue } - const cookieName = pairStr.substring(0, valueStartPos).trim() - if ((name && name !== cookieName) || !validCookieNameRegEx.test(cookieName)) { + const cookieName = trimCookieWhitespace(pairStr.substring(0, valueStartPos)) + if ( + (name && name !== cookieName) || + !validCookieNameRegEx.test(cookieName) || + cookieName in parsedCookie + ) { continue } - let cookieValue = pairStr.substring(valueStartPos + 1).trim() + let cookieValue = trimCookieWhitespace(pairStr.substring(valueStartPos + 1)) if (cookieValue.startsWith('"') && cookieValue.endsWith('"')) { cookieValue = cookieValue.slice(1, -1) } @@ -116,7 +142,7 @@ export const parseSigned = async ( secret: string | BufferSource, name?: string ): Promise => { - const parsedCookie: SignedCookie = {} + const parsedCookie: SignedCookie = Object.create(null) const secretKey = await getCryptoKey(secret) for (const [key, value] of Object.entries(parse(cookie, name))) { @@ -139,6 +165,10 @@ export const parseSigned = async ( } const _serialize = (name: string, value: string, opt: CookieOptions = {}): string => { + if (!validCookieNameRegEx.test(name)) { + throw new Error('Invalid cookie name') + } + let cookie = `${name}=${value}` if (name.startsWith('__Secure-') && !opt.secure) { @@ -161,7 +191,7 @@ const _serialize = (name: string, value: string, opt: CookieOptions = {}): strin } } - for (const key of ['domain', 'path'] as (keyof CookieOptions)[]) { + for (const key of ['domain', 'path', 'sameSite', 'priority'] as (keyof CookieOptions)[]) { if (opt[key] && /[;\r\n]/.test(opt[key] as string)) { throw new Error(`${key} must not contain ";", "\\r", or "\\n"`) } diff --git a/src/utils/filepath.test.ts b/src/utils/filepath.test.ts index 875ef8cb7c..74386b943d 100644 --- a/src/utils/filepath.test.ts +++ b/src/utils/filepath.test.ts @@ -43,6 +43,17 @@ describe('getFilePathWithoutDefaultDocument', () => { expect( getFilePathWithoutDefaultDocument({ filename: slashToBackslash('./..foo../bar.txt') }) ).toBe('..foo../bar.txt') + + // Multiple path segments: every backslash must be normalized, not just the first + expect( + getFilePathWithoutDefaultDocument({ filename: slashToBackslash('foo/bar/baz.txt') }) + ).toBe('foo/bar/baz.txt') + expect( + getFilePathWithoutDefaultDocument({ + filename: slashToBackslash('foo/bar/baz.txt'), + root: 'assets', + }) + ).toBe('assets/foo/bar/baz.txt') }) }) diff --git a/src/utils/filepath.ts b/src/utils/filepath.ts index 614714831f..8b58246b12 100644 --- a/src/utils/filepath.ts +++ b/src/utils/filepath.ts @@ -43,7 +43,7 @@ export const getFilePathWithoutDefaultDocument = ( filename = filename.replace(/^\.?[\/\\]/, '') // foo\bar.txt => foo/bar.txt - filename = filename.replace(/\\/, '/') + filename = filename.replace(/\\/g, '/') // assets/ => assets root = root.replace(/\/$/, '') diff --git a/src/utils/ipaddr.test.ts b/src/utils/ipaddr.test.ts index 9636be5ffc..fd3f56cf22 100644 --- a/src/utils/ipaddr.test.ts +++ b/src/utils/ipaddr.test.ts @@ -5,8 +5,20 @@ import { convertIPv6ToBinary, distinctRemoteAddr, expandIPv6, + INVALID_IP_ADDRESS_ERROR_CODE, } from './ipaddr' +const expectInvalidIPAddressError = (fn: () => unknown) => { + try { + fn() + } catch (error) { + expect(error).toBeInstanceOf(TypeError) + expect(error).toHaveProperty('code', INVALID_IP_ADDRESS_ERROR_CODE) + return + } + throw new Error('Expected invalid IP address error') +} + describe('expandIPv6', () => { it('Should result be valid', () => { expect(expandIPv6('1::1')).toBe('0001:0000:0000:0000:0000:0000:0000:0001') @@ -16,6 +28,10 @@ describe('expandIPv6', () => { expect(expandIPv6('2001:0:0:db8::1')).toBe('2001:0000:0000:0db8:0000:0000:0000:0001') expect(expandIPv6('::ffff:127.0.0.1')).toBe('0000:0000:0000:0000:0000:ffff:7f00:0001') }) + + it('Should expand the unspecified address "::" to eight zero groups', () => { + expect(expandIPv6('::')).toBe('0000:0000:0000:0000:0000:0000:0000:0000') + }) }) describe('distinctRemoteAddr', () => { it('Should result be valid', () => { @@ -51,6 +67,13 @@ describe('convertIPv4ToBinary', () => { expect(convertIPv4ToBinary('0.0.1.0')).toBe(1n << 8n) }) + + test.each(['1.2.3.256', '1.2.3', '1.2.3.4.5', '1..3.4', '01.2.3.4', 'a.b.c.d'])( + 'Should throw for invalid IPv4: %s', + (input) => { + expectInvalidIPAddressError(() => convertIPv4ToBinary(input)) + } + ) }) describe('convertIPv4ToString', () => { @@ -71,8 +94,26 @@ describe('convertIPv6ToBinary', () => { expect(convertIPv6ToBinary('::1')).toBe(1n) expect(convertIPv6ToBinary('::f')).toBe(15n) - expect(convertIPv6ToBinary('1234:::5678')).toBe(24196103360772296748952112894165669496n) + expect(convertIPv6ToBinary('1234::5678')).toBe(24196103360772296748952112894165669496n) expect(convertIPv6ToBinary('::ffff:127.0.0.1')).toBe(281472812449793n) + expect(convertIPv6ToBinary('fe80::1%eth0')).toBe(convertIPv6ToBinary('fe80::1')) + }) + + test.each([ + '1::2::3', + '1:2:3:4:5:6:7:8:9', + '1:2:3:4:5:6:7', + '12345::', + 'gggg::', + '::ffff:127.0.0.256', + '1234:::5678', + '2001:db8::1%eth0', + '::ffff:127.0.0.1%eth0', + '1:2:3%eth0', + 'gggg%eth0', + 'fe80::1%', + ])('Should throw for invalid IPv6: %s', (input) => { + expectInvalidIPAddressError(() => convertIPv6ToBinary(input)) }) }) @@ -82,12 +123,24 @@ describe('convertIPv6ToString', () => { input | expected ${'::1'} | ${'::1'} ${'1::'} | ${'1::'} - ${'1234:::5678'} | ${'1234::5678'} + ${'1234::5678'} | ${'1234::5678'} ${'2001:2::'} | ${'2001:2::'} ${'2001::db8:0:0:0:0:1'} | ${'2001:0:db8::1'} ${'1234:5678:9abc:def0:1234:5678:9abc:def0'} | ${'1234:5678:9abc:def0:1234:5678:9abc:def0'} ${'::ffff:127.0.0.1'} | ${'::ffff:127.0.0.1'} + ${'fe80::1%eth0'} | ${'fe80::1'} + ${'1:0:2:3:4:5:6:7'} | ${'1:0:2:3:4:5:6:7'} + ${'0:1:2:3:4:5:6:7'} | ${'0:1:2:3:4:5:6:7'} + ${'1:2:3:4:5:6:7:0'} | ${'1:2:3:4:5:6:7:0'} + ${'1:0:0:2:3:4:5:6'} | ${'1::2:3:4:5:6'} `('convertIPv6ToString($input) === $expected', ({ input, expected }) => { expect(convertIPv6BinaryToString(convertIPv6ToBinary(input))).toBe(expected) }) + + it('Should compress the unspecified address (all zero groups) to "::"', () => { + expect(convertIPv6BinaryToString(0n)).toBe('::') + expect(convertIPv6BinaryToString(convertIPv6ToBinary('::'))).toBe('::') + // The output must round-trip back to the same binary value. + expect(convertIPv6ToBinary(convertIPv6BinaryToString(0n))).toBe(0n) + }) }) diff --git a/src/utils/ipaddr.ts b/src/utils/ipaddr.ts index aa67719ca4..3617aa7d5f 100644 --- a/src/utils/ipaddr.ts +++ b/src/utils/ipaddr.ts @@ -26,7 +26,10 @@ export const expandIPv6 = (ipV6: string): string => { if (node !== '') { sections[i] = node.padStart(4, '0') } else { - sections[i + 1] === '' && sections.splice(i + 1, 1) + // Keep a single empty slot for `::` zero expansion. + while (sections[i + 1] === '') { + sections.splice(i + 1, 1) + } sections[i] = new Array(8 - sections.length + 1).fill('0000').join(':') } } @@ -35,6 +38,19 @@ export const expandIPv6 = (ipV6: string): string => { const IPV4_OCTET_PART = '(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])' const IPV4_REGEX = new RegExp(`^(?:${IPV4_OCTET_PART}\\.){3}${IPV4_OCTET_PART}$`) +export const INVALID_IP_ADDRESS_ERROR_CODE = 'ERR_INVALID_IP_ADDRESS' +export type InvalidIPAddressError = TypeError & { + code: typeof INVALID_IP_ADDRESS_ERROR_CODE +} +const CHAR_CODE_0 = 48 +const CHAR_CODE_9 = 57 +const CHAR_CODE_A = 65 +const CHAR_CODE_F = 70 +const CHAR_CODE_a = 97 +const CHAR_CODE_f = 102 +const CHAR_CODE_DOT = 46 +const CHAR_CODE_COLON = 58 +const CHAR_CODE_PERCENT = 37 /** * Distinct Remote Addr @@ -50,19 +66,87 @@ export const distinctRemoteAddr = (remoteAddr: string): AddressType => { } } +const createInvalidIPAddressError = (message: string): InvalidIPAddressError => { + const error = new TypeError(message) as InvalidIPAddressError + error.code = INVALID_IP_ADDRESS_ERROR_CODE + return error +} + +const throwInvalidIPv4Address = (ipv4: string): never => { + throw createInvalidIPAddressError(`Invalid IPv4 address: ${ipv4}`) +} + +const throwInvalidIPv6Address = (ipv6: string): never => { + throw createInvalidIPAddressError(`Invalid IPv6 address: ${ipv6}`) +} + +const parseIPv4ToBinary = ( + ipv4: string, + start: number, + end: number, + onInvalid: () => never +): bigint => { + let result = 0n + let octets = 0 + let octet = 0 + let digits = 0 + let firstDigit = 0 + + for (let i = start; i <= end; i++) { + const code = i < end ? ipv4.charCodeAt(i) : CHAR_CODE_DOT + if (code >= CHAR_CODE_0 && code <= CHAR_CODE_9) { + if (digits === 0) { + firstDigit = code + } else if (firstDigit === CHAR_CODE_0) { + onInvalid() + } + octet = octet * 10 + code - CHAR_CODE_0 + if (octet > 255) { + onInvalid() + } + digits++ + continue + } + + if (code !== CHAR_CODE_DOT || digits === 0 || octets === 4) { + onInvalid() + } + + result = (result << 8n) + BigInt(octet) + octets++ + octet = 0 + digits = 0 + } + + if (octets !== 4) { + onInvalid() + } + + return result +} + +const parseIPv6HexCode = (code: number): number => { + if (code >= CHAR_CODE_0 && code <= CHAR_CODE_9) { + return code - CHAR_CODE_0 + } + if (code >= CHAR_CODE_A && code <= CHAR_CODE_F) { + return code - CHAR_CODE_A + 10 + } + if (code >= CHAR_CODE_a && code <= CHAR_CODE_f) { + return code - CHAR_CODE_a + 10 + } + return -1 +} + +const isIPv6LinkLocal = (ipv6binary: bigint): boolean => ipv6binary >> 118n === 0x3fan + /** * Convert IPv4 to Uint8Array * @param ipv4 IPv4 Address * @returns BigInt */ export const convertIPv4ToBinary = (ipv4: string): bigint => { - const parts = ipv4.split('.') - let result = 0n - for (let i = 0; i < 4; i++) { - result <<= 8n - result += BigInt(parts[i]) - } - return result + return parseIPv4ToBinary(ipv4, 0, ipv4.length, () => throwInvalidIPv4Address(ipv4)) } /** @@ -71,11 +155,139 @@ export const convertIPv4ToBinary = (ipv4: string): bigint => { * @returns BigInt */ export const convertIPv6ToBinary = (ipv6: string): bigint => { - const sections = expandIPv6(ipv6).split(':') + const length = ipv6.length + const sections: number[] = [] + let hasZoneId = false + let compressAt = -1 + let index = 0 + + if (length === 0) { + throwInvalidIPv6Address(ipv6) + } + + while (index < length) { + if (sections.length > 8) { + throwInvalidIPv6Address(ipv6) + } + + let code = ipv6.charCodeAt(index) + + if (code === CHAR_CODE_PERCENT) { + if (index + 1 === length) { + throwInvalidIPv6Address(ipv6) + } + hasZoneId = true + break + } + + if (code === CHAR_CODE_COLON) { + if (index + 1 < length && ipv6.charCodeAt(index + 1) === CHAR_CODE_COLON) { + if (compressAt !== -1) { + throwInvalidIPv6Address(ipv6) + } + compressAt = sections.length + index += 2 + continue + } + throwInvalidIPv6Address(ipv6) + } + + let value = 0 + let digits = 0 + const sectionStart = index + + while (index < length) { + code = ipv6.charCodeAt(index) + const hex = parseIPv6HexCode(code) + if (hex === -1) { + break + } + if (digits === 4) { + throwInvalidIPv6Address(ipv6) + } + value = (value << 4) | hex + digits++ + index++ + } + + if (index < length && ipv6.charCodeAt(index) === CHAR_CODE_DOT) { + let ipv4End = length + for (let i = index; i < length; i++) { + if (ipv6.charCodeAt(i) === CHAR_CODE_PERCENT) { + if (i + 1 === length) { + throwInvalidIPv6Address(ipv6) + } + hasZoneId = true + ipv4End = i + break + } + } + const ipv4 = parseIPv4ToBinary(ipv6, sectionStart, ipv4End, () => + throwInvalidIPv6Address(ipv6) + ) + sections.push(Number((ipv4 >> 16n) & 0xffffn), Number(ipv4 & 0xffffn)) + index = length + break + } + + if (digits === 0) { + throwInvalidIPv6Address(ipv6) + } + + sections.push(value) + + if (index === length) { + break + } + + code = ipv6.charCodeAt(index) + if (code === CHAR_CODE_PERCENT) { + if (index + 1 === length) { + throwInvalidIPv6Address(ipv6) + } + hasZoneId = true + break + } + + if (code !== CHAR_CODE_COLON) { + throwInvalidIPv6Address(ipv6) + } + + if (index + 1 < length && ipv6.charCodeAt(index + 1) === CHAR_CODE_COLON) { + if (compressAt !== -1) { + throwInvalidIPv6Address(ipv6) + } + compressAt = sections.length + index += 2 + continue + } + + index++ + if (index === length) { + throwInvalidIPv6Address(ipv6) + } + } + + if (compressAt === -1 ? sections.length !== 8 : sections.length >= 8) { + throwInvalidIPv6Address(ipv6) + } + let result = 0n - for (let i = 0; i < 8; i++) { + const zeros = compressAt === -1 ? 0 : 8 - sections.length + const firstSectionEnd = compressAt === -1 ? sections.length : compressAt + for (let i = 0; i < firstSectionEnd; i++) { result <<= 16n - result += BigInt(parseInt(sections[i], 16)) + result += BigInt(sections[i]) + } + for (let i = 0; i < zeros; i++) { + result <<= 16n + } + for (let i = firstSectionEnd; i < sections.length; i++) { + result <<= 16n + result += BigInt(sections[i]) + } + if (hasZoneId && !isIPv6LinkLocal(result)) { + throwInvalidIPv6Address(ipv6) } return result } @@ -93,15 +305,34 @@ export const convertIPv4BinaryToString = (ipV4: bigint): string => { return sections.join('.') } +/** + * Check if a binary IPv6 address is an IPv4-mapped IPv6 address (::ffff:x.x.x.x) + * @param ipv6binary binary IPv6 Address + * @return true if the address is an IPv4-mapped IPv6 address + */ +export const isIPv4MappedIPv6 = (ipv6binary: bigint): boolean => ipv6binary >> 32n === 0xffffn + +/** + * Extract the IPv4 portion from an IPv4-mapped IPv6 address + * @param ipv6binary binary IPv4-mapped IPv6 Address + * @return binary IPv4 Address + */ +export const convertIPv4MappedIPv6ToIPv4 = (ipv6binary: bigint): bigint => ipv6binary & 0xffffffffn + /** * Convert a binary representation of an IPv6 address to a string. * @param ipV6 binary IPv6 Address * @return normalized IPv6 Address in string */ export const convertIPv6BinaryToString = (ipV6: bigint): string => { - // IPv6-mapped IPv4 address - if (ipV6 >> 32n === 0xffffn) { - return `::ffff:${convertIPv4BinaryToString(ipV6 & 0xffffffffn)}` + if (ipV6 === 0n) { + // The unspecified address compresses every group, which the generic + // logic below would render as a single ":". Handle it explicitly. + return '::' + } + + if (isIPv4MappedIPv6(ipV6)) { + return `::ffff:${convertIPv4BinaryToString(convertIPv4MappedIPv6ToIPv4(ipV6))}` } const sections = [] @@ -133,7 +364,8 @@ export const convertIPv6BinaryToString = (ipV6: bigint): string => { maxZeroEnd = 8 } } - if (maxZeroStart !== -1) { + // RFC 5952 4.2.2: compress only zero runs of at least two 16-bit fields. + if (maxZeroStart !== -1 && maxZeroEnd - maxZeroStart > 1) { sections.splice(maxZeroStart, maxZeroEnd - maxZeroStart, ':') } diff --git a/src/utils/jwt/jws.ts b/src/utils/jwt/jws.ts index cd66021d25..390278ca93 100644 --- a/src/utils/jwt/jws.ts +++ b/src/utils/jwt/jws.ts @@ -48,7 +48,7 @@ export async function verifying( } function pemToBinary(pem: string): Uint8Array { - return decodeBase64(pem.replace(/-+(BEGIN|END).*/g, '').replace(/\s/g, '')) + return decodeBase64(pem.replace(/-+(BEGIN|END).*?-+/g, '').replace(/\s/g, '')) } async function importPrivateKey(key: SignatureKey, alg: KeyImporterAlgorithm): Promise { diff --git a/src/utils/jwt/jwt.test.ts b/src/utils/jwt/jwt.test.ts index 1a7a4cee04..937b71d7b9 100644 --- a/src/utils/jwt/jwt.test.ts +++ b/src/utils/jwt/jwt.test.ts @@ -108,6 +108,53 @@ describe('JWT', () => { expect(authorized).toBeUndefined() }) + describe('JwtTokenNotBefore with malformed nbf claim', () => { + it('rejects token with nbf as a non-numeric string', async () => { + const secret = 'a-secret' + const tok = await JWT.sign( + // @ts-expect-error - testing malformed payload (nbf must be number) + { message: 'hello', nbf: 'tomorrow' }, + secret, + AlgorithmTypes.HS256 + ) + + let err + let authorized + try { + authorized = await JWT.verify(tok, secret, AlgorithmTypes.HS256) + } catch (e) { + err = e + } + expect(err).toEqual(new JwtTokenNotBefore(tok)) + expect(authorized).toBeUndefined() + }) + + it('rejects token with nbf = Infinity (parsed from 1e400 in JSON)', async () => { + // JSON.stringify converts Infinity to null, so hand-craft the payload. + const secret = 'a-secret' + const encode = (s: string) => encodeBase64Url(utf8Encoder.encode(s).buffer).replace(/=/g, '') + const encodedHeader = encode('{"alg":"HS256","typ":"JWT"}') + const encodedPayload = encode('{"message":"hello","nbf":1e400}') + const signingInput = `${encodedHeader}.${encodedPayload}` + const signatureBuffer = await signing( + secret, + AlgorithmTypes.HS256, + utf8Encoder.encode(signingInput) + ) + const tok = `${signingInput}.${encodeBase64Url(signatureBuffer).replace(/=/g, '')}` + + let err + let authorized + try { + authorized = await JWT.verify(tok, secret, AlgorithmTypes.HS256) + } catch (e) { + err = e + } + expect(err).toEqual(new JwtTokenNotBefore(tok)) + expect(authorized).toBeUndefined() + }) + }) + it('JwtTokenExpired', async () => { const tok = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2MzMwNDYxMDAsImV4cCI6MTYzMzA0NjQwMH0.H-OI1TWAbmK8RonvcpPaQcNvOKS9sxinEOsgKwjoiVo' @@ -145,6 +192,68 @@ describe('JWT', () => { vi.useRealTimers() }) + describe('JwtTokenExpired with malformed exp claim', () => { + it('rejects token with exp = 0 (epoch zero)', async () => { + const secret = 'a-secret' + const tok = await JWT.sign({ message: 'hello', exp: 0 }, secret, AlgorithmTypes.HS256) + + let err + let authorized + try { + authorized = await JWT.verify(tok, secret, AlgorithmTypes.HS256) + } catch (e) { + err = e + } + expect(err).toEqual(new JwtTokenExpired(tok)) + expect(authorized).toBeUndefined() + }) + + it('rejects token with exp as a non-numeric string', async () => { + const secret = 'a-secret' + const tok = await JWT.sign( + // @ts-expect-error - testing malformed payload (exp must be number) + { message: 'hello', exp: 'tomorrow' }, + secret, + AlgorithmTypes.HS256 + ) + + let err + let authorized + try { + authorized = await JWT.verify(tok, secret, AlgorithmTypes.HS256) + } catch (e) { + err = e + } + expect(err).toEqual(new JwtTokenExpired(tok)) + expect(authorized).toBeUndefined() + }) + + it('rejects token with exp = Infinity (parsed from 1e400 in JSON)', async () => { + // JSON.stringify converts Infinity to null, so hand-craft the payload. + const secret = 'a-secret' + const encode = (s: string) => encodeBase64Url(utf8Encoder.encode(s).buffer).replace(/=/g, '') + const encodedHeader = encode('{"alg":"HS256","typ":"JWT"}') + const encodedPayload = encode('{"message":"hello","exp":1e400}') + const signingInput = `${encodedHeader}.${encodedPayload}` + const signatureBuffer = await signing( + secret, + AlgorithmTypes.HS256, + utf8Encoder.encode(signingInput) + ) + const tok = `${signingInput}.${encodeBase64Url(signatureBuffer).replace(/=/g, '')}` + + let err + let authorized + try { + authorized = await JWT.verify(tok, secret, AlgorithmTypes.HS256) + } catch (e) { + err = e + } + expect(err).toEqual(new JwtTokenExpired(tok)) + expect(authorized).toBeUndefined() + }) + }) + it('JwtTokenIssuedAt', async () => { const now = 1633046400 vi.useFakeTimers().setSystemTime(new Date().setTime(now * 1000)) @@ -165,6 +274,63 @@ describe('JWT', () => { expect(authorized).toBeUndefined() }) + describe('JwtTokenIssuedAt with malformed iat claim', () => { + it('rejects token with iat as a non-numeric string', async () => { + const now = 1633046400 + vi.useFakeTimers().setSystemTime(new Date(now * 1000)) + + const secret = 'a-secret' + const tok = await JWT.sign( + // @ts-expect-error - testing malformed payload (iat must be number) + { message: 'hello', iat: 'tomorrow' }, + secret, + AlgorithmTypes.HS256 + ) + + let err + let authorized + try { + authorized = await JWT.verify(tok, secret, AlgorithmTypes.HS256) + } catch (e) { + err = e + } + expect(err).toEqual(new JwtTokenIssuedAt(now, 'tomorrow' as unknown as number)) + expect(authorized).toBeUndefined() + + vi.useRealTimers() + }) + + it('rejects token with iat = Infinity (parsed from 1e400 in JSON)', async () => { + // JSON.stringify converts Infinity to null, so hand-craft the payload. + const now = 1633046400 + vi.useFakeTimers().setSystemTime(new Date(now * 1000)) + + const secret = 'a-secret' + const encode = (s: string) => encodeBase64Url(utf8Encoder.encode(s).buffer).replace(/=/g, '') + const encodedHeader = encode('{"alg":"HS256","typ":"JWT"}') + const encodedPayload = encode('{"message":"hello","iat":1e400}') + const signingInput = `${encodedHeader}.${encodedPayload}` + const signatureBuffer = await signing( + secret, + AlgorithmTypes.HS256, + utf8Encoder.encode(signingInput) + ) + const tok = `${signingInput}.${encodeBase64Url(signatureBuffer).replace(/=/g, '')}` + + let err + let authorized + try { + authorized = await JWT.verify(tok, secret, AlgorithmTypes.HS256) + } catch (e) { + err = e + } + expect(err).toEqual(new JwtTokenIssuedAt(now, Infinity)) + expect(authorized).toBeUndefined() + + vi.useRealTimers() + }) + }) + it('JwtTokenIssuer (none)', async () => { const tok = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2MzMwNDY0MDB9.Ha3tPZzmnLGyFfZYd7GSV0iCn2F9kbZffFVZcTe5kJo' @@ -1573,3 +1739,36 @@ describe('JWT decode token format validation', () => { expect(header.typ).toBe('JWT') }) }) + +describe('PEM parsing', async () => { + const keyPair = await crypto.subtle.generateKey( + { + name: 'Ed25519', + }, + true, + ['sign', 'verify'] + ) + + const exported = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey) + const base64Key = Buffer.from(exported).toString('base64') + const privateKey = `-----BEGIN PRIVATE KEY-----\n${base64Key}\n-----END PRIVATE KEY-----` + + it('should sign & verify with a normal private PEM key', async () => { + const payload = { sub: '123' } + + const token = await JWT.sign(payload, privateKey, 'EdDSA') + const verified = await JWT.verify(token, privateKey, 'EdDSA') + + expect(verified).toEqual(payload) + }) + + it('should sign & verify with a single-line private PEM key', async () => { + const singleLinePrivateKey = privateKey.replace(/\n/g, '') + const payload = { sub: '123' } + + const token = await JWT.sign(payload, singleLinePrivateKey, 'EdDSA') + const verified = await JWT.verify(token, privateKey, 'EdDSA') + + expect(verified).toEqual(payload) + }) +}) diff --git a/src/utils/jwt/jwt.ts b/src/utils/jwt/jwt.ts index 0d7e7d75cb..f9b91bf945 100644 --- a/src/utils/jwt/jwt.ts +++ b/src/utils/jwt/jwt.ts @@ -128,14 +128,20 @@ export const verify = async ( throw new JwtAlgorithmMismatch(alg, header.alg) } const now = Math.floor(Date.now() / 1000) - if (nbf && payload.nbf && payload.nbf > now) { - throw new JwtTokenNotBefore(token) + if (nbf && payload.nbf !== undefined) { + if (typeof payload.nbf !== 'number' || !Number.isFinite(payload.nbf) || payload.nbf > now) { + throw new JwtTokenNotBefore(token) + } } - if (exp && payload.exp && payload.exp <= now) { - throw new JwtTokenExpired(token) + if (exp && payload.exp !== undefined) { + if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp) || payload.exp <= now) { + throw new JwtTokenExpired(token) + } } - if (iat && payload.iat && now < payload.iat) { - throw new JwtTokenIssuedAt(now, payload.iat) + if (iat && payload.iat !== undefined) { + if (typeof payload.iat !== 'number' || !Number.isFinite(payload.iat) || now < payload.iat) { + throw new JwtTokenIssuedAt(now, payload.iat) + } } if (iss) { if (!payload.iss) { diff --git a/src/utils/mime.test.ts b/src/utils/mime.test.ts index 6d4facde8f..12a9dee28e 100644 --- a/src/utils/mime.test.ts +++ b/src/utils/mime.test.ts @@ -18,6 +18,16 @@ describe('mime', () => { expect(getMimeType('IMAGE.JPG')).toBe('image/jpeg') }) + it('getMimeType returns charset parameter for text and XML-based formats', () => { + expect(getMimeType('icon.svg')).toBe('image/svg+xml; charset=utf-8') + expect(getMimeType('page.xhtml')).toBe('application/xhtml+xml; charset=utf-8') + expect(getMimeType('feed.xml')).toBe('application/xml; charset=utf-8') + expect(getMimeType('events.ics')).toBe('text/calendar; charset=utf-8') + expect(getMimeType('script.mjs')).toBe('text/javascript; charset=utf-8') + expect(getMimeType('style.css')).toBe('text/css; charset=utf-8') + expect(getMimeType('data.csv')).toBe('text/csv; charset=utf-8') + }) + it('getMimeType with custom mime', () => { expect(getMimeType('morning-routine.m3u8', mime)).toBe('application/vnd.apple.mpegurl') expect(getMimeType('morning-routine1.ts', mime)).toBe('video/mp2t') @@ -40,4 +50,15 @@ describe('mime', () => { expect(getExtension('application/zip')).toBe('zip') expect(getExtension('non/existent')).toBeUndefined() }) + + it('getExtension matches MIME types regardless of charset/parameters', () => { + expect(getExtension('text/html; charset=utf-8')).toBe('htm') + expect(getExtension('text/plain; charset=utf-8')).toBe('txt') + expect(getExtension('image/svg+xml; charset=utf-8')).toBe('svg') + expect(getExtension('application/xml; charset=utf-8')).toBe('xml') + expect(getExtension('application/xhtml+xml; charset=utf-8')).toBe('xhtml') + expect(getExtension('text/css; charset=utf-16')).toBe('css') + expect(getExtension('image/svg+xml')).toBe('svg') + expect(getExtension('application/xml')).toBe('xml') + }) }) diff --git a/src/utils/mime.ts b/src/utils/mime.ts index 7db06d1167..cd974e630d 100644 --- a/src/utils/mime.ts +++ b/src/utils/mime.ts @@ -12,16 +12,14 @@ export const getMimeType = ( if (!match) { return } - let mimeType = mimes[match[1].toLowerCase()] - if (mimeType && mimeType.startsWith('text')) { - mimeType += '; charset=utf-8' - } - return mimeType + return mimes[match[1].toLowerCase()] } export const getExtension = (mimeType: string): string | undefined => { + const baseType = mimeType.split(';', 1)[0].trim() for (const ext in baseMimes) { - if (baseMimes[ext] === mimeType) { + const stored = baseMimes[ext] + if (stored === mimeType || stored.split(';', 1)[0].trim() === baseType) { return ext } } @@ -41,25 +39,25 @@ const _baseMimes = { av1: 'video/av1', bin: 'application/octet-stream', bmp: 'image/bmp', - css: 'text/css', - csv: 'text/csv', + css: 'text/css; charset=utf-8', + csv: 'text/csv; charset=utf-8', eot: 'application/vnd.ms-fontobject', epub: 'application/epub+zip', gif: 'image/gif', gz: 'application/gzip', - htm: 'text/html', - html: 'text/html', + htm: 'text/html; charset=utf-8', + html: 'text/html; charset=utf-8', ico: 'image/x-icon', - ics: 'text/calendar', + ics: 'text/calendar; charset=utf-8', jpeg: 'image/jpeg', jpg: 'image/jpeg', - js: 'text/javascript', + js: 'text/javascript; charset=utf-8', json: 'application/json', jsonld: 'application/ld+json', map: 'application/json', mid: 'audio/x-midi', midi: 'audio/x-midi', - mjs: 'text/javascript', + mjs: 'text/javascript; charset=utf-8', mp3: 'audio/mpeg', mp4: 'video/mp4', mpeg: 'video/mpeg', @@ -71,12 +69,12 @@ const _baseMimes = { pdf: 'application/pdf', png: 'image/png', rtf: 'application/rtf', - svg: 'image/svg+xml', + svg: 'image/svg+xml; charset=utf-8', tif: 'image/tiff', tiff: 'image/tiff', ts: 'video/mp2t', ttf: 'font/ttf', - txt: 'text/plain', + txt: 'text/plain; charset=utf-8', wasm: 'application/wasm', webm: 'video/webm', weba: 'audio/webm', @@ -84,8 +82,8 @@ const _baseMimes = { webp: 'image/webp', woff: 'font/woff', woff2: 'font/woff2', - xhtml: 'application/xhtml+xml', - xml: 'application/xml', + xhtml: 'application/xhtml+xml; charset=utf-8', + xml: 'application/xml; charset=utf-8', zip: 'application/zip', '3gp': 'video/3gpp', '3g2': 'video/3gpp2', diff --git a/src/utils/stream.ts b/src/utils/stream.ts index f25f6f7850..b4d0463c53 100644 --- a/src/utils/stream.ts +++ b/src/utils/stream.ts @@ -38,7 +38,9 @@ export class StreamingApi { done ? controller.close() : controller.enqueue(value) }, cancel: () => { - this.abort() + if (!this.closed) { + this.abort() + } }, }) } @@ -65,12 +67,12 @@ export class StreamingApi { } async close() { + this.closed = true try { await this.writer.close() } catch { // Do nothing. If you want to handle errors, create a stream by yourself. } - this.closed = true } async pipe(body: ReadableStream) { diff --git a/tsconfig.build.json b/tsconfig.build.json index 7843b827c4..f64fb981b2 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -12,12 +12,7 @@ "src/**/*.mts" ], "exclude": [ - "src/mod.ts", - "src/helper.ts", - "src/middleware.ts", - "src/deno/**/*.ts", - "src/test-utils/*.ts", "src/**/*.test.ts", - "src/**/*.test.tsx", + "src/**/*.test.tsx" ] } diff --git a/vitest.config.ts b/vitest.config.ts index db815d9ffb..62594168e7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,6 @@ import { configDefaults, defineConfig } from 'vitest/config' export default defineConfig({ test: { globals: true, - setupFiles: ['./.vitest.config/setup-vitest.ts'], coverage: { enabled: true, provider: 'v8', @@ -26,9 +25,11 @@ export default defineConfig({ projects: [ './runtime-tests/*/vitest.config.ts', { - esbuild: { - jsx: 'automatic', - jsxImportSource: './src/jsx', + oxc: { + jsx: { + runtime: 'automatic', + importSource: './src/jsx', + }, }, extends: true, test: { @@ -42,9 +43,11 @@ export default defineConfig({ }, }, { - esbuild: { - jsx: 'automatic', - jsxImportSource: './src/jsx', + oxc: { + jsx: { + runtime: 'automatic', + importSource: './src/jsx', + }, }, extends: true, test: { @@ -53,9 +56,11 @@ export default defineConfig({ }, }, { - esbuild: { - jsx: 'automatic', - jsxImportSource: './src/jsx/dom', + oxc: { + jsx: { + runtime: 'automatic', + importSource: './src/jsx/dom', + }, }, extends: true, test: {