diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..c49dbd8b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json", + "name": "default", + "image": "golang:1.23-bookworm", + "features": { + "ghcr.io/guiyomh/features/golangci-lint:0": {}, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/sshd:1": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "streetsidesoftware.code-spell-checker" + ] + } + }, + "postCreateCommand": "go mod download" +} diff --git a/.editorconfig b/.editorconfig index df1c5135..27a58b55 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,5 +10,9 @@ indent_style = space indent_size = 2 trim_trailing_whitespace = true +[{*.yml,*.yaml}] +ij_any_spaces_within_braces = false +ij_any_spaces_within_brackets = false + [{Makefile,go.mod,*.go}] indent_style = tab diff --git a/.env.example b/.env.example deleted file mode 100644 index 9a3924e1..00000000 --- a/.env.example +++ /dev/null @@ -1,29 +0,0 @@ -# IP address for listening (alias - PORT) -LISTEN_ADDR=0.0.0.0 - -# port number for listening -LISTEN_PORT=8080 - -# maximum stored requests per session -MAX_REQUESTS=128 - -# session lifetime -SESSION_TTL=168h - -# storage driver name -STORAGE_DRIVER=memory - -# pub/sub driver name -PUBSUB_DRIVER=memory - -# maximal websocket clients -WS_MAX_CLIENTS=0 - -# maximal single websocket lifetime -WS_MAX_LIFETIME=0 - -# URL-like redis connection string -REDIS_DSN=redis://127.0.0.1:6379/0 - -# Persistent session UUID -CREATE_SESSION=00000000-0000-0000-0000-000000000000 diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index e446c120..00000000 --- a/.gitattributes +++ /dev/null @@ -1,10 +0,0 @@ -# Text files have auto line endings -* text=auto - -# Go source files always have LF line endings -*.go text eol=lf - -# Disable next extensions in project "used languages" list -Dockerfile linguist-detectable=false -Makefile linguist-detectable=false -*.html linguist-detectable=false diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4576b8ac..f214887b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,29 +4,28 @@ version: 2 updates: - - package-ecosystem: gomod + - package-ecosystem: github-actions directory: / - groups: {gomod: {patterns: ['*'], exclude-patterns: ['github.com/gorilla/websocket']}} schedule: {interval: monthly} + groups: {github-actions: {patterns: ['*']}} assignees: [tarampampam] - - package-ecosystem: npm - directory: /web - open-pull-requests-limit: 15 - groups: - npm-production: {dependency-type: production, update-types: [minor, patch]} - npm-development: {dependency-type: development, update-types: [minor, patch]} + - package-ecosystem: docker + directory: / schedule: {interval: monthly} + groups: {docker: {patterns: ['*']}} assignees: [tarampampam] - - package-ecosystem: github-actions + - package-ecosystem: gomod directory: / - groups: {github-actions: {patterns: ['*']}} schedule: {interval: monthly} + groups: {gomod: {patterns: ['*']}} assignees: [tarampampam] - - package-ecosystem: docker - directory: / - groups: {docker: {patterns: ['*']}} + - package-ecosystem: npm + directory: /web schedule: {interval: monthly} + groups: + npm-production: {dependency-type: production, update-types: [minor, patch]} + npm-development: {dependency-type: development, update-types: [minor, patch]} assignees: [tarampampam] diff --git a/.github/release.yml b/.github/release.yml deleted file mode 100644 index 2a0b405a..00000000 --- a/.github/release.yml +++ /dev/null @@ -1,13 +0,0 @@ -# yaml-language-server: $schema=https://json.schemastore.org/github-release-config.json -# docs: https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes - -changelog: - categories: - - title: 🛠 Fixes - labels: [type:fix, type:bug] - - title: 🚀 Features - labels: [type:feature, type:feature_request] - - title: 📦 Dependency updates - labels: [dependencies] - - title: Other Changes - labels: ['*'] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44e6e8f9..9dcc2e2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,36 +4,41 @@ name: 🚀 Release on: - release: # Docs: - types: [published] + release: {types: [published]} + workflow_dispatch: {} jobs: build-app: - name: 🏗️ Build the app (${{ matrix.os }} / ${{ matrix.arch }}) + name: Build for ${{ matrix.os }} (${{ matrix.arch }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: - os: [linux, windows, darwin] - arch: [amd64] + os: [linux, darwin, windows] + arch: [amd64, arm64] + env: {FORCE_COLOR: 'true', NPM_PREFIX: './web'} steps: - uses: actions/checkout@v4 - - {uses: actions/setup-node@v4, with: {node-version: 22, cache: 'npm', cache-dependency-path: web/package-lock.json}} - - {working-directory: ./web, run: npm ci --no-audit && npm run generate} - - {working-directory: ./web, run: npm run build} - - {uses: gacts/setup-go-with-cache@v1, with: {go-version-file: go.mod}} - - run: | - go install "github.com/deepmap/oapi-codegen/v2/cmd/oapi-codegen@v2.2.0" - go generate ./... - {uses: gacts/github-slug@v1, id: slug} - id: values run: echo "binary-name=webhook-tester-${{ matrix.os }}-${{ matrix.arch }}`[ ${{ matrix.os }} = 'windows' ] && echo '.exe'`" >> $GITHUB_OUTPUT + # build the frontend + - uses: actions/setup-node@v4 + with: {node-version: 22, cache: 'npm', cache-dependency-path: ./web/package-lock.json} + - run: npm --prefix "$NPM_PREFIX" install --no-audit + - run: npm --prefix "$NPM_PREFIX" run generate + - run: npm --prefix "$NPM_PREFIX" run build + # build the backend + - {uses: actions/setup-go@v5, with: {go-version-file: go.mod}} + - run: go install "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1" + - run: go generate -skip readme ./... - env: GOOS: ${{ matrix.os }} GOARCH: ${{ matrix.arch }} CGO_ENABLED: 0 - LDFLAGS: -s -w -X gh.tarampamp.am/webhook-tester/internal/version.version=${{ steps.slug.outputs.version }} - run: go build -trimpath -ldflags "$LDFLAGS" -o "./${{ steps.values.outputs.binary-name }}" ./cmd/webhook-tester/ + LDFLAGS: -s -w -X gh.tarampamp.am/webhook-tester/v2/internal/version.version=${{ steps.slug.outputs.version }} + run: go build -trimpath -ldflags "$LDFLAGS" -o ./${{ steps.values.outputs.binary-name }} ./cmd/webhook-tester/ + # upload the binary - uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} @@ -42,30 +47,33 @@ jobs: tag: ${{ github.ref }} build-docker-image: - name: 🏗️ Build the docker image + name: Build the docker image runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - {uses: gacts/github-slug@v1, id: slug} - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_LOGIN }} - password: ${{ secrets.DOCKER_PASSWORD }} - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_LOGIN }} + password: ${{ secrets.DOCKER_PASSWORD }} + - {uses: gacts/github-slug@v1, id: slug} + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 - uses: docker/build-push-action@v6 with: context: . + file: ./Dockerfile push: true - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64,linux/arm/v7,linux/arm64/v8 build-args: "APP_VERSION=${{ steps.slug.outputs.version }}" - tags: | - tarampampam/webhook-tester:${{ steps.slug.outputs.version }} - tarampampam/webhook-tester:latest + tags: | # TODO: add `ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:latest` and `docker.io/tarampampam/webhook-tester:latest` ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version }} - ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:latest + ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }} + ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version-major }} + docker.io/tarampampam/webhook-tester:${{ steps.slug.outputs.version }} + docker.io/tarampampam/webhook-tester:${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }} + docker.io/tarampampam/webhook-tester:${{ steps.slug.outputs.version-major }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 78fd3b4d..c7be9698 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,6 +4,7 @@ name: 🧪 Tests on: + workflow_dispatch: {} push: branches: [master, main] paths-ignore: ['**.md'] @@ -16,185 +17,88 @@ concurrency: cancel-in-progress: true jobs: - gitleaks: + git-leaks: name: Check for GitLeaks runs-on: ubuntu-latest steps: - {uses: actions/checkout@v4, with: {fetch-depth: 0}} - uses: gacts/gitleaks@v1 - validate-openapi: - name: Validate OpenAPI schemas - runs-on: ubuntu-latest - container: stoplight/spectral:5.9 - steps: - - uses: actions/checkout@v4 - - run: spectral lint --verbose --fail-severity warn ./api/*.y*ml # Tool page: - - build-frontend: - name: Lint and build the frontend - runs-on: ubuntu-latest - env: {FORCE_COLOR: 'true', NPM_CONFIG_UPDATE_NOTIFIER: 'false'} - defaults: {run: {working-directory: ./web}} - steps: - - uses: actions/checkout@v4 - - {uses: actions/setup-node@v4, with: {node-version: 22, cache: 'npm', cache-dependency-path: web/package-lock.json}} - - run: npm ci --no-audit --prefer-offline && npm run generate - - run: npm run lint - - run: npm run build - - uses: actions/upload-artifact@v4 - with: {path: ./web/dist, name: frontend-dist, if-no-files-found: error, retention-days: 1} - - golangci-lint: - name: Run GolangCI-lint + lint-and-test: + name: Test and lint (backend) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - {uses: actions/setup-go@v5, with: {go-version-file: go.mod}} - - run: | - mkdir ./web/dist && touch ./web/dist/index.html # is needed for go:embed - go install "github.com/deepmap/oapi-codegen/v2/cmd/oapi-codegen@v2.2.0" - go generate ./... + - run: go install "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1" + - run: go generate -skip readme ./... - uses: golangci/golangci-lint-action@v6 + - run: go test -race -covermode=atomic ./... - go-test: - name: Run unit-tests (Go) + lint-and-test-web: + name: Test and lint (web) runs-on: ubuntu-latest + env: {FORCE_COLOR: 'true', NPM_PREFIX: './web'} steps: - uses: actions/checkout@v4 - - {uses: actions/setup-go@v5, with: {go-version-file: go.mod}} - - run: | - mkdir ./web/dist && touch ./web/dist/index.html # is needed for go:embed - go install "github.com/deepmap/oapi-codegen/v2/cmd/oapi-codegen@v2.2.0" - go generate ./... - - run: go test -race -covermode=atomic ./... + - uses: actions/setup-node@v4 + with: {node-version: 22, cache: 'npm', cache-dependency-path: ./web/package-lock.json} + - run: npm --prefix "$NPM_PREFIX" install -dd --no-audit + - run: npm --prefix "$NPM_PREFIX" run generate + - run: npm --prefix "$NPM_PREFIX" run lint + - run: npm --prefix "$NPM_PREFIX" run test + - run: npm --prefix "$NPM_PREFIX" run build build-app: - name: Build the app (${{ matrix.os }} / ${{ matrix.arch }}) + name: Build for ${{ matrix.os }} (${{ matrix.arch }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: - os: [linux, windows, darwin] # linux, freebsd, darwin, windows - arch: [amd64] # amd64, 386 - needs: [validate-openapi, golangci-lint, go-test, build-frontend] + os: [linux, darwin, windows] + arch: [amd64, arm64] + env: {FORCE_COLOR: 'true', NPM_PREFIX: './web'} + needs: [lint-and-test, lint-and-test-web] steps: - uses: actions/checkout@v4 - - {uses: actions/setup-go@v5, with: {go-version-file: go.mod}} - - uses: actions/download-artifact@v4 - with: {path: web/dist, name: frontend-dist} - - run: | - go install "github.com/deepmap/oapi-codegen/v2/cmd/oapi-codegen@v2.2.0" - go generate ./... - {uses: gacts/github-slug@v1, id: slug} + - id: values + run: echo "binary-name=webhook-tester-${{ matrix.os }}-${{ matrix.arch }}`[ ${{ matrix.os }} = 'windows' ] && echo '.exe'`" >> $GITHUB_OUTPUT + # build the frontend + - uses: actions/setup-node@v4 + with: {node-version: 22, cache: 'npm', cache-dependency-path: ./web/package-lock.json} + - run: npm --prefix "$NPM_PREFIX" install --no-audit + - run: npm --prefix "$NPM_PREFIX" run generate + - run: npm --prefix "$NPM_PREFIX" run build + # build the backend + - {uses: actions/setup-go@v5, with: {go-version-file: go.mod}} + - run: go install "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1" + - run: go generate -skip readme ./... - env: GOOS: ${{ matrix.os }} GOARCH: ${{ matrix.arch }} CGO_ENABLED: 0 - LDFLAGS: -s -w -X gh.tarampamp.am/webhook-tester/internal/version.version=${{ steps.slug.outputs.commit-hash-short }} - run: go build -trimpath -ldflags "$LDFLAGS" -o /tmp/webhook-tester ./cmd/webhook-tester/ - - name: Try to execute - if: matrix.os == 'linux' - run: /tmp/webhook-tester --version && /tmp/webhook-tester -h + LDFLAGS: -s -w -X gh.tarampamp.am/webhook-tester/v2/internal/version.version=v0.0.0@${{ steps.slug.outputs.commit-hash-short }} + run: go build -trimpath -ldflags "$LDFLAGS" -o ./${{ steps.values.outputs.binary-name }} ./cmd/webhook-tester/ + # try to run the binary + - if: matrix.os == runner.os && matrix.arch == 'amd64' + run: ./${{ steps.values.outputs.binary-name }} -h - uses: actions/upload-artifact@v4 with: - name: webhook-tester-${{ matrix.os }}-${{ matrix.arch }} - path: /tmp/webhook-tester + name: ${{ steps.values.outputs.binary-name }} + path: ./${{ steps.values.outputs.binary-name }} if-no-files-found: error - retention-days: 3 + retention-days: 7 build-docker-image: name: Build the docker image runs-on: ubuntu-latest - #needs: [validate-openapi, golangci-lint, go-test, build-frontend] # speed up tests pipeline + needs: [lint-and-test, lint-and-test-web] steps: - uses: actions/checkout@v4 - - {uses: gacts/github-slug@v1, id: slug} - uses: docker/build-push-action@v6 with: context: . + file: ./Dockerfile push: false - build-args: "APP_VERSION=${{ steps.slug.outputs.commit-hash-short }}" tags: app:local - - run: docker run --rm app:local --version - - run: docker save app:local > ./docker-image.tar - - uses: actions/upload-artifact@v4 - with: {path: ./docker-image.tar, name: docker-image, retention-days: 1} - - scan-docker-image: - name: Scan the docker image - runs-on: ubuntu-latest - needs: [build-docker-image] - steps: - - uses: actions/checkout@v4 # is needed for `upload-sarif` action - - uses: actions/download-artifact@v4 - with: {name: docker-image} - - uses: aquasecurity/trivy-action@0.24.0 - with: - input: docker-image.tar - format: sarif - severity: MEDIUM,HIGH,CRITICAL - exit-code: 1 - output: trivy-results.sarif - - uses: github/codeql-action/upload-sarif@v3 - if: always() - continue-on-error: true - with: {sarif_file: trivy-results.sarif} - - e2e-test-app: - name: End-to-End tests (${{ matrix.storage-driver }} storage, ${{ matrix.pubsub-driver }} pubsub) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - storage-driver: [memory, redis] - pubsub-driver: [memory, redis] - services: - redis: - image: redis:7-alpine - ports: ['6379:6379/tcp'] - options: --health-cmd "redis-cli ping" --health-interval 3s --health-timeout 2s --health-retries 3 - needs: [build-app] - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: {name: webhook-tester-linux-amd64} - - run: | - chmod +x ./webhook-tester - ./webhook-tester serve --port 8081 \ - --storage-driver "${{ matrix.storage-driver }}" \ - --pubsub-driver "${{ matrix.pubsub-driver }}" \ - --redis-dsn "redis://127.0.0.1:6379/0" & - - uses: gacts/install-hurl@v1 - - run: hurl --color --test --fail-at-end --variable host=127.0.0.1 --variable port=8081 ./test/hurl/*/*.hurl - - e2e-docker-image: - name: Docker image End-to-End tests - runs-on: ubuntu-latest - services: - redis: - image: redis:7-alpine - ports: ['6379:6379/tcp'] - options: --health-cmd "redis-cli ping" --health-interval 3s --health-timeout 2s --health-retries 3 - needs: [build-docker-image] - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: {name: docker-image} - - name: Load the image with the app - run: docker load < docker-image.tar - - name: Run docker image with app - run: | - docker run --rm -d \ - --network host \ - --name app \ - -p "8081:8081/tcp" \ - -e "STORAGE_DRIVER=redis" \ - -e "PUBSUB_DRIVER=redis" \ - -e "REDIS_DSN=redis://127.0.0.1:6379/0" \ - -e "LISTEN_PORT=8081" \ - app:local - - uses: gacts/install-hurl@v1 - - run: hurl --color --test --fail-at-end --variable host=127.0.0.1 --variable port=8081 ./test/hurl/*/*.hurl - - if: always() - run: docker kill app diff --git a/.gitignore b/.gitignore index 3049fc38..6b6ac40d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,18 @@ -## IDEs +# IDEs +/nbproject /.idea -/.vscode -## Temp dirs & trash +## Binaries +/app +/webhook-tester + +## Generated code +*.gen.go +*_enum.go +*.pb.go + +# Temp dirs & trash +/__old__ /temp /tmp -/gen -*.env .DS_Store -*.cache -*.out -/cover*.* -/webhook-tester diff --git a/.golangci.yml b/.golangci.yml index 887b68a6..42753d36 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -18,9 +18,7 @@ linters-settings: gofmt: simplify: false rewrite-rules: - - {pattern: 'interface{}', replacement: 'any'} - gosec: - excludes: [G115] # TODO: Remove this exclusion after fixing the issue + - { pattern: 'interface{}', replacement: 'any' } govet: enable: - shadow @@ -52,9 +50,6 @@ linters-settings: # Make an issue if func has more lines of code than this setting, and it has naked returns. # Default: 30 max-func-lines: 100 - mnd: - ignored-functions: - - Grow linters: # All available linters list: disable-all: true @@ -107,16 +102,15 @@ linters: # All available linters list: -FROM node:22-alpine as frontend +# -✂- this stage is used to develop and build the application locally ------------------------------------------------- +FROM docker.io/library/node:22-bookworm AS develop -RUN mkdir -p /src/web +# install Go using the official image +COPY --from=docker.io/library/golang:1.23-bookworm /usr/local/go /usr/local/go -COPY ./web/package*.json /src/web/ +ENV \ + # add Go and Node.js "binaries" to the PATH + PATH="$PATH:/src/web/node_modules/.bin:/go/bin:/usr/local/go/bin" \ + # use the /var/tmp/go as the GOPATH to reuse the modules cache + GOPATH="/var/tmp/go" \ + # set path to the Go cache (think about this as a "object files cache") + GOCACHE="/var/tmp/go/cache" \ + # disable npm update notifier + NPM_CONFIG_UPDATE_NOTIFIER=false -WORKDIR /src/web - -# install node dependencies +# install development tools and dependencies RUN set -x \ - && npm config set update-notifier false \ - && npm ci --no-audit --prefer-offline + # renovate: source=github-releases name=oapi-codegen/oapi-codegen + && OAPI_CODEGEN_VERSION="2.4.1" \ + && GOBIN=/bin go install "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v${OAPI_CODEGEN_VERSION}" \ + && go clean -cache -modcache \ + # renovate: source=github-releases name=golangci/golangci-lint + && GOLANGCI_LINT_VERSION="1.61.0" \ + && wget -O- -nv "https://cdn.jsdelivr.net/gh/golangci/golangci-lint@v${GOLANGCI_LINT_VERSION}/install.sh" \ + | sh -s -- -b /bin "v${GOLANGCI_LINT_VERSION}" \ + # customize the shell prompt (for the bash) + && echo "PS1='\[\033[1;36m\][develop] \[\033[1;34m\]\w\[\033[0;35m\] \[\033[1;36m\]# \[\033[0m\]'" >> /etc/bash.bashrc + +WORKDIR /src -COPY ./api /src/api +RUN \ + --mount=type=bind,source=web/package.json,target=/src/web/package.json \ + --mount=type=bind,source=web/package-lock.json,target=/src/web/package-lock.json \ + --mount=type=bind,source=go.mod,target=/src/go.mod \ + --mount=type=bind,source=go.sum,target=/src/go.sum \ + set -x \ + # install node dependencies + && npm --prefix /src/web ci -dd --no-audit --prefer-offline \ + # burn the Go modules cache + && go mod download -x \ + # allow anyone to read/write the Go cache + && find /var/tmp/go -type d -exec chmod 0777 {} + \ + && find /var/tmp/go -type f -exec chmod 0666 {} + + +# -✂- this stage is used to build the application frontend ------------------------------------------------------------ +FROM develop AS frontend + +# copy the frontend source code COPY ./web /src/web # build the frontend (built artifact can be found in /src/web/dist) -RUN set -x \ - && npm run generate \ - && npm run build +RUN --mount=type=bind,source=api/openapi.yml,target=/src/api/openapi.yml \ + set -x \ + && npm --prefix /src/web run generate \ + && npm --prefix /src/web run build -# Image page: -FROM golang:1.23-alpine as builder +# -✂- this stage is used to compile the application ------------------------------------------------------------------- +FROM develop AS compile -# can be passed with any prefix (like `v1.2.3@GITHASH`) -# e.g.: `docker build --build-arg "APP_VERSION=v1.2.3@GITHASH" .` +# can be passed with any prefix (like `v1.2.3@FOO`), e.g.: `docker build --build-arg "APP_VERSION=v1.2.3@FOO" .` ARG APP_VERSION="undefined@docker" -# renovate: source=github-releases name=deepmap/oapi-codegen -ENV OAPI_CODEGEN_VERSION="2.2.0" - -RUN set -x \ - # Install `oapi-codegen`: - && GOBIN=/bin go install "github.com/deepmap/oapi-codegen/v2/cmd/oapi-codegen@v${OAPI_CODEGEN_VERSION}" - -# This argument allows to install additional software for local development using docker and avoid it \ -# in the production build -ARG DEV_MODE="false" - -RUN set -x \ - && if [ "${DEV_MODE}" = "true" ]; then \ - # The following dependencies are needed for `go test` to work - apk add --no-cache gcc musl-dev \ - # The following tool is used to format the imports in the source code - && GOBIN=/bin go install golang.org/x/tools/cmd/goimports@latest \ - ;fi - +# copy the source code COPY . /src -WORKDIR /src - -COPY --from=frontend /src/web/dist /src/web/dist - -# arguments to pass on each go tool link invocation -ENV LDFLAGS="-s -w -X gh.tarampamp.am/webhook-tester/internal/version.version=$APP_VERSION" - -RUN set -x \ - && go generate ./... \ - && CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" -o /tmp/webhook-tester ./cmd/webhook-tester/ \ - && /tmp/webhook-tester --version \ - && /tmp/webhook-tester -h - -# prepare rootfs for runtime -RUN mkdir -p /tmp/rootfs - -WORKDIR /tmp/rootfs - -RUN set -x \ - && mkdir -p \ - ./etc \ - ./bin \ +RUN --mount=type=bind,from=frontend,source=/src/web/dist,target=/src/web/dist \ + set -x \ + # build the app itself + && go generate -skip readme ./... \ + && CGO_ENABLED=0 go build \ + -trimpath \ + -ldflags "-s -w -X gh.tarampamp.am/webhook-tester/v2/internal/version.version=${APP_VERSION}" \ + -o ./app \ + ./cmd/webhook-tester/ \ + && ./app --version \ + # prepare rootfs for runtime + && mkdir -p /tmp/rootfs \ + && cd /tmp/rootfs \ + && mkdir -p ./etc/ssl/certs ./bin ./tmp \ && echo 'appuser:x:10001:10001::/nonexistent:/sbin/nologin' > ./etc/passwd \ && echo 'appuser:x:10001:' > ./etc/group \ - && mv /tmp/webhook-tester ./bin/webhook-tester + && chmod 777 ./tmp \ + && cp /etc/ssl/certs/ca-certificates.crt ./etc/ssl/certs/ \ + && mv /src/app ./bin/app -# use empty filesystem -FROM scratch as runtime +# -✂- and this is the final stage ------------------------------------------------------------------------------------- +FROM scratch AS runtime ARG APP_VERSION="undefined@docker" @@ -91,19 +101,20 @@ LABEL \ org.opencontainers.version="$APP_VERSION" \ org.opencontainers.image.licenses="MIT" -# Import from builder -COPY --from=builder /tmp/rootfs / +# import compiled application +COPY --from=compile /tmp/rootfs / -# Use an unprivileged user +# use an unprivileged user USER 10001:10001 -ENV LISTEN_PORT=8080 - -# Docs: -HEALTHCHECK --interval=15s --timeout=3s --start-period=1s CMD [ \ - "/bin/webhook-tester", "--log-json", "healthcheck" \ -] +ENV \ + # logging format + LOG_FORMAT=json \ + # logging level + LOG_LEVEL=info -ENTRYPOINT ["/bin/webhook-tester"] +#EXPOSE "80/tcp" "443/tcp" -CMD ["--log-json", "serve"] +HEALTHCHECK --interval=10s --start-interval=1s --start-period=5s --timeout=1s CMD ["/bin/app", "start", "healthcheck"] +ENTRYPOINT ["/bin/app"] +CMD ["start"] diff --git a/Makefile b/Makefile index 5f028cd7..7b48125d 100644 --- a/Makefile +++ b/Makefile @@ -1,83 +1,38 @@ #!/usr/bin/make -# Makefile readme (en): - -SHELL = /bin/sh -LDFLAGS = "-s -w -X github.com/tarampampam/webhook-tester/internal/version.version=$(shell git rev-parse HEAD)" DC_RUN_ARGS = --rm --user "$(shell id -u):$(shell id -g)" -APP_NAME = $(notdir $(CURDIR)) .DEFAULT_GOAL : help - -# This will output the help for each task. thanks to https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html help: ## Show this help @printf "\033[33m%s:\033[0m\n" 'Available commands' @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[32m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) -image: ## Build docker image with the app - docker build -f ./Dockerfile -t $(APP_NAME):local . - docker images $(APP_NAME):local # print the info - @printf "\n \e[30;42m %s \033[0m\n\n" 'Now you can use image like `docker run --rm $(APP_NAME):local ...`' - -fake-web-dist: # Is needed for the backend (embedding) - test -d ./web/dist || ( mkdir ./web/dist && touch ./web/dist/index.html ) - -# Frontend stuff - -node-install: ## Install node dependencies - docker compose run $(DC_RUN_ARGS) node sh -c 'test -d ./node_modules || npm ci --no-audit --prefer-offline' - -node-generate: node-install ## Generate frontend assets - docker compose run $(DC_RUN_ARGS) node npm run generate - -node-build: node-generate ## Build the frontend - -rm -R ./web/dist - docker compose run $(DC_RUN_ARGS) node npm run build - -node-lint: node-generate ## Lint the frontend - docker compose run $(DC_RUN_ARGS) node npm run lint +shell: ## Start shell + docker compose run $(DC_RUN_ARGS) app bash -node-shell: ## Start shell inside node environment - docker compose run $(DC_RUN_ARGS) node sh +web-shell: ## Start shell in web directory + docker compose run $(DC_RUN_ARGS) -w '/src/web' app bash -# Backend stuff +generate: ## Run code generation + docker compose run $(DC_RUN_ARGS) app go generate -skip readme ./... + docker compose run $(DC_RUN_ARGS) app npm --prefix ./web run generate + docker compose run $(DC_RUN_ARGS) app go generate -run readme ./... -go-generate: ## Generate backend assets - docker compose run $(DC_RUN_ARGS) --no-deps go go generate ./... +node-build: ## Build the frontend + docker compose run $(DC_RUN_ARGS) app npm --prefix ./web run build -go-build: node-build go-generate ## Build app binary file - docker compose run $(DC_RUN_ARGS) -e "CGO_ENABLED=0" --no-deps go go build -trimpath -ldflags $(LDFLAGS) ./cmd/webhook-tester/ +node-fmt: ## Format frontend code + docker compose run $(DC_RUN_ARGS) app npm --prefix ./web run fmt -go-test: fake-web-dist go-generate ## Run backend tests - docker compose run $(DC_RUN_ARGS) --no-deps go go test -v -race -timeout 10s ./... +lint: ## Run linters + docker compose run $(DC_RUN_ARGS) app golangci-lint run -go-lint: fake-web-dist go-generate ## Link the backend - docker compose run --rm golint golangci-lint run +up: ## Start the application in watch mode + #docker compose build + docker compose up --detach --build --remove-orphans + docker compose kill app-http --remove-orphans 2>/dev/null || true + #docker compose up --detach --wait whoami httpbin + docker compose up app-http -go-fmt: fake-web-dist ## Run source code formatting tools - docker compose run $(DC_RUN_ARGS) --no-deps go gofmt -s -w -d . - docker compose run $(DC_RUN_ARGS) --no-deps go goimports -d -w . - docker compose run $(DC_RUN_ARGS) --no-deps go go mod tidy - -go-shell: ## Start shell inside go environment - docker compose run $(DC_RUN_ARGS) go sh - -# Overall stuff - -e2e-test: ## Run integration (E2E) tests - docker compose run --rm hurl - -test: go-lint go-test node-lint ## Run all tests - -up: node-generate go-generate ## Start the app in the development mode - docker compose up --detach node-watch web - -down: ## Stop the app +down: ## Stop the application docker compose down --remove-orphans - -restart: down up ## Restart all containers - -clean: ## Make clean - docker compose down -v -t 1 - -docker rmi $(APP_NAME):local -f - -rm -R ./webhook-tester ./web/node_modules ./web/dist diff --git a/README.md b/README.md index 93c3d6ed..6d2cc490 100644 --- a/README.md +++ b/README.md @@ -1,218 +1,159 @@

- Logo + + + + + +

# WebHook Tester -[![Release version][badge_release_version]][link_releases] -![Project language][badge_language] -[![Build Status][badge_build]][link_build] -[![Release Status][badge_release]][link_build] -[![Image size][badge_size_latest]][link_docker_hub] -[![License][badge_license]][link_license] - -This application allows you to test and debug Webhooks and HTTP requests using unique (random) URLs. You can customize the response code, `content-type` HTTP header, response content and set some delay for the HTTP responses. The main idea is viewed [here](https://github.com/fredsted/webhook.site). +This application allows you to test and debug webhooks and HTTP requests using unique, randomly generated URLs. You +can customize the response code, `Content-Type` HTTP header, response content, and even set a delay for responses. +The concept is inspired by [this project](https://github.com/fredsted/webhook.site).

- screencast + screencast

-This application is written in GoLang and works very fast. It comes with a tiny UI (written in `Vue.js`), which is built in the binary file, so you don't need any additional assets for the application using. Websockets are also used for incoming webhook notifications in the UI - you don't need any 3rd party solutions (like `pusher.com`) for this! +Built with Go for high performance, this application includes a lightweight UI (written in `ReactJS`) that’s compiled +into the binary, so no additional assets are required. WebSocket support provides real-time webhook notifications in +the UI—no need for third-party solutions like `pusher.com`! ### 🔥 Features list -- Liveness/readiness probes (routes `/live` and `/ready` respectively) -- Can be started without any 3rd party dependencies -- Metrics in prometheus format (route `/metrics`) -- Built-in tiny and fast UI, based on `vue.js` -- Multi-arch docker image, based on `scratch` -- Unprivileged user in docker image is used -- Well-tested and documented source code -- Built-in CLI health check sub-command -- Recorded request binary view using UI -- JSON/human-readable logging formats +- Standalone operation with in-memory storage/pubsub - no third-party dependencies needed +- Fully customizable response code, headers, and body for webhooks +- Fast, built-in UI based on `ReactJS` +- Multi-architecture Docker image based on `scratch` +- Runs as an unprivileged user in Docker +- Well-tested, documented source code +- CLI health check sub-command included +- Binary view of recorded requests in UI +- Supports JSON and human-readable logging formats +- Liveness probes (`/healthz` endpoint) - Customizable webhook responses -- Built-in Websockets support -- Low memory/cpu usage -- Free and open-source -- Ready to scale - -## 📷 Screenshots - -| Dashboard | Request details | Help screen | Session options | -|:---------------------:|:------------------------:|:---------------------:|:----------------------------:| -| [![dash][scr1]][scr1] | [![request][scr2]][scr2] | [![help][scr3]][scr3] | [![new-session][scr4]][scr4] | - -[scr1]:https://user-images.githubusercontent.com/7326800/201884152-7df553d8-c2aa-4e8e-9657-602ba07c1d9a.png -[scr2]:https://user-images.githubusercontent.com/7326800/201884148-af541ccc-83d7-41ae-b639-9f4d9f2d7ed3.png -[scr3]:https://user-images.githubusercontent.com/7326800/201884143-80c5dcaf-4540-460e-92f5-b5e640614b1e.png -[scr4]:https://user-images.githubusercontent.com/7326800/201884129-0ebece4b-dd1e-455c-aacc-8dc4a42fef7d.png +- Built-in WebSocket support +- Efficient in memory and CPU usage +- Free, open-source, and scalable ### 🗃 Storage -At the moment 2 types of data storage are supported - **memory** and **redis server** (flag `--storage-driver`). +The app supports two storage options: **memory** and **Redis** (configured with the `--storage-driver` flag). -The **memory** driver is useful for fast local debugging when recorded requests will not be needed after the app stops. The **Redis driver**, on the contrary, stores all the data on the redis server, and the data will not be lost after the app restarts. When running multiple app instances (behind the load balancer), it is also necessary to use the redis driver. +- **Memory** driver: Ideal for local debugging when persistent storage isn’t needed, as recorded requests are cleared + upon app shutdown +- **Redis** driver: Retains data across app restarts, suitable for environments where data persistence is required. + Redis is also necessary when running multiple instances behind a load balancer -### 📢 Pub/sub +### 📢 Pub/Sub -Publishing/subscribing are used to send notifications using WebSockets, and it also supports 2 types of driver - **memory** and **redis server** (flag `--pubsub-driver`). +For WebSocket notifications, two drivers are supported for the pub/sub system: **memory** and **Redis** (configured +with the `--pubsub-driver` flag). -For multiple app instances redis driver must be used. +When running multiple instances of the app, the Redis driver is required. ## 🧩 Installation -Download the latest binary file for your arch (to run on macOS use the `linux/arm64` platform) from the [releases page][link_releases]. For example, let's install it on **amd64** arch (e.g.: Debian, Ubuntu, etc): +Download the latest binary for your architecture from the [releases page][link_releases]. For example, to install +on an **amd64** system (e.g., Debian, Ubuntu): + +[link_releases]:https://github.com/tarampampam/webhook-tester/releases ```shell $ curl -SsL -o ./webhook-tester https://github.com/tarampampam/webhook-tester/releases/latest/download/webhook-tester-linux-amd64 $ chmod +x ./webhook-tester - -# optionally, install the binary file globally: -$ sudo install -g root -o root -t /usr/local/bin -v ./webhook-tester -$ rm ./webhook-tester -$ webhook-tester --help +$ ./webhook-tester start ``` -Additionally, you can use the docker image: +Alternatively, you can use the Docker image: | Registry | Image | |----------------------------------------|--------------------------------------| | [GitHub Container Registry][link_ghcr] | `ghcr.io/tarampampam/webhook-tester` | -| [Docker Hub][link_docker_hub] | `tarampampam/webhook-tester` | +| [Docker Hub][link_docker_hub] (mirror) | `tarampampam/webhook-tester` | -> Using the `latest` tag for the docker image is highly discouraged because of possible backward-incompatible changes during **major** upgrades. Please, use tags in `X.Y.Z` format +> [!NOTE] +> It’s recommended to avoid using the `latest` tag, as **major** upgrades may include breaking changes. +> Instead, use specific tags in `X.Y.Z` format for version consistency. ## ⚙ Usage -This application supports the following sub-commands: - -| Sub-command | Description | -|---------------|--------------------------------------------------------------------| -| `serve` | Start HTTP server | -| `healthcheck` | Health checker for the HTTP server (use case - docker healthcheck) | - -And global flags: - -| Flag | Description | -|-------------------|-----------------------------| -| `--version`, `-v` | Display application version | -| `--verbose` | Verbose output | -| `--debug` | Debug output | -| `--log-json` | Logs in JSON format | - -### 🖥 HTTP server starting - -`serve` sub-command allows to use next flags: - -| Flag | Description | Default value | Environment variable | -|---------------------------|-----------------------------------------------------------------------------------------------------|----------------------------|-------------------------| -| `--listen`, `-l` | IP address to listen on | `0.0.0.0` (all interfaces) | `LISTEN_ADDR` | -| `--port`, `-p` | TCP port number | `8080` | `LISTEN_PORT` or `PORT` | -| `--create-session` | Create a session on server startup with this UUID (example: `00000000-0000-0000-0000-000000000000`) | | `CREATE_SESSION` | -| `--storage-driver` | Storage engine (`memory` or `redis`) | `memory` | `STORAGE_DRIVER` | -| `--pubsub-driver` | Pub/Sub engine (`memory` or `redis`) | `memory` | `PUBSUB_DRIVER` | -| `--redis-dsn` | Redis server DSN (required if storage or pub/sub driver is `redis`) | `redis://127.0.0.1:6379/0` | `REDIS_DSN` | -| `--ignore-header-prefix` | Ignore incoming webhook header prefix (case insensitive; example: `X-Forwarded-`) | `[]` | | -| `--max-request-body-size` | Maximal webhook request body size (in bytes; `0` = unlimited) | `65536` | | -| `--max-requests` | Maximum stored requests per session (max `65535`) | `128` | `MAX_REQUESTS` | -| `--session-ttl` | Session lifetime (examples: `48h`, `1h30m`) | `168h` | `SESSION_TTL` | -| `--ws-max-clients` | Maximal websocket clients (`0` = unlimited) | `0` | `WS_MAX_CLIENTS` | -| `--ws-max-lifetime` | Maximal single websocket lifetime (examples: `3h`, `1h30m`; `0` = unlimited) | `0` | `WS_MAX_LIFETIME` | +> [!NOTE] +> TODO: Add usage examples -> Redis DSN format: `redis://:@:/` - -Server starting command example: - -```shell -$ ./webhook-tester --log-json serve \ - --port 8080 \ - --storage-driver redis \ - --pubsub-driver redis \ - --redis-dsn redis://redis-host:6379/0 \ - --max-requests 512 \ - --ignore-header-prefix X-Forwarded- \ - --ignore-header-prefix X-Reverse-Proxy- \ - --create-session 00000000-0000-0000-0000-000000000000 \ - --ws-max-clients 30000 \ - --ws-max-lifetime 6h -``` +[link_ghcr]:https://github.com/users/tarampampam/packages/container/package/webhook-tester +[link_docker_hub]:https://hub.docker.com/r/tarampampam/webhook-tester/ -After that you can navigate your browser to `http://127.0.0.1:8080/` try to send your first HTTP request for the webhook-tester! + + +## CLI interface -### 🐋 Using docker +webhook tester. -Just execute in your terminal: +Usage: -```shell -$ docker run --rm -p 8080:8080/tcp tarampampam/webhook-tester serve +```bash +$ app [GLOBAL FLAGS] [COMMAND] [COMMAND FLAGS] [ARGUMENTS...] ``` -#### Docker-compose +Global flags: -For running this app using docker-compose and if you want to keep the data after restarts, you can use the following example with a Redis server as a backend for the data: +| Name | Description | Default value | Environment variables | +|--------------------|---------------------------------------------|:-------------:|:---------------------:| +| `--log-level="…"` | Logging level (debug/info/warn/error/fatal) | `info` | `LOG_LEVEL` | +| `--log-format="…"` | Logging format (console/json) | `console` | `LOG_FORMAT` | -```yaml -version: '3.8' +### `start` command (aliases: `s`, `server`, `serve`, `http-server`) -volumes: - redis-data: {} +Start HTTP/HTTPs servers. -services: - webhook-tester: - image: tarampampam/webhook-tester - command: --log-json serve --port 8080 --storage-driver redis --pubsub-driver redis --redis-dsn redis://redis:6379/0 - ports: ['8080:8080/tcp'] # Open - depends_on: - redis: {condition: service_healthy} +Usage: - redis: - image: redis:7-alpine - volumes: [redis-data:/data:rw] - ports: ['6379/tcp'] - healthcheck: - test: ['CMD', 'redis-cli', 'ping'] - interval: 1s +```bash +$ app [GLOBAL FLAGS] start [COMMAND FLAGS] [ARGUMENTS...] ``` -Or you can use in-memory data storage only: - -```yaml -version: '3.8' - -services: - webhook-tester: - image: tarampampam/webhook-tester - command: serve --port 8080 --create-session 00000000-0000-0000-0000-000000000000 - ports: ['8080:8080/tcp'] # Open +The following flags are supported: + +| Name | Description | Default value | Environment variables | +|-------------------------------|---------------------------------------------------------------------------------------------------------------------------|:--------------------------:|:----------------------------:| +| `--addr="…"` | IP (v4 or v6) address to listen on (0.0.0.0 to bind to all interfaces) | `0.0.0.0` | `SERVER_ADDR`, `LISTEN_ADDR` | +| `--http-port="…"` | HTTP server port | `8080` | `HTTP_PORT` | +| `--read-timeout="…"` | maximum duration for reading the entire request, including the body (zero = no timeout) | `1m0s` | `HTTP_READ_TIMEOUT` | +| `--write-timeout="…"` | maximum duration before timing out writes of the response (zero = no timeout) | `1m0s` | `HTTP_WRITE_TIMEOUT` | +| `--idle-timeout="…"` | maximum amount of time to wait for the next request (keep-alive, zero = no timeout) | `1m0s` | `HTTP_IDLE_TIMEOUT` | +| `--storage-driver="…"` | storage driver (memory/redis) | `memory` | `STORAGE_DRIVER` | +| `--session-ttl="…"` | session TTL (time-to-live, lifetime) | `168h0m0s` | `SESSION_TTL` | +| `--max-requests="…"` | maximal number of requests to store in the storage (zero means unlimited) | `128` | `MAX_REQUESTS` | +| `--max-request-body-size="…"` | maximal webhook request body size (in bytes), zero means unlimited | `0` | `MAX_REQUEST_BODY_SIZE` | +| `--auto-create-sessions` | automatically create sessions for incoming requests | `false` | `AUTO_CREATE_SESSIONS` | +| `--pubsub-driver="…"` | pub/sub driver (memory/redis) | `memory` | `PUBSUB_DRIVER` | +| `--redis-dsn="…"` | redis-like (redis, keydb) server DSN (e.g. redis://user:pwd@127.0.0.1:6379/0 or unix://user:pwd@/path/to/redis.sock?db=0) | `redis://127.0.0.1:6379/0` | `REDIS_DSN` | +| `--shutdown-timeout="…"` | maximum duration for graceful shutdown | `15s` | `SHUTDOWN_TIMEOUT` | +| `--use-live-frontend` | use frontend from the local directory instead of the embedded one (useful for development) | `false` | *none* | + +### `start healthcheck` subcommand (aliases: `hc`, `health`, `check`) + +Health checker for the HTTP(S) servers. Use case - docker healthcheck. + +Usage: + +```bash +$ app [GLOBAL FLAGS] start healthcheck [COMMAND FLAGS] [ARGUMENTS...] ``` -## Support +The following flags are supported: -[![Issues][badge_issues]][link_issues] -[![Issues][badge_pulls]][link_pulls] +| Name | Description | Default value | Environment variables | +|-------------------|------------------|:-------------:|:---------------------:| +| `--http-port="…"` | HTTP server port | `8080` | `HTTP_PORT` | -If you find any package errors, please, [make an issue][link_create_issue] in current repository. + ## License This is open-sourced software licensed under the [MIT License][link_license]. -[badge_build]:https://img.shields.io/github/actions/workflow/status/tarampampam/webhook-tester/tests.yml?branch=master&maxAge=30&label=tests&logo=github -[badge_release]:https://img.shields.io/github/actions/workflow/status/tarampampam/webhook-tester/release.yml?maxAge=30&label=release&logo=github -[badge_release_version]:https://img.shields.io/github/release/tarampampam/webhook-tester.svg?maxAge=30 -[badge_size_latest]:https://img.shields.io/docker/image-size/tarampampam/webhook-tester/latest?maxAge=30 -[badge_language]:https://img.shields.io/github/go-mod/go-version/tarampampam/webhook-tester?longCache=true -[badge_license]:https://img.shields.io/github/license/tarampampam/webhook-tester.svg?longCache=true -[badge_issues]:https://img.shields.io/github/issues/tarampampam/webhook-tester.svg?maxAge=45 -[badge_pulls]:https://img.shields.io/github/issues-pr/tarampampam/webhook-tester.svg?maxAge=45 - -[link_build]:https://github.com/tarampampam/webhook-tester/actions -[link_docker_hub]:https://hub.docker.com/r/tarampampam/webhook-tester/ -[link_docker_tags]:https://hub.docker.com/r/tarampampam/webhook-tester/tags [link_license]:https://github.com/tarampampam/webhook-tester/blob/master/LICENSE -[link_releases]:https://github.com/tarampampam/webhook-tester/releases -[link_issues]:https://github.com/tarampampam/webhook-tester/issues -[link_create_issue]:https://github.com/tarampampam/webhook-tester/issues/new/choose -[link_pulls]:https://github.com/tarampampam/webhook-tester/pulls -[link_ghcr]:https://github.com/users/tarampampam/packages/container/package/webhook-tester diff --git a/api/openapi.yml b/api/openapi.yml index f9cb2fef..9a39ddcc 100644 --- a/api/openapi.yml +++ b/api/openapi.yml @@ -1,466 +1,417 @@ -# Online editor: -openapi: 3.0.1 # Docs: +# yaml-language-server: $schema=https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/schemas/v3.0/schema.json +openapi: 3.0.3 info: title: WebHook Tester - description: A simple web app to test webhooks - version: 0.1.0 + description: The powerful tool to test webhooks and not only + version: 0.2.0 contact: {name: tarampampam, url: 'https://github.com/tarampampam'} +servers: + - {url: '/', description: Current server} + tags: - name: api - - name: websocket - - name: health - - name: metrics - - name: webhook - -servers: - - url: / - description: Current server + - name: service paths: - /api/version: - get: - tags: [api] - summary: Get application version - description: Returns the application version - operationId: apiAppVersion - responses: - "200": {$ref: '#/components/responses/AppVersion'} - /api/settings: get: + summary: Get app settings tags: [api] - summary: Get application settings - description: Returns the application settings operationId: apiSettings responses: - "200": {$ref: '#/components/responses/AppSettings'} + '200': {$ref: '#/components/responses/SettingsResponse'} /api/session: post: + summary: Create a new session tags: [api] - summary: Create new session - description: Creates new session operationId: apiSessionCreate - requestBody: {$ref: '#/components/requestBodies/NewSession'} + requestBody: {$ref: '#/components/requestBodies/CreateSessionRequest'} responses: - "200": {$ref: '#/components/responses/SessionOptions'} - "400": {$ref: '#/components/responses/BadRequest'} - "5XX": {$ref: '#/components/responses/ServerError'} + '200': {$ref: '#/components/responses/SessionOptionsResponse'} + '400': {$ref: '#/components/responses/ErrorResponse'} # Bad request + '5XX': {$ref: '#/components/responses/ErrorResponse'} # Server error /api/session/{session_uuid}: + get: + summary: Get session options by UUID + tags: [api] + operationId: apiSessionGet + parameters: [{$ref: '#/components/parameters/SessionUUIDInPath'}] + responses: + '200': {$ref: '#/components/responses/SessionOptionsResponse'} + '404': {$ref: '#/components/responses/ErrorResponse'} # Not found + '5XX': {$ref: '#/components/responses/ErrorResponse'} # Server error + delete: + summary: Delete a session by UUID tags: [api] - summary: Delete session with passed UUID - description: Deletes session with passed UUID operationId: apiSessionDelete - parameters: [{$ref: '#/components/parameters/SessionUUID'}] + parameters: [{$ref: '#/components/parameters/SessionUUIDInPath'}] responses: - "200": {$ref: '#/components/responses/SuccessfulOperation'} - "404": {$ref: '#/components/responses/NotFound'} - "5XX": {$ref: '#/components/responses/ServerError'} + '200': {$ref: '#/components/responses/SuccessfulOperationResponse'} + '404': {$ref: '#/components/responses/ErrorResponse'} # Not found + '5XX': {$ref: '#/components/responses/ErrorResponse'} # Server error /api/session/{session_uuid}/requests: get: + summary: Get the list of requests for a session by UUID tags: [api] - summary: Get requests list for session with passed UUID (sorted from newest to oldest) - description: Returns requests list for session with passed UUID - operationId: apiSessionGetAllRequests - parameters: [{$ref: '#/components/parameters/SessionUUID'}] + operationId: apiSessionListRequests + parameters: [{$ref: '#/components/parameters/SessionUUIDInPath'}] responses: - "200": {$ref: '#/components/responses/RequestsList'} - "404": {$ref: '#/components/responses/NotFound'} - "5XX": {$ref: '#/components/responses/ServerError'} + '200': {$ref: '#/components/responses/CapturedRequestsListResponse'} + '404': {$ref: '#/components/responses/ErrorResponse'} # Not found + '5XX': {$ref: '#/components/responses/ErrorResponse'} # Server error delete: + summary: Delete all requests for a session by UUID tags: [api] - summary: Delete all requests for session with passed UUID - description: Deletes all requests for session with passed UUID operationId: apiSessionDeleteAllRequests - parameters: [{$ref: '#/components/parameters/SessionUUID'}] + parameters: [{$ref: '#/components/parameters/SessionUUIDInPath'}] responses: - "200": {$ref: '#/components/responses/SuccessfulOperation'} - "404": {$ref: '#/components/responses/NotFound'} - "5XX": {$ref: '#/components/responses/ServerError'} + '200': {$ref: '#/components/responses/SuccessfulOperationResponse'} + '404': {$ref: '#/components/responses/ErrorResponse'} # Not found + '5XX': {$ref: '#/components/responses/ErrorResponse'} # Server error + + /api/session/{session_uuid}/requests/subscribe: + get: + summary: Subscribe to new requests for a session by UUID using WebSocket + tags: [api] + operationId: apiSessionRequestsSubscribe + parameters: + - {$ref: '#/components/parameters/SessionUUIDInPath'} + - {$ref: '#/components/parameters/WebSocketRequestConnectionInHeader'} + - {$ref: '#/components/parameters/WebSocketRequestUpgradeInHeader'} + - {$ref: '#/components/parameters/WebSocketRequestSecKeyInHeader'} + - {$ref: '#/components/parameters/WebSocketRequestSecVersionInHeader'} + responses: + '101': + description: Switching Protocols + headers: + Connection: {$ref: '#/components/headers/WebSocketResponseConnection'} + Upgrade: {$ref: '#/components/headers/WebSocketResponseUpgrade'} + Sec-Websocket-Accept: {$ref: '#/components/headers/WebSocketResponseSecWebsocketAccept'} + '200': + description: WebSocket connection established + content: + application/json: + schema: {$ref: '#/components/schemas/CapturedRequestShort'} + '400': {$ref: '#/components/responses/ErrorResponse'} # Bad request + '5XX': {$ref: '#/components/responses/ErrorResponse'} # Server error /api/session/{session_uuid}/requests/{request_uuid}: get: + summary: Get captured request details by UUID for a session by UUID tags: [api] - summary: Get request details by UUID for session with passed UUID - description: Returns request details by UUID for session with passed UUID operationId: apiSessionGetRequest - parameters: [{$ref: '#/components/parameters/SessionUUID'}, {$ref: '#/components/parameters/RequestUUID'}] + parameters: + - {$ref: '#/components/parameters/SessionUUIDInPath'} + - {$ref: '#/components/parameters/RequestUUIDInPath'} responses: - "200": {$ref: '#/components/responses/Request'} - "404": {$ref: '#/components/responses/NotFound'} - "5XX": {$ref: '#/components/responses/ServerError'} + '200': {$ref: '#/components/responses/CapturedRequestsResponse'} + '404': {$ref: '#/components/responses/ErrorResponse'} # Not found + '5XX': {$ref: '#/components/responses/ErrorResponse'} # Server error delete: + summary: Delete a request by UUID for a session by UUID tags: [api] - summary: Delete request by UUID for session with passed UUID - description: Deletes request by UUID for session with passed UUID operationId: apiSessionDeleteRequest parameters: - - {$ref: '#/components/parameters/SessionUUID'} - - {$ref: '#/components/parameters/RequestUUID'} + - {$ref: '#/components/parameters/SessionUUIDInPath'} + - {$ref: '#/components/parameters/RequestUUIDInPath'} responses: - "200": {$ref: '#/components/responses/SuccessfulOperation'} - "404": {$ref: '#/components/responses/NotFound'} - "5XX": {$ref: '#/components/responses/ServerError'} + '200': {$ref: '#/components/responses/SuccessfulOperationResponse'} + '404': {$ref: '#/components/responses/ErrorResponse'} # Not found + '5XX': {$ref: '#/components/responses/ErrorResponse'} # Server error - /ws/session/{session_uuid}: + /api/version: get: - tags: [websocket] - summary: Websocket endpoint - description: Websocket endpoint - operationId: websocketSession - parameters: [{$ref: '#/components/parameters/SessionUUID'}] + summary: Get app version + tags: [api] + operationId: apiAppVersion + responses: + '200': {$ref: '#/components/responses/VersionResponse'} + + /api/version/latest: + get: + summary: Get the latest app version + tags: [api] + operationId: apiAppVersionLatest responses: - "101": {description: Switching protocols} - "200": {$ref: '#/components/responses/WebsocketSessionEvents'} - "429": {$ref: '#/components/responses/TooManyConnections'} - "404": {$ref: '#/components/responses/NotFound'} - "5XX": {$ref: '#/components/responses/ServerError'} + '200': {$ref: '#/components/responses/VersionResponse'} + '5XX': {$ref: '#/components/responses/ErrorResponse'} # Server error /ready: get: - tags: [health] - summary: Readiness probe - description: Is the app ready to serve traffic? + summary: Readiness probe (checks if the app is ready to serve traffic) + tags: [service] operationId: readinessProbe responses: - "200": {$ref: '#/components/responses/ServiceHealthy'} - "503": {$ref: '#/components/responses/ServiceUnhealthy'} + '200': {$ref: '#/components/responses/ServiceHealthy'} + '503': {$ref: '#/components/responses/ServiceUnhealthy'} head: - tags: [health] summary: Readiness probe (HEAD) - description: Is an alias for the GET method, but without content in the response body + description: Alias for the GET method, but without response body content + tags: [service] operationId: readinessProbeHead responses: - "200": {$ref: '#/components/responses/ServiceHealthy'} - "503": {$ref: '#/components/responses/ServiceUnhealthy'} + '200': {$ref: '#/components/responses/ServiceHealthy'} + '503': {$ref: '#/components/responses/ServiceUnhealthy'} - /live: + /healthz: get: - tags: [health] - summary: Liveness probe - description: Is the app alive or dead? + summary: Liveness probe (checks if the app is running or down) + tags: [service] operationId: livenessProbe responses: - "200": {$ref: '#/components/responses/ServiceHealthy'} - "503": {$ref: '#/components/responses/ServiceUnhealthy'} + '200': {$ref: '#/components/responses/ServiceHealthy'} + '503': {$ref: '#/components/responses/ServiceUnhealthy'} head: - tags: [health] summary: Liveness probe (HEAD) - description: Is an alias for the GET method, but without content in the response body + description: Alias for the GET method, but without response body content + tags: [service] operationId: livenessProbeHead responses: - "200": {$ref: '#/components/responses/ServiceHealthy'} - "503": {$ref: '#/components/responses/ServiceUnhealthy'} - - /metrics: - get: - tags: [metrics] - summary: Application metrics - description: In Prometheus format - operationId: appMetrics - externalDocs: {url: 'https://prometheus.io/', description: Prometheus} - responses: - "200": {$ref: '#/components/responses/Metrics'} - -x-uuid-schema: &uuid-schema - type: string - format: uuid - pattern: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' - example: 9b6bbab9-c197-4dd3-bc3f-3cb6253820c7 + '200': {$ref: '#/components/responses/ServiceHealthy'} + '503': {$ref: '#/components/responses/ServiceUnhealthy'} components: - parameters: - SessionUUID: - name: session_uuid - in: path - description: Session UUID # version 4 - required: true - schema: *uuid-schema - - RequestUUID: - name: request_uuid - in: path - description: Request UUID # version 4 - required: true - schema: *uuid-schema - - RequiredStatusCode: - name: status_code - in: path - description: Required HTTP response code - required: true - schema: {type: integer, minimum: 100, maximum: 599, example: 200} - - requestBodies: - NewSession: - description: New session options - content: - application/json: - schema: - type: object - additionalProperties: false - properties: - status_code: {$ref: '#/components/schemas/StatusCode'} - content_type: {$ref: '#/components/schemas/ContentType'} - response_delay: {$ref: '#/components/schemas/ResponseDelayInSeconds'} - response_content_base64: {$ref: '#/components/schemas/Base64Encoded'} - - schemas: - TrueOnly: - type: boolean - enum: [true] - example: true - - FalseOnly: - type: boolean - enum: [false] - example: false - + schemas: # ------------------------------------------------ SCHEMAS ------------------------------------------------- StatusCode: - type: integer description: HTTP status code + type: integer example: 301 minimum: 100 maximum: 530 - ContentType: - type: string - example: 'application/json' - maxLength: 32 - - ResponseDelayInSeconds: - type: integer - description: In seconds - maximum: 30 - example: 5 - Base64Encoded: + description: Base64-encoded content type: string - description: Base64 encoded content maxLength: 10240 example: aGVsbG8gd29ybGQ= - UnixTime: + UnixMilliTime: + description: Unix timestamp in milliseconds (the number of milliseconds elapsed since January 1, 1970 UTC) type: integer - description: Unix timestamp example: 1667845578 minimum: 1600000000 + x-go-type: int64 - UUID: *uuid-schema + UUID: + type: string + format: uuid + pattern: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' + example: 9b6bbab9-c197-4dd3-bc3f-3cb6253820c7 HttpMethod: + description: HTTP method (GET, POST, PUT, DELETE, etc.) type: string - enum: [GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE] + minLength: 1 example: GET HttpHeader: type: object - description: HTTP header - additionalProperties: false properties: - name: - type: string - example: X-Header-Name - minLength: 1 - value: - type: string - example: X-Header-Value + name: {type: string, minLength: 1, maxLength: 40, example: User-Agent} + value: {type: string, minLength: 0, maxLength: 2048, example: curl/7.68.0} required: [name, value] - - WebsocketPayload: - type: object - description: Websocket response payload additionalProperties: false - properties: - name: - type: string - example: request-deleted - enum: [request-registered, request-deleted, requests-deleted] - data: - type: string - example: 9b6bbab9-c197-4dd3-bc3f-3cb6253820c7 - minLength: 1 - required: [name, data] - AppSettingsLimits: + SessionResponseOptions: + description: Session response options type: object - description: Application limit settings - additionalProperties: false properties: - max_requests: {type: integer, maximum: 65535, minimum: 0, example: 128} - max_webhook_body_size: - type: integer - description: Maximal webhook request body size (in bytes), zero means unlimited - maximum: 4294967295 - minimum: 0 - example: 1024 - session_lifetime_sec: {type: integer, maximum: 4294967295, example: 5} - required: [max_requests, max_webhook_body_size, session_lifetime_sec] + status_code: {$ref: '#/components/schemas/StatusCode'} + headers: {type: array, items: {$ref: '#/components/schemas/HttpHeader'}} + delay: {type: integer, description: Delay in seconds, maximum: 30, example: 5, x-go-type: uint16} + response_body_base64: {$ref: '#/components/schemas/Base64Encoded'} + required: [status_code, headers, delay, response_body_base64] + additionalProperties: false - SessionResponseOptions: + AppSettings: + description: Configuration settings of the app type: object - description: Session response options - additionalProperties: false properties: - content_base64: {$ref: '#/components/schemas/Base64Encoded'} - content_type: {$ref: '#/components/schemas/ContentType'} - code: {$ref: '#/components/schemas/StatusCode'} - delay_sec: {$ref: '#/components/schemas/ResponseDelayInSeconds'} - required: [content_base64, content_type, code, delay_sec] + limits: + type: object + description: App limit settings + properties: + max_requests: {type: integer, x-go-type: uint16, example: 128} + max_request_body_size: {type: integer, x-go-type: uint32, example: 1024, description: In bytes} + session_ttl: {type: integer, x-go-type: uint32, example: 5, description: In seconds} + required: [max_requests, max_request_body_size, session_ttl] + additionalProperties: false + required: [limits] + additionalProperties: false - SessionRequest: + CapturedRequest: type: object description: Recorded request - additionalProperties: false properties: uuid: {$ref: '#/components/schemas/UUID'} - client_address: - type: string - format: IPv4 - example: '214.184.32.7' + client_address: {type: string, format: IPv4, example: '214.184.32.7'} method: {$ref: '#/components/schemas/HttpMethod'} - content_base64: {$ref: '#/components/schemas/Base64Encoded'} # request content - headers: - type: array - items: {$ref: '#/components/schemas/HttpHeader'} + request_payload_base64: {$ref: '#/components/schemas/Base64Encoded'} + headers: {type: array, items: {$ref: '#/components/schemas/HttpHeader'}} url: + description: The URL's hostname, schema, and port may differ from those on the frontend due to proxying type: string example: 'https://example.com/path?query=string' - created_at_unix: {$ref: '#/components/schemas/UnixTime'} - required: [uuid, client_address, method, content_base64, headers, url, created_at_unix] + captured_at_unix_milli: {$ref: '#/components/schemas/UnixMilliTime'} + required: [uuid, client_address, method, request_payload_base64, headers, url, captured_at_unix_milli] + additionalProperties: false - Failure: - description: Request processing error (server-side error, bad request and so on) + CapturedRequestShort: type: object + description: The same as CapturedRequest, but without the request payload properties: - success: {$ref: '#/components/schemas/FalseOnly'} - code: {type: integer, example: 400, minimum: 400, maximum: 599} - message: {type: string, example: Internal error} - required: [success, code, message] - - responses: - AppVersion: - description: Application version information + uuid: {$ref: '#/components/schemas/UUID'} + client_address: {type: string, format: IPv4, example: '214.184.32.7'} + method: {$ref: '#/components/schemas/HttpMethod'} + headers: {type: array, items: {$ref: '#/components/schemas/HttpHeader'}} + url: {type: string, example: 'https://example.com/path?query=string'} + captured_at_unix_milli: {$ref: '#/components/schemas/UnixMilliTime'} + required: [uuid, client_address, method, headers, url, captured_at_unix_milli] + additionalProperties: false + + headers: # ------------------------------------------------ HEADERS ------------------------------------------------- + WebSocketResponseConnection: + description: WebSocket connection header + schema: {type: string, example: Upgrade, externalDocs: {url: 'https://mzl.la/3WWJi8w'}} + + WebSocketResponseUpgrade: + description: WebSocket upgrade header + schema: {type: string, example: websocket, externalDocs: {url: 'https://mzl.la/46XxkyZ'}} + + WebSocketResponseSecWebsocketAccept: + description: WebSocket Sec-WebSocket-Accept header + schema: {type: string, example: 'nESCeAuSsDkp9fVKF/BQ9Nfev+U=', externalDocs: {url: 'https://mzl.la/4duaxwC'}} + + parameters: # --------------------------------------------- PARAMETERS ---------------------------------------------- + SessionUUIDInPath: + description: Session UUID (version 4) + name: session_uuid + in: path + required: true + schema: + type: string + format: uuid + pattern: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' + example: 9b6bbab9-c197-4dd3-bc3f-3cb6253820c7 + + RequestUUIDInPath: + description: Request UUID (version 4) + name: request_uuid + in: path + required: true + schema: + type: string + format: uuid + pattern: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' + example: d74a7998-dcbc-4d77-82ba-27945e56a25d + + WebSocketRequestConnectionInHeader: + name: Connection + in: header + required: true + schema: {type: string, example: Upgrade, externalDocs: {url: 'https://mzl.la/3WWJi8w'}} + + WebSocketRequestUpgradeInHeader: + name: Upgrade + in: header + required: true + schema: {type: string, example: websocket, externalDocs: {url: 'https://mzl.la/46XxkyZ'}} + + WebSocketRequestSecKeyInHeader: + name: Sec-WebSocket-Key + in: header + required: true + schema: {type: string, example: 'K/TxmSsnVc71pFVjGIYy3w=='} + + WebSocketRequestSecVersionInHeader: + name: Sec-WebSocket-Version + in: header + required: true + schema: {type: string, example: '13'} + + requestBodies: # --------------------------------------------- REQUESTS --------------------------------------------- + CreateSessionRequest: + description: Options for creating a new session + content: + application/json: + schema: {$ref: '#/components/schemas/SessionResponseOptions'} + + responses: # ---------------------------------------------- RESPONSES ----------------------------------------------- + VersionResponse: + description: Information about the version content: application/json: schema: type: object - additionalProperties: false properties: {version: {type: string, example: '0.0.1'}} required: [version] + additionalProperties: false - AppSettings: - description: Application settings + SettingsResponse: + description: Configuration settings of the app content: application/json: - schema: - type: object - additionalProperties: false - properties: - limits: {$ref: '#/components/schemas/AppSettingsLimits'} - required: [limits] + schema: {$ref: '#/components/schemas/AppSettings'} - SessionOptions: - description: Created session options + SessionOptionsResponse: + description: Options of the session content: application/json: schema: type: object - additionalProperties: false properties: uuid: {$ref: '#/components/schemas/UUID'} - created_at_unix: {$ref: '#/components/schemas/UnixTime'} response: {$ref: '#/components/schemas/SessionResponseOptions'} - required: [uuid, response, created_at_unix] - - Request: - description: Recorded request details - content: - application/json: - schema: - $ref: '#/components/schemas/SessionRequest' + created_at_unix_milli: {$ref: '#/components/schemas/UnixMilliTime'} + required: [uuid, response, created_at_unix_milli] + additionalProperties: false - RequestsList: - description: Requests list + CapturedRequestsListResponse: + description: List of captured requests, sorted from newest to oldest content: application/json: - schema: - type: array - items: {$ref: '#/components/schemas/SessionRequest'} + schema: {type: array, items: {$ref: '#/components/schemas/CapturedRequest'}} - WebsocketSessionEvents: - description: Websocket session events stream + CapturedRequestsResponse: + description: Captured request content: application/json: - schema: - $ref: '#/components/schemas/WebsocketPayload' + schema: {$ref: '#/components/schemas/CapturedRequest'} - SuccessfulOperation: - description: Successful operation + SuccessfulOperationResponse: + description: Operation completed successfully content: application/json: schema: type: object - additionalProperties: false - properties: - success: {$ref: '#/components/schemas/TrueOnly'} + properties: {success: {type: boolean, example: true}} required: [success] - - NotFound: - description: Not found - content: - application/json: - schema: - $ref: '#/components/schemas/Failure' - - TooManyConnections: - description: Too many connections - content: - application/json: - schema: - $ref: '#/components/schemas/Failure' - - BadRequest: - description: Bad request - content: - application/json: - schema: - $ref: '#/components/schemas/Failure' - - ServerError: - description: Server error - content: - application/json: - schema: - $ref: '#/components/schemas/Failure' + additionalProperties: false ServiceHealthy: - description: Ok - content: - text/plain: - example: OK + description: Service is operational + content: {text/plain: {example: OK}} ServiceUnhealthy: - description: Service Unavailable - content: - text/plain: - example: | - application error: some important service is unavailable: host "10.0.0.10" unreachable + description: Service unavailable + content: {text/plain: {example: 'application error: some service is unavailable: host "10.0.0.10" unreachable'}} - Metrics: - description: App metrics + ErrorResponse: + description: Error response content: - text/plain: - example: | - # HELP go_goroutines Number of goroutines that currently exist. - # TYPE go_goroutines gauge - go_goroutines 92 + application/json: + schema: + type: object + properties: {error: {type: string, example: 'Internal server error'}} + required: [error] + additionalProperties: false diff --git a/cmd/webhook-tester/main.go b/cmd/webhook-tester/main.go index 2322e5fe..42bdc217 100644 --- a/cmd/webhook-tester/main.go +++ b/cmd/webhook-tester/main.go @@ -1,44 +1,32 @@ -// Main CLI application entrypoint. package main import ( + "context" + "fmt" "os" + "os/signal" + "runtime" + "syscall" - "github.com/fatih/color" - "github.com/joho/godotenv" - "github.com/pkg/errors" - - "gh.tarampamp.am/webhook-tester/internal/cli" + "gh.tarampamp.am/webhook-tester/v2/internal/cli" ) -// exitFn is a function for application exiting. -var exitFn = os.Exit //nolint:gochecknoglobals - // main CLI application entrypoint. func main() { - code, err := run() - if err != nil { - _, _ = color.New(color.FgHiRed, color.Bold).Fprintln(os.Stderr, err.Error()) - } + if err := run(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err.Error()) - exitFn(code) + os.Exit(1) + } } -// run this CLI application. -// Exit codes documentation: -func run() (int, error) { - const dotenvFileName = ".env" // dotenv (.env) file name +// run is the entry point of the program. The code is in separate function to allow executing deferred functions +// before exiting (os.Exit does not execute deferred functions). +func run() error { + defer runtime.Gosched() // increase the chance of running deferred functions before exiting - // load .env file (if file exists; useful for the local app development) - if stat, dotenvErr := os.Stat(dotenvFileName); dotenvErr == nil && !stat.IsDir() { - if err := godotenv.Load(dotenvFileName); err != nil { - return 1, errors.Wrap(err, dotenvFileName+" file error") - } - } - - if err := (cli.NewApp()).Run(os.Args); err != nil { - return 1, err - } + var ctx, cancel = signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() - return 0, nil + return cli.NewApp().Run(ctx, os.Args) } diff --git a/cmd/webhook-tester/main_test.go b/cmd/webhook-tester/main_test.go deleted file mode 100644 index 3b7532a6..00000000 --- a/cmd/webhook-tester/main_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package main - -import ( - "os" - "testing" - - "github.com/kami-zh/go-capturer" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_MainHelp(t *testing.T) { - os.Args = []string{"", "--help"} - exitFn = func(code int) { require.Equal(t, 0, code) } - - output := capturer.CaptureStdout(main) - - assert.Contains(t, output, "USAGE:") - assert.Contains(t, output, "COMMANDS:") - assert.Contains(t, output, "GLOBAL OPTIONS:") -} diff --git a/compose.yml b/compose.yml index 85afbb6c..809ccdde 100644 --- a/compose.yml +++ b/compose.yml @@ -1,98 +1,31 @@ # yaml-language-server: $schema=https://cdn.jsdelivr.net/gh/compose-spec/compose-spec@master/schema/compose-spec.json services: - go: &go - build: - target: builder - args: [DEV_MODE=true] - working_dir: /src + app: # common use case is to run shell or execute commands + build: &app-build {dockerfile: Dockerfile, target: develop} environment: - PS1: '\[\033[1;32m\]\[\033[1;36m\][\u@go] \[\033[1;34m\]\w\[\033[0;35m\] \[\033[1;36m\]# \[\033[0m\]' HOME: /tmp - GOPATH: /tmp - volumes: - - /etc/passwd:/etc/passwd:ro - - /etc/group:/etc/group:ro - - tmp-data:/tmp:rw - - .:/src:rw - depends_on: &go-deps - redis: {condition: service_healthy} + LOG_LEVEL: debug + volumes: [.:/src:rw, app-tmp-data:/tmp:rw, app-modules-cache:/var/tmp/go:rw] security_opt: [no-new-privileges:true] - node: &node - build: {target: frontend} - environment: - PS1: '\[\033[1;32m\]\[\033[1;36m\][\u@node] \[\033[1;34m\]\w\[\033[0;35m\] \[\033[1;36m\]# \[\033[0m\]' - HOME: /tmp - NPM_CONFIG_UPDATE_NOTIFIER: false - volumes: - - /etc/passwd:/etc/passwd:ro - - /etc/group:/etc/group:ro - - node-data:/tmp:rw - - .:/src:rw + app-web-dist: + build: *app-build + user: node + volumes: [.:/src:rw] working_dir: /src/web - security_opt: [no-new-privileges:true] - - web: - <<: *go - ports: ['8080:8080/tcp'] # Open - command: >- - go run -tags watch ./cmd/webhook-tester/ serve - --port=8080 --storage-driver=redis --pubsub-driver=redis - --redis-dsn=redis://redis:6379/0 --max-requests=12 --ignore-header-prefix=x-test-foo --ws-max-clients=20 - --ws-max-lifetime=1m - healthcheck: - test: ['CMD-SHELL', 'wget --spider -q http://127.0.0.1:8080/ready'] - start_period: 5s - interval: 5s - retries: 5 - depends_on: - <<: *go-deps - node-watch: {condition: service_healthy} - - node-watch: - <<: *node command: npm run watch - healthcheck: - test: ['CMD-SHELL', 'test -d ./dist'] - interval: 3s - start_period: 5s - retries: 20 - - redis: - image: redis:7-alpine - volumes: [redis-data:/data:rw] - ports: ['6379/tcp'] - healthcheck: - test: ['CMD', 'redis-cli', 'ping'] - interval: 500ms - timeout: 1s - security_opt: [no-new-privileges:true] - - golint: - image: golangci/golangci-lint:v1.61-alpine # Image page: - environment: - GOLANGCI_LINT_CACHE: /tmp/golint # - volumes: - - golint-go:/go:rw # go dependencies will be downloaded on each run without this - - golint-cache:/tmp/golint:rw - - .:/src:ro - working_dir: /src + healthcheck: {test: ['CMD', 'test', '-f', './dist/robots.txt'], start_interval: 1s, interval: 10s, start_period: 20s} security_opt: [no-new-privileges:true] - hurl: - image: ghcr.io/orange-opensource/hurl:5.0.1 - entrypoint: "" - command: sh -c "hurl --color --test --variable host=web --variable port=8080 ./test/hurl/*/*.hurl" - volumes: [.:/src:ro] - working_dir: /src - depends_on: - web: {condition: service_healthy} + app-http: + build: *app-build + command: go run ./cmd/webhook-tester/ start --use-live-frontend --auto-create-sessions --max-requests 8 + volumes: [.:/src:rw] + ports: ['8080:8080/tcp'] + depends_on: {app-web-dist: {condition: service_healthy}} security_opt: [no-new-privileges:true] volumes: - tmp-data: {} - redis-data: {} - golint-go: {} - golint-cache: {} - node-data: {} + app-modules-cache: {} + app-tmp-data: {} diff --git a/go.mod b/go.mod index 81b1f336..04759ece 100644 --- a/go.mod +++ b/go.mod @@ -1,57 +1,29 @@ -module gh.tarampamp.am/webhook-tester +module gh.tarampamp.am/webhook-tester/v2 go 1.23 require ( github.com/alicebob/miniredis/v2 v2.33.0 - github.com/fatih/color v1.17.0 - github.com/go-redis/redis/v8 v8.11.5 github.com/google/uuid v1.6.0 - github.com/gorilla/websocket v1.5.0 - github.com/joho/godotenv v1.5.1 - github.com/kami-zh/go-capturer v0.0.0-20171211120116-e492ea43421d - github.com/labstack/echo/v4 v4.12.0 + github.com/gorilla/websocket v1.5.3 github.com/oapi-codegen/runtime v1.1.1 - github.com/pkg/errors v0.9.1 - github.com/prometheus/client_golang v1.20.2 - github.com/prometheus/client_model v0.6.1 + github.com/redis/go-redis/v9 v9.6.2 github.com/stretchr/testify v1.9.0 - github.com/urfave/cli/v2 v2.27.4 - github.com/vmihailenco/msgpack/v5 v5.4.1 + github.com/urfave/cli-docs/v3 v3.0.0-alpha5 + github.com/urfave/cli/v3 v3.0.0-alpha9.1 go.uber.org/zap v1.27.0 ) require ( github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/golang-jwt/jwt v3.2.2+incompatible // indirect - github.com/klauspost/compress v1.17.9 // indirect - github.com/kylelemons/godebug v1.1.0 // indirect - github.com/labstack/gommon v0.4.2 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/common v0.55.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasttemplate v1.2.2 // indirect - github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect - go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.24.0 // indirect - golang.org/x/net v0.26.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect - golang.org/x/time v0.5.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect + go.uber.org/multierr v1.10.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3c55a7f6..db802555 100644 --- a/go.sum +++ b/go.sum @@ -5,78 +5,31 @@ github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUi github.com/alicebob/miniredis/v2 v2.33.0/go.mod h1:MhP4a3EU7aENRi9aO+tHfTBZicLqQevyi/DJpoj6mi0= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= -github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= -github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= -github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= -github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= -github.com/kami-zh/go-capturer v0.0.0-20171211120116-e492ea43421d h1:cVtBfNW5XTHiKQe7jDaDBSh/EVM4XLPutLAGboIXuM0= -github.com/kami-zh/go-capturer v0.0.0-20171211120116-e492ea43421d/go.mod h1:P2viExyCEfeWGU259JnaQ34Inuec4R38JCyBx2edgD0= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= -github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= -github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= -github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= -github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= -github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= -github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= -github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.20.2 h1:5ctymQzZlyOON1666svgwn3s6IKWgfbjsejTMiXIyjg= -github.com/prometheus/client_golang v1.20.2/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/redis/go-redis/v9 v9.6.2 h1:w0uvkRbc9KpgD98zcvo5IrVUsn0lXpRMuhNgiHDJzdk= +github.com/redis/go-redis/v9 v9.6.2/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= @@ -84,46 +37,19 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8= -github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ= -github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= -github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= -github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= -github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= -github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/urfave/cli-docs/v3 v3.0.0-alpha5 h1:H1oWnR2/GN0dNm2PVylws+GxSOD6YOwW/jI5l78YfPk= +github.com/urfave/cli-docs/v3 v3.0.0-alpha5/go.mod h1:AIqom6Q60U4tiqHp41i7+/AB2XHgi1WvQ7jOFlccmZ4= +github.com/urfave/cli/v3 v3.0.0-alpha9.1 h1:1fJU+bltkwN8lF4Sni/X0i1d8XwPIrS82ivZ8qsp/q4= +github.com/urfave/cli/v3 v3.0.0-alpha9.1/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/.gitignore b/internal/api/.gitignore deleted file mode 100644 index d08e4e7a..00000000 --- a/internal/api/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -## Generated code -/openapi.gen.go diff --git a/internal/api/generate.go b/internal/api/generate.go deleted file mode 100644 index c3bcf11b..00000000 --- a/internal/api/generate.go +++ /dev/null @@ -1,9 +0,0 @@ -package api - -import ( - _ "github.com/oapi-codegen/runtime" - _ "github.com/oapi-codegen/runtime/types" -) - -// Generate openapi stubs (`oapi-codegen` is required for this): -//go:generate oapi-codegen -generate server,types -o ./openapi.gen.go -package api ./../../api/openapi.yml diff --git a/internal/api/new_session.go b/internal/api/new_session.go deleted file mode 100644 index 2e7a5c1e..00000000 --- a/internal/api/new_session.go +++ /dev/null @@ -1,75 +0,0 @@ -package api - -import ( - "encoding/base64" - "fmt" - "net/http" - "unicode/utf8" - - "github.com/pkg/errors" -) - -func (data NewSession) Validate() error { - const ( - minStatusCode, maxStatusCode = StatusCode(100), StatusCode(530) - maxContentTypeLength = 32 - maxResponseContentLength = 10240 - maxResponseDelaySeconds = ResponseDelayInSeconds(30) // IMPORTANT! Must be less than http/writeTimeout value! - ) - - if data.StatusCode != nil && (*data.StatusCode < minStatusCode || *data.StatusCode > maxStatusCode) { - return fmt.Errorf("wrong status code (should be between %d and %d)", minStatusCode, maxStatusCode) - } - - if data.ContentType != nil && utf8.RuneCountInString(*data.ContentType) > maxContentTypeLength { - return fmt.Errorf("content-type value is too long (max length is %d)", maxContentTypeLength) - } - - if data.ResponseDelay != nil && *data.ResponseDelay > maxResponseDelaySeconds { - return fmt.Errorf("response delay is too much (max is %d)", maxResponseDelaySeconds) - } - - if data.ResponseContentBase64 != nil { - if v, err := base64.StdEncoding.DecodeString(*data.ResponseContentBase64); err != nil { - return errors.Wrap(err, "cannot decode response body (wrong base64)") - } else if utf8.RuneCount(v) > maxResponseContentLength { - return fmt.Errorf("response content is too large (max length is %d)", maxResponseContentLength) - } - } - - return nil -} - -func (data NewSession) GetStatusCode() uint16 { - if data.StatusCode != nil { - return uint16(*data.StatusCode) - } - - return http.StatusOK // default value -} - -func (data NewSession) GetContentType() string { - if data.ContentType != nil { - return *data.ContentType - } - - return "text/plain" // default value -} - -func (data NewSession) GetResponseDelay() int { - if data.ResponseDelay != nil { - return *data.ResponseDelay - } - - return 0 // default value -} - -func (data NewSession) ResponseContent() []byte { - if data.ResponseContentBase64 != nil { - if v, err := base64.StdEncoding.DecodeString(*data.ResponseContentBase64); err == nil { - return v - } - } - - return []byte{} // default value -} diff --git a/internal/api/new_session_test.go b/internal/api/new_session_test.go deleted file mode 100644 index bd441081..00000000 --- a/internal/api/new_session_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package api_test - -import ( - "encoding/base64" - "net/http" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/webhook-tester/internal/api" -) - -func TestNewSession_Validate(t *testing.T) { - assert.NoError(t, api.NewSession{}.Validate()) - - var ( - lowCode = 99 - highCode = 531 - longContentType = strings.Repeat("x", 33) - highDelay = 31 - wrongBase64 = "foobar" - longBase64 = base64.StdEncoding.EncodeToString([]byte(strings.Repeat("x", 10241))) - ) - - assert.True(t, len(longBase64) > 10240) - - for name, tt := range map[string]struct { - give api.NewSession - wantErrSubstr string - }{ - "too low status code": { - give: api.NewSession{StatusCode: &lowCode}, - wantErrSubstr: "wrong status code", - }, - "too high status code": { - give: api.NewSession{StatusCode: &highCode}, - wantErrSubstr: "wrong status code", - }, - "too long content-type": { - give: api.NewSession{ContentType: &longContentType}, - wantErrSubstr: "content-type value is too long", - }, - "too high delay": { - give: api.NewSession{ResponseDelay: &highDelay}, - wantErrSubstr: "response delay is too much", - }, - "wrong base64 body": { - give: api.NewSession{ResponseContentBase64: &wrongBase64}, - wantErrSubstr: "cannot decode response body", - }, - "base64 body too long": { - give: api.NewSession{ResponseContentBase64: &longBase64}, - wantErrSubstr: "response content is too large", - }, - } { - t.Run(name, func(t *testing.T) { - err := tt.give.Validate() - - if tt.wantErrSubstr != "" { - assert.Contains(t, err.Error(), tt.wantErrSubstr) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestNewSession_Getters(t *testing.T) { - // default values - assert.EqualValues(t, http.StatusOK, api.NewSession{}.GetStatusCode()) - assert.EqualValues(t, "text/plain", api.NewSession{}.GetContentType()) - assert.EqualValues(t, 0, api.NewSession{}.GetResponseDelay()) - assert.EqualValues(t, []byte{}, api.NewSession{}.ResponseContent()) - - var ( - code = 123 - cType = "foo" - delay = 10 - content = "Zm9v" // foo - ) - - var valid = api.NewSession{ - StatusCode: &code, - ContentType: &cType, - ResponseContentBase64: &content, - ResponseDelay: &delay, - } - - assert.EqualValues(t, code, valid.GetStatusCode()) - assert.EqualValues(t, cType, valid.GetContentType()) - assert.EqualValues(t, delay, valid.GetResponseDelay()) - assert.EqualValues(t, "foo", valid.ResponseContent()) -} diff --git a/internal/breaker/os_signal.go b/internal/breaker/os_signal.go deleted file mode 100644 index 464d9d79..00000000 --- a/internal/breaker/os_signal.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package breaker provides OSSignals struct for OS signals handling (with context). -package breaker - -import ( - "context" - "os" - "os/signal" - "syscall" -) - -// OSSignals allows to subscribe for system signals. -type OSSignals struct { - ctx context.Context - ch chan os.Signal -} - -// NewOSSignals creates new subscriber for system signals. -func NewOSSignals(ctx context.Context) OSSignals { - return OSSignals{ - ctx: ctx, - ch: make(chan os.Signal, 1), - } -} - -// Subscribe for some system signals (call Stop for stopping). -func (oss *OSSignals) Subscribe(onSignal func(os.Signal), signals ...os.Signal) { - if len(signals) == 0 { - signals = []os.Signal{os.Interrupt, syscall.SIGINT, syscall.SIGTERM} // default signals - } - - signal.Notify(oss.ch, signals...) - - go func(ch <-chan os.Signal) { - select { - case <-oss.ctx.Done(): - break - - case sig, opened := <-ch: - if oss.ctx.Err() != nil { - break - } - - if opened && sig != nil { - onSignal(sig) - } - } - }(oss.ch) -} - -// Stop system signals listening. -func (oss *OSSignals) Stop() { - signal.Stop(oss.ch) - close(oss.ch) -} diff --git a/internal/breaker/os_signal_test.go b/internal/breaker/os_signal_test.go deleted file mode 100644 index a8d0e6c5..00000000 --- a/internal/breaker/os_signal_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package breaker_test - -import ( - "context" - "os" - "syscall" - "testing" - "time" - - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/webhook-tester/internal/breaker" -) - -func TestNewOSSignals(t *testing.T) { - oss := breaker.NewOSSignals(context.Background()) - - gotSignal := make(chan os.Signal, 1) - - oss.Subscribe(func(signal os.Signal) { - gotSignal <- signal - }, syscall.SIGUSR2) - - defer oss.Stop() - - proc, err := os.FindProcess(os.Getpid()) - assert.NoError(t, err) - - assert.NoError(t, proc.Signal(syscall.SIGUSR2)) // send the signal - - time.Sleep(time.Millisecond * 5) - - assert.Equal(t, syscall.SIGUSR2, <-gotSignal) -} - -func TestNewOSSignalCtxCancel(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - - oss := breaker.NewOSSignals(ctx) - - gotSignal := make(chan os.Signal, 1) - - oss.Subscribe(func(signal os.Signal) { - gotSignal <- signal - }, syscall.SIGUSR2) - - defer oss.Stop() - - proc, err := os.FindProcess(os.Getpid()) - assert.NoError(t, err) - - cancel() - - assert.NoError(t, proc.Signal(syscall.SIGUSR2)) // send the signal - - assert.Empty(t, gotSignal) -} diff --git a/internal/checkers/health.go b/internal/checkers/health.go deleted file mode 100644 index f2aecbfb..00000000 --- a/internal/checkers/health.go +++ /dev/null @@ -1,56 +0,0 @@ -package checkers - -import ( - "context" - "fmt" - "net/http" - "time" -) - -type httpClient interface { - Do(*http.Request) (*http.Response, error) -} - -// HealthChecker is a heals checker. -type HealthChecker struct { - ctx context.Context - httpClient httpClient -} - -const defaultHTTPClientTimeout = time.Second * 3 - -// NewHealthChecker creates heals checker. -func NewHealthChecker(ctx context.Context, client ...httpClient) *HealthChecker { - var c httpClient - - if len(client) == 1 { - c = client[0] - } else { - c = &http.Client{Timeout: defaultHTTPClientTimeout} // default - } - - return &HealthChecker{ctx: ctx, httpClient: c} -} - -// Check application using liveness probe. -func (c *HealthChecker) Check(port uint16) error { - req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/live", port), nil) - if err != nil { - return err - } - - req.Header.Set("User-Agent", "HealthChecker/internal") - - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - - _ = resp.Body.Close() - - if code := resp.StatusCode; code != http.StatusOK { - return fmt.Errorf("wrong status code [%d] from live endpoint", code) - } - - return nil -} diff --git a/internal/checkers/live.go b/internal/checkers/live.go deleted file mode 100644 index 10c666c4..00000000 --- a/internal/checkers/live.go +++ /dev/null @@ -1,10 +0,0 @@ -package checkers - -// LiveChecker is a liveness checker. -type LiveChecker struct{} - -// NewLiveChecker creates liveness checker. -func NewLiveChecker() *LiveChecker { return &LiveChecker{} } - -// Check application is alive? -func (*LiveChecker) Check() error { return nil } diff --git a/internal/checkers/live_test.go b/internal/checkers/live_test.go deleted file mode 100644 index 736985a6..00000000 --- a/internal/checkers/live_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package checkers_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/webhook-tester/internal/checkers" -) - -func TestLiveChecker_Check(t *testing.T) { - assert.NoError(t, checkers.NewLiveChecker().Check()) -} diff --git a/internal/checkers/ready.go b/internal/checkers/ready.go deleted file mode 100644 index 21097e33..00000000 --- a/internal/checkers/ready.go +++ /dev/null @@ -1,29 +0,0 @@ -package checkers - -import ( - "context" - - "github.com/go-redis/redis/v8" -) - -// ReadyChecker is a readiness checker. -type ReadyChecker struct { - ctx context.Context - rdb *redis.Client // can be nil -} - -// NewReadyChecker creates readiness checker. -func NewReadyChecker(ctx context.Context, rdb *redis.Client) *ReadyChecker { - return &ReadyChecker{ctx: ctx, rdb: rdb} -} - -// Check application is ready for incoming requests processing? -func (c *ReadyChecker) Check() error { - if c.rdb != nil { - if err := c.rdb.Ping(c.ctx).Err(); err != nil { - return err - } - } - - return nil -} diff --git a/internal/checkers/ready_test.go b/internal/checkers/ready_test.go deleted file mode 100644 index 1b40e7e2..00000000 --- a/internal/checkers/ready_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package checkers_test - -import ( - "context" - "testing" - - "github.com/alicebob/miniredis/v2" - "github.com/go-redis/redis/v8" - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/webhook-tester/internal/checkers" -) - -func TestReadyChecker_CheckSuccessWithRedisClient(t *testing.T) { - // start mini-redis - mini, err := miniredis.Run() - assert.NoError(t, err) - - defer mini.Close() - - rdb := redis.NewClient(&redis.Options{Addr: mini.Addr()}) - defer rdb.Close() - - assert.NoError(t, checkers.NewReadyChecker(context.Background(), rdb).Check()) -} - -func TestReadyChecker_CheckFailedWithRedisClient(t *testing.T) { - // start mini-redis - mini, err := miniredis.Run() - assert.NoError(t, err) - - defer mini.Close() - - rdb := redis.NewClient(&redis.Options{Addr: mini.Addr()}) - defer rdb.Close() - - mini.SetError("foo err") - assert.Error(t, checkers.NewReadyChecker(context.Background(), rdb).Check()) - mini.SetError("") -} diff --git a/internal/cli/app.go b/internal/cli/app.go index ff067715..fe6ac033 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -4,67 +4,84 @@ import ( "context" "fmt" "runtime" + "strings" - "github.com/urfave/cli/v2" + "github.com/urfave/cli/v3" - "gh.tarampamp.am/webhook-tester/internal/checkers" - "gh.tarampamp.am/webhook-tester/internal/cli/healthcheck" - "gh.tarampamp.am/webhook-tester/internal/cli/serve" - "gh.tarampamp.am/webhook-tester/internal/logger" - "gh.tarampamp.am/webhook-tester/internal/version" + "gh.tarampamp.am/webhook-tester/v2/internal/cli/start" + "gh.tarampamp.am/webhook-tester/v2/internal/logger" + "gh.tarampamp.am/webhook-tester/v2/internal/version" ) +//go:generate go run app_readme.go + // NewApp creates new console application. -func NewApp() *cli.App { - const ( - verboseFlagName = "verbose" - debugFlagName = "debug" - logJSONFlagName = "log-json" - ) +func NewApp() *cli.Command { //nolint:funlen + var ( + logLevelFlag = cli.StringFlag{ + Name: "log-level", + Value: logger.InfoLevel.String(), + Usage: "Logging level (" + strings.Join(logger.LevelStrings(), "/") + ")", + Sources: cli.EnvVars("LOG_LEVEL"), + OnlyOnce: true, + Config: cli.StringConfig{TrimSpace: true}, + Validator: func(s string) error { + if _, err := logger.ParseLevel(s); err != nil { + return err + } + + return nil + }, + } - const loggingCategoryName = "Logging" + logFormatFlag = cli.StringFlag{ + Name: "log-format", + Value: logger.ConsoleFormat.String(), + Usage: "Logging format (" + strings.Join(logger.FormatStrings(), "/") + ")", + Sources: cli.EnvVars("LOG_FORMAT"), + OnlyOnce: true, + Config: cli.StringConfig{TrimSpace: true}, + Validator: func(s string) error { + if _, err := logger.ParseFormat(s); err != nil { + return err + } + + return nil + }, + } + ) // create "default" logger (will be overwritten later with customized) - log, err := logger.New(false, false, false) - if err != nil { - panic(err) - } + var log, _ = logger.New(logger.InfoLevel, logger.ConsoleFormat) // error will never occur + + const defaultHttpPort uint16 = 8080 - return &cli.App{ - Usage: "CLI client for images compressing using tinypng.com API", - Before: func(c *cli.Context) error { + return &cli.Command{ + Usage: "webhook tester", + Before: func(ctx context.Context, c *cli.Command) error { _ = log.Sync() // sync previous logger instance - customizedLog, e := logger.New(c.Bool(verboseFlagName), c.Bool(debugFlagName), c.Bool(logJSONFlagName)) - if e != nil { - return e + var ( + logLevel, _ = logger.ParseLevel(c.String(logLevelFlag.Name)) // error ignored because the flag validates itself + logFormat, _ = logger.ParseFormat(c.String(logFormatFlag.Name)) // --//-- + ) + + configured, err := logger.New(logLevel, logFormat) // create new logger instance + if err != nil { + return err } - *log = *customizedLog // override "default" logger with customized + *log = *configured // replace "default" logger with customized return nil }, Commands: []*cli.Command{ - healthcheck.NewCommand(checkers.NewHealthChecker(context.Background())), - serve.NewCommand(log), + start.NewCommand(log, defaultHttpPort), }, Version: fmt.Sprintf("%s (%s)", version.Version(), runtime.Version()), - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: verboseFlagName, - Category: loggingCategoryName, - Usage: "verbose output", - }, - &cli.BoolFlag{ - Name: debugFlagName, - Category: loggingCategoryName, - Usage: "debug output", - }, - &cli.BoolFlag{ - Name: logJSONFlagName, - Category: loggingCategoryName, - Usage: "logs in JSON format", - }, + Flags: []cli.Flag{ // global flags + &logLevelFlag, + &logFormatFlag, }, } } diff --git a/internal/cli/app_readme.go b/internal/cli/app_readme.go new file mode 100644 index 00000000..171748e3 --- /dev/null +++ b/internal/cli/app_readme.go @@ -0,0 +1,25 @@ +//go:build generate + +package main + +import ( + "os" + + cliDocs "github.com/urfave/cli-docs/v3" + + "gh.tarampamp.am/webhook-tester/v2/internal/cli" +) + +func main() { + const readmePath = "../../README.md" + + if stat, err := os.Stat(readmePath); err == nil && stat.Mode().IsRegular() { + if err = cliDocs.ToTabularToFileBetweenTags(cli.NewApp(), "app", readmePath); err != nil { + panic(err) + } else { + println("✔ cli docs updated successfully") + } + } else if err != nil { + println("⚠ readme file not found, cli docs not updated:", err.Error()) + } +} diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go deleted file mode 100644 index ae41ae2c..00000000 --- a/internal/cli/app_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package cli_test - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "gh.tarampamp.am/webhook-tester/internal/cli" -) - -func TestNewApp(t *testing.T) { - app := cli.NewApp() - - require.NotEmpty(t, app.Commands) -} diff --git a/internal/cli/healthcheck/command.go b/internal/cli/healthcheck/command.go deleted file mode 100644 index 135ced37..00000000 --- a/internal/cli/healthcheck/command.go +++ /dev/null @@ -1,36 +0,0 @@ -// Package healthcheck contains CLI `healthcheck` command implementation. -package healthcheck - -import ( - "errors" - "math" - - "github.com/urfave/cli/v2" - - "gh.tarampamp.am/webhook-tester/internal/cli/shared" -) - -type checker interface { - Check(port uint16) error -} - -// NewCommand creates `healthcheck` command. -func NewCommand(checker checker) *cli.Command { - return &cli.Command{ - Name: "healthcheck", - Aliases: []string{"chk", "health", "check"}, - Usage: "Health checker for the HTTP server. Use case - docker healthcheck", - Action: func(c *cli.Context) error { - var port = c.Uint(shared.PortNumberFlag.Name) - - if port > math.MaxUint16 { - return errors.New("wrong TCP port number") - } - - return checker.Check(uint16(port)) - }, - Flags: []cli.Flag{ - shared.PortNumberFlag, - }, - } -} diff --git a/internal/cli/healthcheck/command_test.go b/internal/cli/healthcheck/command_test.go deleted file mode 100644 index a6b34f0f..00000000 --- a/internal/cli/healthcheck/command_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package healthcheck_test - -import ( - "testing" -) - -func TestNewCommand(t *testing.T) { - t.Skip("Not implemented") -} diff --git a/internal/cli/serve/command.go b/internal/cli/serve/command.go deleted file mode 100644 index decfd287..00000000 --- a/internal/cli/serve/command.go +++ /dev/null @@ -1,382 +0,0 @@ -// Package serve contains CLI `serve` command implementation. -package serve - -import ( - "context" - "errors" - "fmt" - "math" - "net" - "net/http" - "os" - "time" - - "github.com/go-redis/redis/v8" - "github.com/google/uuid" - "github.com/urfave/cli/v2" - "go.uber.org/zap" - - "gh.tarampamp.am/webhook-tester/internal/breaker" - "gh.tarampamp.am/webhook-tester/internal/cli/shared" - "gh.tarampamp.am/webhook-tester/internal/config" - "gh.tarampamp.am/webhook-tester/internal/env" - appHttp "gh.tarampamp.am/webhook-tester/internal/http" - "gh.tarampamp.am/webhook-tester/internal/logger" - "gh.tarampamp.am/webhook-tester/internal/pubsub" - "gh.tarampamp.am/webhook-tester/internal/storage" -) - -// NewCommand creates `serve` command. -func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocyclo - const ( - listenFlagName = "listen" - maxRequestsFlagName = "max-requests" - sessionTtlFlagName = "session-ttl" - ignoreHeaderPrefixFlagName = "ignore-header-prefix" - maxRequestBodySizeFlagName = "max-request-body-size" - redisDsnFlagName = "redis-dsn" - storageDriverFlagName = "storage-driver" - pubSubDriverFlagName = "pubsub-driver" - wsMaxClientsFlagName = "ws-max-clients" - wsMaxLifetimeFlagName = "ws-max-lifetime" - createSessionFlagName = "create-session" - ) - - return &cli.Command{ - Name: "serve", - Aliases: []string{"s", "server"}, - Usage: "Start HTTP server", - Action: func(c *cli.Context) error { - var ( - port = c.Uint(shared.PortNumberFlag.Name) - listen = c.String(listenFlagName) - maxRequests = c.Uint(maxRequestsFlagName) - sessionTtl = c.Duration(sessionTtlFlagName) - ignoreHeaderPrefix = c.StringSlice(ignoreHeaderPrefixFlagName) - maxRequestBodySize = c.Uint(maxRequestBodySizeFlagName) - redisDsn = c.String(redisDsnFlagName) - storageDriver = c.String(storageDriverFlagName) - pubSubDriver = c.String(pubSubDriverFlagName) - wsMaxClients = c.Uint(wsMaxClientsFlagName) - wsMaxLifetime = c.Duration(wsMaxLifetimeFlagName) - createSession = c.String(createSessionFlagName) - ) - - { - if port > math.MaxUint16 { - return errors.New("wrong TCP port number") - } - - if net.ParseIP(listen) == nil { - return fmt.Errorf("wrong IP address [%s] for listening", listen) - } - - if maxRequests > math.MaxUint16 { - return errors.New("wrong max requests value") - } - - _, redisDsnErr := redis.ParseURL(redisDsn) - - switch storageDriver { - case config.StorageDriverMemory.String(): - // do nothing - - case config.StorageDriverRedis.String(): - if redisDsnErr != nil { - return fmt.Errorf("wrong redis DSN [%s]: %w", redisDsn, redisDsnErr) - } - - default: - return fmt.Errorf("unsupported storage driver: %s", storageDriver) - } - - switch pubSubDriver { - case config.PubSubDriverMemory.String(): - // do nothing - - case config.PubSubDriverRedis.String(): - if redisDsnErr != nil { - return fmt.Errorf("wrong redis DSN [%s]: %w", redisDsn, redisDsnErr) - } - - default: - return fmt.Errorf("unsupported pub/sub driver: %s", pubSubDriver) - } - - if createSession != "" { - if _, err := uuid.Parse(createSession); err != nil { - return fmt.Errorf("wrong session UUID: %s", createSession) - } - } - } - - var cfg = config.Config{} - - { - cfg.MaxRequests = uint16(maxRequests) - cfg.IgnoreHeaderPrefixes = ignoreHeaderPrefix - cfg.MaxRequestBodySize = uint32(maxRequestBodySize) - cfg.SessionTTL = sessionTtl - - switch storageDriver { - case config.StorageDriverMemory.String(): - cfg.StorageDriver = config.StorageDriverMemory - - case config.StorageDriverRedis.String(): - cfg.StorageDriver = config.StorageDriverRedis - } - - switch pubSubDriver { - case config.PubSubDriverMemory.String(): - cfg.PubSubDriver = config.PubSubDriverMemory - - case config.PubSubDriverRedis.String(): - cfg.PubSubDriver = config.PubSubDriverRedis - } - - cfg.WebSockets.MaxClients = uint32(wsMaxClients) - cfg.WebSockets.MaxLifetime = wsMaxLifetime - } - - return run(c.Context, log, cfg, listen, uint16(port), redisDsn, createSession) - }, - Flags: []cli.Flag{ - shared.PortNumberFlag, - &cli.StringFlag{ - Name: listenFlagName, - Aliases: []string{"l"}, - Usage: "IP address to listen on", - Value: "0.0.0.0", - EnvVars: []string{env.ListenAddr.String()}, - }, - &cli.UintFlag{ - Name: maxRequestsFlagName, - Usage: "maximum stored requests per session (max 65535)", - Value: 128, //nolint:mnd - EnvVars: []string{env.MaxSessionRequests.String()}, - }, - &cli.DurationFlag{ - Name: sessionTtlFlagName, - Usage: "session lifetime (examples: 48h, 1h30m)", - Value: time.Hour * 168, //nolint:mnd - EnvVars: []string{env.SessionTTL.String()}, - }, - &cli.StringSliceFlag{ - Name: ignoreHeaderPrefixFlagName, - Usage: "ignore headers with the following prefixes for webhooks, case insensitive (example: 'X-Forwarded-')", - // EnvVars: []string{}, // TODO add env var - }, - &cli.UintFlag{ - Name: maxRequestBodySizeFlagName, - Usage: "maximal webhook request body size (in bytes; 0 = unlimited)", - Value: 64 * 1024, //nolint:mnd // 64 KiB - // EnvVars: []string{}, // TODO add env var - }, - &cli.StringFlag{ - // redisDSN allows to setup redis server using single string. Examples: - // redis://:@:/ - // unix://:@?db= - Name: redisDsnFlagName, - Usage: "redis server DSN (format: \"redis://:@:/\")", - Value: "redis://127.0.0.1:6379/0", - EnvVars: []string{env.RedisDSN.String()}, - }, - &cli.StringFlag{ - Name: storageDriverFlagName, - Usage: fmt.Sprintf("storage driver (%s|%s)", config.StorageDriverMemory, config.StorageDriverRedis), - Value: config.StorageDriverMemory.String(), - EnvVars: []string{env.StorageDriverName.String()}, - }, - &cli.StringFlag{ - Name: pubSubDriverFlagName, - Usage: fmt.Sprintf("pub/sub driver (%s|%s)", config.PubSubDriverMemory, config.PubSubDriverRedis), - Value: config.PubSubDriverMemory.String(), - EnvVars: []string{env.PubSubDriver.String()}, - }, - &cli.UintFlag{ - Name: wsMaxClientsFlagName, - Usage: "maximal websocket clients count (0 = unlimited)", - Value: 0, - EnvVars: []string{env.WebsocketMaxClients.String()}, - }, - &cli.DurationFlag{ - Name: wsMaxLifetimeFlagName, - Usage: "maximal single websocket lifetime (examples: 3h, 1h30m; 0 = unlimited)", - Value: time.Duration(0), - EnvVars: []string{env.WebsocketMaxLifetime.String()}, - }, - &cli.StringFlag{ - Name: createSessionFlagName, - Usage: "crete a session on server startup with this UUID (for the persistent URL, example: 00000000-0000-0000-0000-000000000000)", //nolint:lll - EnvVars: []string{env.CreateSessionUUID.String()}, - }, - }, - } -} - -const serverShutdownTimeout = 5 * time.Second - -// run current command. -func run( //nolint:funlen,gocyclo - parentCtx context.Context, - log *zap.Logger, - cfg config.Config, - ip string, - port uint16, - redisDSN string, - createSessionUUID string, -) error { - var ( - ctx, cancel = context.WithCancel(parentCtx) // serve context creation - oss = breaker.NewOSSignals(ctx) // OS signals listener - ) - - // subscribe for system signals - oss.Subscribe(func(sig os.Signal) { - log.Warn("Stopping by OS signal..", zap.String("signal", sig.String())) - - cancel() - }) - - defer func() { - cancel() // call the cancellation function after all - oss.Stop() // stop system signals listening - }() - - var rdb *redis.Client // can be nil, that's ok - - // establish connection with the redis server, if this action is required (based on storage/pubsub drivers) - if cfg.StorageDriver == config.StorageDriverRedis || cfg.PubSubDriver == config.PubSubDriverRedis { - opt, optErr := redis.ParseURL(redisDSN) - if optErr != nil { - return optErr - } - - rdb = redis.NewClient(opt).WithContext(ctx) - redis.SetLogger(logger.NewRedisBridge(log)) // set zap logger for the redis client (globally) - - defer func() { _ = rdb.Close() }() - - if pingErr := rdb.Ping(ctx).Err(); pingErr != nil { - return pingErr - } - } - - var stor storage.Storage - - // create required storage driver - switch cfg.StorageDriver { - case config.StorageDriverRedis: - stor = storage.NewRedis(ctx, rdb, cfg.SessionTTL, cfg.MaxRequests) - - case config.StorageDriverMemory: - inmemory := storage.NewInMemory(cfg.SessionTTL, cfg.MaxRequests) - defer func() { _ = inmemory.Close() }() - - stor = inmemory - - default: - return errors.New("unsupported storage driver") // cannot be covered by tests - } - - if createSessionUUID != "" { // create a persistent session - if _, err := stor.CreateSession( // persistent session defaults - []byte{}, - http.StatusOK, - "text/plain; charset=utf-8", - time.Duration(0), - createSessionUUID, - ); err != nil { - log.Error("cannot create persistent session", zap.Error(err)) - } else { - log.Info("persistent session created", zap.String("uuid", createSessionUUID)) - } - } - - var ( - pub pubsub.Publisher - sub pubsub.Subscriber - ) - - // create required pub/sub driver - switch cfg.PubSubDriver { - case config.PubSubDriverRedis: - redisPubSub := pubsub.NewRedis(ctx, rdb) - defer func() { _ = redisPubSub.Close() }() - - pub, sub = redisPubSub, redisPubSub - - case config.PubSubDriverMemory: - memoryPubSub := pubsub.NewInMemory() - defer func() { _ = memoryPubSub.Close() }() - - pub, sub = memoryPubSub, memoryPubSub - - default: - return errors.New("unsupported pub/sub driver") // cannot be covered by tests - } - - // create HTTP server - server := appHttp.NewServer(log) - - // register server routes, middlewares, etc. - if err := server.Register(ctx, cfg, rdb, stor, pub, sub); err != nil { - return err - } - - startingErrCh := make(chan error, 1) // channel for server starting error - - // start HTTP server in separate goroutine - go func(errCh chan<- error) { - defer close(errCh) - - fields := []zap.Field{ - zap.String("addr", ip), - zap.Uint16("port", port), - zap.Uint16("max requests", cfg.MaxRequests), - zap.Duration("session ttl", cfg.SessionTTL), - zap.Strings("ignore prefixes", cfg.IgnoreHeaderPrefixes), - zap.String("storage driver", cfg.StorageDriver.String()), - zap.String("pub/sub driver", cfg.PubSubDriver.String()), - zap.Uint32("max websocket clients", cfg.WebSockets.MaxClients), - zap.Duration("single websocket ttl", cfg.WebSockets.MaxLifetime), - } - - if cfg.StorageDriver == config.StorageDriverRedis { - fields = append(fields, zap.String("redis dsn", redisDSN)) - } - - log.Info("Server starting", fields...) - - if err := server.Start(ip, port); err != nil && !errors.Is(err, http.ErrServerClosed) { - errCh <- err - } - }(startingErrCh) - - // and wait for.. - select { - case err := <-startingErrCh: // ..server starting error - return err - - case <-ctx.Done(): // ..or context cancellation - log.Debug("Server stopping") - - // create context for server graceful shutdown - ctxShutdown, ctxCancelShutdown := context.WithTimeout(context.Background(), serverShutdownTimeout) - defer ctxCancelShutdown() - - // stop the server using created context above - if err := server.Stop(ctxShutdown); err != nil { //nolint:contextcheck - return err - } - - // and close redis connection - if rdb != nil { - if err := rdb.Close(); err != nil { - return err - } - } - } - - return nil -} diff --git a/internal/cli/serve/command_test.go b/internal/cli/serve/command_test.go deleted file mode 100644 index 414040f1..00000000 --- a/internal/cli/serve/command_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package serve_test - -import ( - "testing" -) - -func TestNewCommand(t *testing.T) { - t.Skip("Not implemented") -} diff --git a/internal/cli/shared/flags.go b/internal/cli/shared/flags.go deleted file mode 100644 index 00000f86..00000000 --- a/internal/cli/shared/flags.go +++ /dev/null @@ -1,15 +0,0 @@ -package shared - -import ( - "github.com/urfave/cli/v2" - - "gh.tarampamp.am/webhook-tester/internal/env" -) - -var PortNumberFlag = &cli.UintFlag{ - Name: "port", - Aliases: []string{"p"}, - Usage: "Server TCP port number", - Value: 8080, - EnvVars: []string{env.ListenPort.String(), env.Port.String()}, -} diff --git a/internal/cli/start/command.go b/internal/cli/start/command.go new file mode 100644 index 00000000..0258266e --- /dev/null +++ b/internal/cli/start/command.go @@ -0,0 +1,434 @@ +package start + +import ( + "context" + "errors" + "fmt" + "math" + "net" + "strings" + "time" + + "github.com/redis/go-redis/v9" + "github.com/urfave/cli/v3" + "go.uber.org/zap" + + "gh.tarampamp.am/webhook-tester/v2/internal/cli/start/healthcheck" + "gh.tarampamp.am/webhook-tester/v2/internal/config" + "gh.tarampamp.am/webhook-tester/v2/internal/encoding" + appHttp "gh.tarampamp.am/webhook-tester/v2/internal/http" + "gh.tarampamp.am/webhook-tester/v2/internal/logger" + "gh.tarampamp.am/webhook-tester/v2/internal/pubsub" + "gh.tarampamp.am/webhook-tester/v2/internal/storage" + "gh.tarampamp.am/webhook-tester/v2/internal/version" +) + +type ( + command struct { + c *cli.Command + + options struct { + addr string // IP (v4 or v6) address to listen on + http struct { + tcpPort uint16 // TCP port number for HTTP server + } + timeouts struct { + httpRead, httpWrite, httpIdle time.Duration // timeouts for HTTP(s) servers + shutdown time.Duration // maximum amount of time to wait for the server to stop + } + storage struct { + driver string // storage driver + sessionTTL time.Duration // session TTL + maxRequests uint16 // maximal number of requests + } + pubSub struct { + driver string // Pub/Sub driver + } + redis struct { + dsn string // redis-like server DSN + } + frontend struct { + useLive bool // false to use embedded frontend, true to use live (local) + } + maxRequestPayloadSize uint32 + autoCreateSessions bool + } + } +) + +const ( + PubSubDriverMemory = "memory" + PubSubDriverRedis = "redis" + + StorageDriverMemory = "memory" + StorageDriverRedis = "redis" +) + +// NewCommand creates new `start` command. +func NewCommand(log *zap.Logger, defaultHttpPort uint16) *cli.Command { //nolint:funlen + var cmd command + + const httpCategory = "HTTP" + + var ( + httpAddrFlag = cli.StringFlag{ + Name: "addr", + Category: httpCategory, + Usage: "IP (v4 or v6) address to listen on (0.0.0.0 to bind to all interfaces)", + Value: "0.0.0.0", + Sources: cli.EnvVars("SERVER_ADDR", "LISTEN_ADDR"), + OnlyOnce: true, + Config: cli.StringConfig{TrimSpace: true}, + Validator: func(ip string) error { + if ip == "" { + return fmt.Errorf("missing IP address") + } + + if net.ParseIP(ip) == nil { + return fmt.Errorf("wrong IP address [%s] for listening", ip) + } + + return nil + }, + } + httpPortFlag = cli.UintFlag{ + Name: "http-port", + Category: httpCategory, + Usage: "HTTP server port", + Value: uint64(defaultHttpPort), + Sources: cli.EnvVars("HTTP_PORT"), + OnlyOnce: true, + Validator: func(port uint64) error { + if port == 0 || port > math.MaxUint16 { + return fmt.Errorf("wrong TCP port number [%d]", port) + } + + return nil + }, + } + httpReadTimeoutFlag = cli.DurationFlag{ + Name: "read-timeout", + Category: httpCategory, + Usage: "maximum duration for reading the entire request, including the body (zero = no timeout)", + Value: time.Second * 60, //nolint:mnd + Sources: cli.EnvVars("HTTP_READ_TIMEOUT"), + OnlyOnce: true, + Validator: validateDuration("read timeout", time.Millisecond, time.Hour), + } + httpWriteTimeoutFlag = cli.DurationFlag{ + Name: "write-timeout", + Category: httpCategory, + Usage: "maximum duration before timing out writes of the response (zero = no timeout)", + Value: time.Second * 60, //nolint:mnd + Sources: cli.EnvVars("HTTP_WRITE_TIMEOUT"), + OnlyOnce: true, + Validator: validateDuration("write timeout", time.Millisecond, time.Hour), + } + httpIdleTimeoutFlag = cli.DurationFlag{ + Name: "idle-timeout", + Category: httpCategory, + Usage: "maximum amount of time to wait for the next request (keep-alive, zero = no timeout)", + Value: time.Second * 60, //nolint:mnd + Sources: cli.EnvVars("HTTP_IDLE_TIMEOUT"), + OnlyOnce: true, + Validator: validateDuration("idle timeout", time.Millisecond, time.Hour), + } + storageDriverFlag = cli.StringFlag{ + Name: "storage-driver", + Value: StorageDriverMemory, + Usage: "storage driver (" + strings.Join([]string{StorageDriverMemory, StorageDriverRedis}, "/") + ")", + Sources: cli.EnvVars("STORAGE_DRIVER"), + OnlyOnce: true, + Config: cli.StringConfig{TrimSpace: true}, + Validator: func(s string) error { + switch s { + case StorageDriverMemory, StorageDriverRedis: + return nil + default: + return fmt.Errorf("wrong storage driver [%s]", s) + } + }, + } + storageSessionTTLFlag = cli.DurationFlag{ + Name: "session-ttl", + Usage: "session TTL (time-to-live, lifetime)", + Value: time.Hour * 24 * 7, //nolint:mnd + Sources: cli.EnvVars("SESSION_TTL"), + OnlyOnce: true, + Validator: validateDuration("session TTL", time.Minute, time.Hour*24*31), //nolint:mnd + } + storageMaxRequestsFlag = cli.UintFlag{ + Name: "max-requests", + Usage: "maximal number of requests to store in the storage (zero means unlimited)", + Value: 128, //nolint:mnd + Sources: cli.EnvVars("MAX_REQUESTS"), + OnlyOnce: true, + Validator: func(n uint64) error { + if n > math.MaxUint16 { + return fmt.Errorf("too big number of requests [%d]", n) + } + + return nil + }, + } + maxRequestPayloadSizeFlag = cli.UintFlag{ + Name: "max-request-body-size", + Usage: "maximal webhook request body size (in bytes), zero means unlimited", + Value: 0, + Sources: cli.EnvVars("MAX_REQUEST_BODY_SIZE"), + OnlyOnce: true, + Validator: func(n uint64) error { + if n > math.MaxUint32 { + return fmt.Errorf("too big request body size [%d]", n) + } + + return nil + }, + } + autoCreateSessionsFlag = cli.BoolFlag{ + Name: "auto-create-sessions", + Usage: "automatically create sessions for incoming requests", + Sources: cli.EnvVars("AUTO_CREATE_SESSIONS"), + OnlyOnce: true, + } + pubSubDriverFlag = cli.StringFlag{ + Name: "pubsub-driver", + Value: PubSubDriverMemory, + Usage: "pub/sub driver (" + strings.Join([]string{PubSubDriverMemory, PubSubDriverRedis}, "/") + ")", + Sources: cli.EnvVars("PUBSUB_DRIVER"), + OnlyOnce: true, + Config: cli.StringConfig{TrimSpace: true}, + Validator: func(s string) error { + switch s { + case PubSubDriverMemory, PubSubDriverRedis: + return nil + default: + return fmt.Errorf("wrong pub/sub driver [%s]", s) + } + }, + } + redisServerDsnFlag = cli.StringFlag{ + Name: "redis-dsn", + Usage: "redis-like (redis, keydb) server DSN (e.g. redis://user:pwd@127.0.0.1:6379/0 or " + + "unix://user:pwd@/path/to/redis.sock?db=0)", + Value: "redis://127.0.0.1:6379/0", + Sources: cli.EnvVars("REDIS_DSN"), + OnlyOnce: true, + Config: cli.StringConfig{TrimSpace: true}, + Validator: func(s string) (err error) { _, err = redis.ParseURL(s); return }, //nolint:nlreturn + } + shutdownTimeoutFlag = cli.DurationFlag{ + Name: "shutdown-timeout", + Usage: "maximum duration for graceful shutdown", + Value: time.Second * 15, //nolint:mnd + Sources: cli.EnvVars("SHUTDOWN_TIMEOUT"), + OnlyOnce: true, + Validator: validateDuration("shutdown timeout", time.Millisecond, time.Minute), + } + useLiveFrontendFlag = cli.BoolFlag{ + Name: "use-live-frontend", + Usage: "use frontend from the local directory instead of the embedded one (useful for development)", + OnlyOnce: true, + } + ) + + cmd.c = &cli.Command{ + Name: "start", + Usage: "Start HTTP/HTTPs servers", + Aliases: []string{"s", "server", "serve", "http-server"}, + Action: func(ctx context.Context, c *cli.Command) error { + var opt = &cmd.options + + // set options + opt.addr = c.String(httpAddrFlag.Name) + opt.http.tcpPort = uint16(c.Uint(httpPortFlag.Name)) //nolint:gosec + opt.timeouts.httpRead = c.Duration(httpReadTimeoutFlag.Name) + opt.timeouts.httpWrite = c.Duration(httpWriteTimeoutFlag.Name) + opt.timeouts.httpIdle = c.Duration(httpIdleTimeoutFlag.Name) + opt.storage.driver = c.String(storageDriverFlag.Name) + opt.storage.sessionTTL = c.Duration(storageSessionTTLFlag.Name) + opt.storage.maxRequests = uint16(c.Uint(storageMaxRequestsFlag.Name)) //nolint:gosec + opt.maxRequestPayloadSize = uint32(c.Uint(maxRequestPayloadSizeFlag.Name)) //nolint:gosec + opt.autoCreateSessions = c.Bool(autoCreateSessionsFlag.Name) + opt.pubSub.driver = c.String(pubSubDriverFlag.Name) + opt.redis.dsn = c.String(redisServerDsnFlag.Name) + opt.timeouts.shutdown = c.Duration(shutdownTimeoutFlag.Name) + opt.frontend.useLive = c.Bool(useLiveFrontendFlag.Name) + + return cmd.Run(ctx, log) + }, + Flags: []cli.Flag{ + &httpAddrFlag, + &httpPortFlag, + &httpReadTimeoutFlag, + &httpWriteTimeoutFlag, + &httpIdleTimeoutFlag, + &storageDriverFlag, + &storageSessionTTLFlag, + &storageMaxRequestsFlag, + &maxRequestPayloadSizeFlag, + &autoCreateSessionsFlag, + &pubSubDriverFlag, + &redisServerDsnFlag, + &shutdownTimeoutFlag, + &useLiveFrontendFlag, + }, + Commands: []*cli.Command{ + healthcheck.NewCommand(defaultHttpPort), + }, + } + + return cmd.c +} + +// validateDuration returns a validator for the given duration. +func validateDuration(name string, minValue, maxValue time.Duration) func(d time.Duration) error { + return func(d time.Duration) error { + switch { + case d < 0: + return fmt.Errorf("negative %s (%s)", name, d) + case d < minValue: + return fmt.Errorf("too small %s (%s)", name, d) + case d > maxValue: + return fmt.Errorf("too big %s (%s)", name, d) + } + + return nil + } +} + +// Run current command. +func (cmd *command) Run(parentCtx context.Context, log *zap.Logger) error { //nolint:funlen,gocyclo + ctx, cancel := context.WithCancel(parentCtx) + defer cancel() + + var rdc *redis.Client // may be nil + + // establish connection to Redis server if needed + if cmd.options.pubSub.driver == PubSubDriverRedis || cmd.options.storage.driver == StorageDriverRedis { + var opt, pErr = redis.ParseURL(cmd.options.redis.dsn) + if pErr != nil { + return fmt.Errorf("failed to parse Redis DSN: %w", pErr) + } + + rdc = redis.NewClient(opt) + redis.SetLogger(logger.NewRedisBridge(log.Named("redis"))) + + defer func() { _ = rdc.Close() }() + + if err := rdc.Ping(ctx).Err(); err != nil { + return fmt.Errorf("failed to ping Redis server: %w", err) + } + } + + var db storage.Storage + + // create the storage + switch cmd.options.storage.driver { + case StorageDriverMemory: + var inMemory = storage.NewInMemory(cmd.options.storage.sessionTTL, uint32(cmd.options.storage.maxRequests)) //nolint:contextcheck,lll + defer func() { _ = inMemory.Close() }() + db = inMemory //nolint:wsl + case StorageDriverRedis: + db = storage.NewRedis(rdc, cmd.options.storage.sessionTTL, uint32(cmd.options.storage.maxRequests)) + default: + return fmt.Errorf("unknown storage driver [%s]", cmd.options.storage.driver) + } + + var pubSub pubsub.PubSub[pubsub.CapturedRequest] + + // create the Pub/Sub + switch cmd.options.pubSub.driver { + case PubSubDriverMemory: + pubSub = pubsub.NewInMemory[pubsub.CapturedRequest]() + case PubSubDriverRedis: + pubSub = pubsub.NewRedis[pubsub.CapturedRequest](rdc, encoding.JSON{}) + default: + return fmt.Errorf("unknown Pub/Sub driver [%s]", cmd.options.pubSub.driver) + } + + var httpLog = log.Named("http") + + // create HTTP server + var server = appHttp.NewServer(ctx, httpLog, + appHttp.WithReadTimeout(cmd.options.timeouts.httpRead), + appHttp.WithWriteTimeout(cmd.options.timeouts.httpWrite), + appHttp.WithIDLETimeout(cmd.options.timeouts.httpIdle), + ).Register( + ctx, + httpLog, + cmd.readinessChecker(rdc), + cmd.latestAppVersionGetter(), + config.AppSettings{ + MaxRequests: cmd.options.storage.maxRequests, + MaxRequestBodySize: cmd.options.maxRequestPayloadSize, + SessionTTL: cmd.options.storage.sessionTTL, + AutoCreateSessions: cmd.options.autoCreateSessions, + }, + db, + pubSub, + cmd.options.frontend.useLive, + ) + + server.ShutdownTimeout = cmd.options.timeouts.shutdown // set shutdown timeout + + // open HTTP port + httpLn, httpLnErr := net.Listen("tcp", fmt.Sprintf("%s:%d", cmd.options.addr, cmd.options.http.tcpPort)) + if httpLnErr != nil { + return fmt.Errorf("HTTP port error (%s:%d): %w", cmd.options.addr, cmd.options.http.tcpPort, httpLnErr) + } + + // start HTTP server in separate goroutine + go func() { + defer func() { _ = httpLn.Close() }() + + log.Info("HTTP server starting", + zap.String("address", cmd.options.addr), + zap.Uint16("port", cmd.options.http.tcpPort), + zap.String("storage", cmd.options.storage.driver), + zap.String("pubsub", cmd.options.pubSub.driver), + zap.String("open", fmt.Sprintf("http://%s:%d", func() string { + if addr := cmd.options.addr; addr == "0.0.0.0" || addr == "::" || strings.HasPrefix(addr, "127.") { + return "127.0.0.1" + } + + return cmd.options.addr + }(), cmd.options.http.tcpPort)), + ) + + if err := server.StartHTTP(ctx, httpLn); err != nil { + cancel() // cancel the context on error (this is critical for us) + + log.Error("Failed to start HTTP server", zap.Error(err)) + } else { + log.Debug("HTTP server stopped") + } + }() + + // here, we are blocking until the context is canceled. this will occur when the user sends a signal to stop + // the app by pressing Ctrl+C, terminating the process, or if the HTTP/HTTPS server fails to start + <-ctx.Done() + + // if the context contains an error, and it's not a cancellation error, return it + if err := ctx.Err(); err != nil && !errors.Is(err, context.Canceled) { + return err + } + + return nil +} + +// readinessChecker returns a readiness checker. Feel free to add more checks/dependencies here if needed. +func (cmd *command) readinessChecker(rdc *redis.Client) func(ctx context.Context) error { + return func(ctx context.Context) error { + if rdc == nil { + return nil + } + + return rdc.Ping(ctx).Err() + } +} + +// latestAppVersionGetter returns a function to get the latest app version. +func (cmd *command) latestAppVersionGetter() func(ctx context.Context) (string, error) { + return func(ctx context.Context) (string, error) { return version.Latest(ctx) } +} diff --git a/internal/cli/start/healthcheck/checker.go b/internal/cli/start/healthcheck/checker.go new file mode 100644 index 00000000..196358a2 --- /dev/null +++ b/internal/cli/start/healthcheck/checker.go @@ -0,0 +1,71 @@ +package healthcheck + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "time" +) + +type httpClient interface { + Do(*http.Request) (*http.Response, error) +} + +// HealthChecker is a heals checker. +type HealthChecker struct { + httpClient httpClient +} + +const ( + defaultHTTPClientTimeout = time.Second * 3 + + UserAgent = "HealthChecker/webhook-tester" + Route = "/healthz" + Method = http.MethodGet +) + +// NewHealthChecker creates heals checker. +func NewHealthChecker(client ...httpClient) *HealthChecker { + var c httpClient + + if len(client) == 1 { + c = client[0] + } else { + c = &http.Client{ // default + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec + }, + }, + Timeout: defaultHTTPClientTimeout, + } + } + + return &HealthChecker{httpClient: c} +} + +// Check application using liveness probe. +func (c *HealthChecker) Check(ctx context.Context, httpPort uint) error { + var uri = fmt.Sprintf("http://127.0.0.1:%d%s", httpPort, Route) + + req, err := http.NewRequestWithContext(ctx, Method, uri, http.NoBody) + if err != nil { + return err + } + + req.Header.Set("User-Agent", UserAgent) + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + + _ = resp.Body.Close() + + if code := resp.StatusCode; code != http.StatusOK { + return fmt.Errorf("wrong status code [%d] from the live endpoint (%s)", code, uri) + } + + return nil +} diff --git a/internal/checkers/health_test.go b/internal/cli/start/healthcheck/checker_test.go similarity index 63% rename from internal/checkers/health_test.go rename to internal/cli/start/healthcheck/checker_test.go index 0d56c7b2..03d9fb4d 100644 --- a/internal/checkers/health_test.go +++ b/internal/cli/start/healthcheck/checker_test.go @@ -1,4 +1,4 @@ -package checkers_test +package healthcheck_test import ( "bytes" @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" - "gh.tarampamp.am/webhook-tester/internal/checkers" + "gh.tarampamp.am/webhook-tester/v2/internal/cli/start/healthcheck" ) type httpClientFunc func(*http.Request) (*http.Response, error) @@ -17,10 +17,17 @@ type httpClientFunc func(*http.Request) (*http.Response, error) func (f httpClientFunc) Do(req *http.Request) (*http.Response, error) { return f(req) } func TestHealthChecker_CheckSuccess(t *testing.T) { + t.Parallel() + var httpMock httpClientFunc = func(req *http.Request) (*http.Response, error) { + switch req.URL.String() { + case "http://127.0.0.1:80/healthz": // ok + default: + t.Error("unexpected URL") + } + assert.Equal(t, http.MethodGet, req.Method) - assert.Equal(t, "http://127.0.0.1:123/live", req.URL.String()) - assert.Equal(t, "HealthChecker/internal", req.Header.Get("User-Agent")) + assert.Equal(t, "HealthChecker/webhook-tester", req.Header.Get("User-Agent")) return &http.Response{ Body: io.NopCloser(bytes.NewReader([]byte{})), @@ -28,12 +35,14 @@ func TestHealthChecker_CheckSuccess(t *testing.T) { }, nil } - checker := checkers.NewHealthChecker(context.Background(), httpMock) + checker := healthcheck.NewHealthChecker(httpMock) - assert.NoError(t, checker.Check(123)) + assert.NoError(t, checker.Check(context.Background(), 80)) } func TestHealthChecker_CheckFail(t *testing.T) { + t.Parallel() + var httpMock httpClientFunc = func(req *http.Request) (*http.Response, error) { return &http.Response{ Body: io.NopCloser(bytes.NewReader([]byte{})), @@ -41,9 +50,9 @@ func TestHealthChecker_CheckFail(t *testing.T) { }, nil } - checker := checkers.NewHealthChecker(context.Background(), httpMock) + checker := healthcheck.NewHealthChecker(httpMock) - err := checker.Check(123) + err := checker.Check(context.Background(), 80) assert.Error(t, err) assert.Contains(t, err.Error(), "wrong status code") } diff --git a/internal/cli/start/healthcheck/command.go b/internal/cli/start/healthcheck/command.go new file mode 100644 index 00000000..b2435d4e --- /dev/null +++ b/internal/cli/start/healthcheck/command.go @@ -0,0 +1,40 @@ +package healthcheck + +import ( + "context" + "fmt" + + "github.com/urfave/cli/v3" +) + +// NewCommand creates `healthcheck` command. +func NewCommand(defaultHttpPort uint16) *cli.Command { + var ( + httpPortFlag = cli.UintFlag{ + Name: "http-port", + Usage: "HTTP server port", + Value: uint64(defaultHttpPort), + Sources: cli.EnvVars("HTTP_PORT"), + OnlyOnce: true, + Validator: func(port uint64) error { + if port == 0 || port > 65535 { + return fmt.Errorf("wrong TCP port number [%d]", port) + } + + return nil + }, + } + ) + + return &cli.Command{ + Name: "healthcheck", + Aliases: []string{"hc", "health", "check"}, + Usage: "Health checker for the HTTP(S) servers. Use case - docker healthcheck", + Action: func(ctx context.Context, c *cli.Command) error { + return NewHealthChecker().Check(ctx, uint(c.Uint(httpPortFlag.Name))) + }, + Flags: []cli.Flag{ + &httpPortFlag, + }, + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 339a0353..e70941fc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,35 +2,9 @@ package config import "time" -type pubSubDriver string - -const ( - PubSubDriverMemory pubSubDriver = "memory" - PubSubDriverRedis pubSubDriver = "redis" -) - -func (d pubSubDriver) String() string { return string(d) } - -type storageDriver string - -const ( - StorageDriverMemory storageDriver = "memory" - StorageDriverRedis storageDriver = "redis" -) - -func (d storageDriver) String() string { return string(d) } - -type Config struct { - MaxRequests uint16 - SessionTTL time.Duration - IgnoreHeaderPrefixes []string - MaxRequestBodySize uint32 // maximal webhook request body size (in bytes), zero means unlimited - - StorageDriver storageDriver - PubSubDriver pubSubDriver - - WebSockets struct { - MaxClients uint32 // zero means unlimited - MaxLifetime time.Duration // zero means unlimited - } +type AppSettings struct { + MaxRequests uint16 + MaxRequestBodySize uint32 + SessionTTL time.Duration + AutoCreateSessions bool } diff --git a/internal/encoding/encoding.go b/internal/encoding/encoding.go new file mode 100644 index 00000000..4327f136 --- /dev/null +++ b/internal/encoding/encoding.go @@ -0,0 +1,16 @@ +package encoding + +type Encoder interface { + // Encode marshals the given value into a byte slice. + Encode(any) ([]byte, error) +} + +type Decoder interface { + // Decode unmarshal the given byte slice into the given value. + Decode([]byte, any) error +} + +type EncoderDecoder interface { + Encoder + Decoder +} diff --git a/internal/encoding/json.go b/internal/encoding/json.go new file mode 100644 index 00000000..d4c59bd1 --- /dev/null +++ b/internal/encoding/json.go @@ -0,0 +1,10 @@ +package encoding + +import "encoding/json" + +type JSON struct{} + +var _ EncoderDecoder = (*JSON)(nil) // ensure interface implementation + +func (JSON) Encode(v any) ([]byte, error) { return json.Marshal(v) } +func (JSON) Decode(data []byte, v any) error { return json.Unmarshal(data, v) } diff --git a/internal/encoding/json_test.go b/internal/encoding/json_test.go new file mode 100644 index 00000000..4ca68383 --- /dev/null +++ b/internal/encoding/json_test.go @@ -0,0 +1,102 @@ +package encoding_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gh.tarampamp.am/webhook-tester/v2/internal/encoding" +) + +type someJSONStruct struct { + String string `json:"string"` + Int int `json:"int"` + Uint uint `json:"uint"` + Float float64 `json:"float"` + StrSlice []string `json:"str_slice"` + IntSlice []int `json:"int_slice"` + Nested *someJSONStruct `json:"nested,omitempty"` +} + +func TestJSON_Decode(t *testing.T) { + t.Parallel() + + var v = someJSONStruct{ + String: "string", + Int: 1, + Uint: 1, + Float: 1.1, + StrSlice: []string{"a", "b"}, + IntSlice: []int{1, 2}, + Nested: &someJSONStruct{ + String: "string", + Int: 1, + Uint: 1, + Float: 1.1, + StrSlice: []string{"a", "b"}, + IntSlice: []int{1, 2}, + }, + } + + j, err := (encoding.JSON{}).Encode(v) + require.NoError(t, err) + + assert.JSONEq(t, + `{ +"string":"string", +"int":1, +"uint":1, +"float":1.1, +"str_slice":["a","b"], +"int_slice":[1,2], +"nested":{ + "string":"string", + "int":1, + "uint":1, + "float":1.1, + "str_slice":["a","b"], + "int_slice":[1,2] +}}`, string(j)) +} + +func TestJSON_Encode(t *testing.T) { + t.Parallel() + + var v someJSONStruct + + err := (encoding.JSON{}).Decode([]byte(`{ +"string":"string", +"int":1, +"uint":1, +"float":1.1, +"str_slice":["a","b"], +"int_slice":[1,2], +"nested":{ + "string":"string", + "int":1, + "uint":1, + "float":1.1, + "str_slice":["a","b"], + "int_slice":[1,2] +}}`), &v) + + require.NoError(t, err) + + assert.Equal(t, v, someJSONStruct{ + String: "string", + Int: 1, + Uint: 1, + Float: 1.1, + StrSlice: []string{"a", "b"}, + IntSlice: []int{1, 2}, + Nested: &someJSONStruct{ + String: "string", + Int: 1, + Uint: 1, + Float: 1.1, + StrSlice: []string{"a", "b"}, + IntSlice: []int{1, 2}, + }, + }) +} diff --git a/internal/env/env.go b/internal/env/env.go deleted file mode 100644 index 1a98af8f..00000000 --- a/internal/env/env.go +++ /dev/null @@ -1,28 +0,0 @@ -// Package env contains all about environment variables, that can be used by current application. -package env - -import "os" - -type envVariable string - -const ( - ListenAddr envVariable = "LISTEN_ADDR" // IP address for listening - ListenPort envVariable = "LISTEN_PORT" // port number for listening - Port envVariable = "PORT" // alias for ListenPort - MaxSessionRequests envVariable = "MAX_REQUESTS" // maximum stored requests per session - SessionTTL envVariable = "SESSION_TTL" // session lifetime - StorageDriverName envVariable = "STORAGE_DRIVER" // storage driver name - PubSubDriver envVariable = "PUBSUB_DRIVER" // pub/sub driver name - WebsocketMaxClients envVariable = "WS_MAX_CLIENTS" // maximal websocket clients - WebsocketMaxLifetime envVariable = "WS_MAX_LIFETIME" // maximal single websocket lifetime - RedisDSN envVariable = "REDIS_DSN" // URL-like redis connection string - CreateSessionUUID envVariable = "CREATE_SESSION" // Persistent session UUID, issue#160 -) - -// String returns environment variable name in the string representation. -func (e envVariable) String() string { return string(e) } - -// Lookup retrieves the value of the environment variable. If the variable is present in the environment the value -// (which may be empty) is returned and the boolean is true. Otherwise, the returned value will be empty and the -// boolean will be false. -func (e envVariable) Lookup() (string, bool) { return os.LookupEnv(string(e)) } diff --git a/internal/env/env_test.go b/internal/env/env_test.go deleted file mode 100644 index 0108e685..00000000 --- a/internal/env/env_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package env - -import ( - "os" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestConstants(t *testing.T) { - assert.Equal(t, "LISTEN_ADDR", string(ListenAddr)) - assert.Equal(t, "LISTEN_PORT", string(ListenPort)) - assert.Equal(t, "MAX_REQUESTS", string(MaxSessionRequests)) - assert.Equal(t, "SESSION_TTL", string(SessionTTL)) - assert.Equal(t, "STORAGE_DRIVER", string(StorageDriverName)) - assert.Equal(t, "PUBSUB_DRIVER", string(PubSubDriver)) - assert.Equal(t, "WS_MAX_CLIENTS", string(WebsocketMaxClients)) - assert.Equal(t, "WS_MAX_LIFETIME", string(WebsocketMaxLifetime)) - assert.Equal(t, "REDIS_DSN", string(RedisDSN)) - assert.Equal(t, "CREATE_SESSION", string(CreateSessionUUID)) -} - -func TestEnvVariable_Lookup(t *testing.T) { - cases := []struct { - giveEnv envVariable - }{ - {giveEnv: ListenAddr}, - {giveEnv: ListenPort}, - {giveEnv: MaxSessionRequests}, - {giveEnv: SessionTTL}, - {giveEnv: StorageDriverName}, - {giveEnv: PubSubDriver}, - {giveEnv: WebsocketMaxClients}, - {giveEnv: WebsocketMaxLifetime}, - {giveEnv: RedisDSN}, - {giveEnv: CreateSessionUUID}, - } - - for _, tt := range cases { - t.Run(tt.giveEnv.String(), func(t *testing.T) { - assert.NoError(t, os.Unsetenv(tt.giveEnv.String())) // make sure that env is unset for test - - defer func() { assert.NoError(t, os.Unsetenv(tt.giveEnv.String())) }() - - value, exists := tt.giveEnv.Lookup() - assert.False(t, exists) - assert.Empty(t, value) - - assert.NoError(t, os.Setenv(tt.giveEnv.String(), "foo")) - - value, exists = tt.giveEnv.Lookup() - assert.True(t, exists) - assert.Equal(t, "foo", value) - }) - } -} diff --git a/internal/http/fileserver/handler.go b/internal/http/fileserver/handler.go deleted file mode 100644 index 69200a7e..00000000 --- a/internal/http/fileserver/handler.go +++ /dev/null @@ -1,48 +0,0 @@ -package fileserver - -import ( - "io" - "net/http" - "os" - "path" - - "github.com/labstack/echo/v4" -) - -var fallback404 = []byte("

Error 404

Not found

") //nolint:lll,gochecknoglobals - -func NewHandler(root http.FileSystem) func(c echo.Context) error { - var ( - fileServer = http.FileServer(root) - errorPageContent []byte - ) - - if f, err := root.Open("404.html"); err == nil { - errorPageContent, _ = io.ReadAll(f) - } - - return func(c echo.Context) error { - f, err := root.Open(path.Clean(c.Request().URL.Path)) - if os.IsNotExist(err) { - if c.Request().Method == http.MethodHead { - return c.NoContent(http.StatusNotFound) - } - - if len(errorPageContent) > 0 { - return c.HTMLBlob(http.StatusNotFound, errorPageContent) - } - - return c.HTMLBlob(http.StatusNotFound, fallback404) - } - - if err != nil { // looks like unneeded, but so looks better - _ = f.Close() - } - - if c.Request().Method == http.MethodHead { - return c.NoContent(http.StatusOK) - } - - return echo.WrapHandler(fileServer)(c) - } -} diff --git a/internal/http/frontend/fallback404.html b/internal/http/frontend/fallback404.html new file mode 100644 index 00000000..b017e3fd --- /dev/null +++ b/internal/http/frontend/fallback404.html @@ -0,0 +1,51 @@ + + + + + + Not found + + + +

Error 404

+

Not found

+ + diff --git a/internal/http/frontend/handler.go b/internal/http/frontend/handler.go new file mode 100644 index 00000000..9df429b7 --- /dev/null +++ b/internal/http/frontend/handler.go @@ -0,0 +1,103 @@ +package frontend + +import ( + _ "embed" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path" + "strings" +) + +//go:embed fallback404.html +var fallback404html []byte + +func New(root fs.FS) http.Handler { //nolint:funlen + var fileServer = http.FileServerFS(root) + + const ( + contentTypeHeader = "Content-Type" + contentTypeHTML = "text/html; charset=utf-8" + indexFileName = "index.html" + ) + + var fileCacheBoostExtensionsMap = map[string]struct{}{ + ".gz": {}, + ".svg": {}, + ".png": {}, + ".jpg": {}, + ".jpeg": {}, + ".woff": {}, + ".otf": {}, + ".ttf": {}, + ".eot": {}, + ".ico": {}, + ".css": {}, + ".js": {}, + ".webmanifest": {}, + } + + // TODO: add caching headers for static files + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var filePath = strings.TrimLeft(path.Clean(r.URL.Path), "/") + + if filePath == "" { + filePath = indexFileName + } + + fd, fErr := root.Open(filePath) + switch { //nolint:wsl + case os.IsNotExist(fErr): // if requested file does not exist + index, indexErr := root.Open(indexFileName) + if indexErr == nil { // always return index.html, if it exists (required for SPA to work) + defer func() { _ = index.Close() }() + + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + + return + } + + w.Header().Set(contentTypeHeader, contentTypeHTML) + w.WriteHeader(http.StatusOK) + _, _ = io.Copy(w, index) + + return + } + + w.Header().Set(contentTypeHeader, contentTypeHTML) + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write(fallback404html) + + return + case fErr != nil: // some other error + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusInternalServerError) + + return + } + + http.Error(w, fmt.Errorf("failed to open file %s: %w", filePath, fErr).Error(), http.StatusInternalServerError) + + return + } + + defer func() { _ = fd.Close() }() + + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + + return + } + + if ext := strings.ToLower(path.Ext(r.URL.Path)); ext != "" { + if _, ok := fileCacheBoostExtensionsMap[ext]; ok { + w.Header().Set("Cache-Control", "public, max-age=604800") // 1 week + } + } + + fileServer.ServeHTTP(w, r) + }) +} diff --git a/internal/http/fileserver/handler_test.go b/internal/http/frontend/middleware_test.go similarity index 74% rename from internal/http/fileserver/handler_test.go rename to internal/http/frontend/middleware_test.go index 23938e23..687a75b9 100644 --- a/internal/http/fileserver/handler_test.go +++ b/internal/http/frontend/middleware_test.go @@ -1,34 +1,35 @@ -package fileserver_test +package frontend_test import ( + "io/fs" "net/http" "net/http/httptest" "testing" "testing/fstest" - "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" - "gh.tarampamp.am/webhook-tester/internal/http/fileserver" + "gh.tarampamp.am/webhook-tester/v2/internal/http/frontend" ) func TestHandler(t *testing.T) { var ( - e = echo.New() - root = http.FS(fstest.MapFS{ + root = fs.FS(fstest.MapFS{ "index.html": { Data: []byte("index"), }, "404.html": { Data: []byte("OLOLO 404"), }, + "robots.txt": { + Data: []byte("User-agent: *\nDisallow: /"), + }, }) ) for name, tt := range map[string]struct { giveUrl string giveMethod string - wantError bool wantCode int wantInBody string wantEmptyResponseBody bool @@ -54,13 +55,19 @@ func TestHandler(t *testing.T) { "not found": { giveUrl: "/foo", giveMethod: http.MethodGet, - wantCode: http.StatusNotFound, - wantInBody: "OLOLO 404", + wantCode: http.StatusOK, + wantInBody: "index", }, "not found (head)": { giveUrl: "/foo", giveMethod: http.MethodHead, - wantCode: http.StatusNotFound, + wantCode: http.StatusOK, + wantEmptyResponseBody: true, + }, + "existing file (head)": { + giveUrl: "/robots.txt", + giveMethod: http.MethodHead, + wantCode: http.StatusOK, wantEmptyResponseBody: true, }, } { @@ -68,16 +75,9 @@ func TestHandler(t *testing.T) { var ( req = httptest.NewRequest(tt.giveMethod, tt.giveUrl, http.NoBody) rec = httptest.NewRecorder() - ctx = e.NewContext(req, rec) - - h = fileserver.NewHandler(root) ) - err := h(ctx) - - if tt.wantError { - assert.Error(t, err) - } + frontend.New(root).ServeHTTP(rec, req) assert.Equal(t, tt.wantCode, rec.Code) diff --git a/internal/http/handlers/api.go b/internal/http/handlers/api.go deleted file mode 100644 index 2585657f..00000000 --- a/internal/http/handlers/api.go +++ /dev/null @@ -1,60 +0,0 @@ -package handlers - -import ( - "context" - - "github.com/go-redis/redis/v8" - "github.com/prometheus/client_golang/prometheus" - - "gh.tarampamp.am/webhook-tester/internal/api" - "gh.tarampamp.am/webhook-tester/internal/checkers" - "gh.tarampamp.am/webhook-tester/internal/config" - "gh.tarampamp.am/webhook-tester/internal/pubsub" - "gh.tarampamp.am/webhook-tester/internal/storage" -) - -type API struct { - apiVersion - apiHealth - apiSession - apiSettings - apiMetrics - apiWebsocket -} - -var _ api.ServerInterface = (*API)(nil) // verify that API implements interface - -func NewAPI( - ctx context.Context, - cfg config.Config, - rdb *redis.Client, - stor storage.Storage, - pub pubsub.Publisher, - sub pubsub.Subscriber, - registry *prometheus.Registry, - version string, - websocketMetrics websocketMetrics, -) *API { - var result = API{} - - result.apiHealth.liveChecker = checkers.NewLiveChecker() - result.apiHealth.readyChecker = checkers.NewReadyChecker(ctx, rdb) - - result.apiSettings.cfg = cfg - - result.apiVersion.version = version - - result.apiSession.storage = stor - result.apiSession.pub = pub - - result.apiMetrics.registry = registry - - result.apiWebsocket.ctx = ctx - result.apiWebsocket.cfg = cfg - result.apiWebsocket.stor = stor - result.apiWebsocket.pub = pub - result.apiWebsocket.sub = sub - result.apiWebsocket.metrics = websocketMetrics - - return &result -} diff --git a/internal/http/handlers/api_health.go b/internal/http/handlers/api_health.go deleted file mode 100644 index 37f49dab..00000000 --- a/internal/http/handlers/api_health.go +++ /dev/null @@ -1,47 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/labstack/echo/v4" -) - -type checker interface { - Check() error -} - -type apiHealth struct { - liveChecker, readyChecker checker -} - -func (s *apiHealth) makeCheck(c echo.Context, chk checker, noContent bool) error { - if err := chk.Check(); err != nil { - if noContent { - return c.NoContent(http.StatusServiceUnavailable) - } - - return c.String(http.StatusServiceUnavailable, err.Error()) - } - - return c.NoContent(http.StatusOK) -} - -// LivenessProbe returns code 200 if the application is alive. -func (s *apiHealth) LivenessProbe(c echo.Context) error { - return s.makeCheck(c, s.liveChecker, false) -} - -// LivenessProbeHead is an alias for the LivenessProbe. -func (s *apiHealth) LivenessProbeHead(c echo.Context) error { - return s.makeCheck(c, s.liveChecker, true) -} - -// ReadinessProbe returns code 200 if the application is ready to serve traffic. -func (s *apiHealth) ReadinessProbe(c echo.Context) error { - return s.makeCheck(c, s.readyChecker, false) -} - -// ReadinessProbeHead is an alias for the ReadinessProbe. -func (s *apiHealth) ReadinessProbeHead(c echo.Context) error { - return s.makeCheck(c, s.readyChecker, true) -} diff --git a/internal/http/handlers/api_health_test.go b/internal/http/handlers/api_health_test.go deleted file mode 100644 index d35e8f5e..00000000 --- a/internal/http/handlers/api_health_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package handlers_test - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - - "github.com/alicebob/miniredis/v2" - "github.com/go-redis/redis/v8" - "github.com/labstack/echo/v4" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "gh.tarampamp.am/webhook-tester/internal/config" - "gh.tarampamp.am/webhook-tester/internal/http/handlers" -) - -func TestApiHealth_LivenessProbe(t *testing.T) { - var ( - api = handlers.NewAPI( - context.Background(), config.Config{}, nil, nil, nil, nil, nil, "", nil, - ) - - req, _ = http.NewRequest(http.MethodGet, "http://test/", http.NoBody) - rr = httptest.NewRecorder() - ) - - assert.NoError(t, api.LivenessProbe(echo.New().NewContext(req, rr))) - - assert.Equal(t, rr.Code, http.StatusOK) - assert.Empty(t, rr.Body.String()) -} - -func TestApiHealth_ReadinessProbe(t *testing.T) { - // start mini-redis - mini, err := miniredis.Run() - require.NoError(t, err) - - defer mini.Close() - - rdb := redis.NewClient(&redis.Options{Addr: mini.Addr()}) - - var ( - api = handlers.NewAPI( - context.Background(), config.Config{}, rdb, nil, nil, nil, nil, "", nil, - ) - - req, _ = http.NewRequest(http.MethodGet, "http://test/", http.NoBody) - rr = httptest.NewRecorder() - ) - - assert.NoError(t, api.ReadinessProbe(echo.New().NewContext(req, rr))) - - assert.Equal(t, rr.Code, http.StatusOK) - assert.Empty(t, rr.Body.String()) -} - -func TestApiHealth_ReadinessProbeFail(t *testing.T) { - var ( - api = handlers.NewAPI( - context.Background(), config.Config{}, redis.NewClient(&redis.Options{}), nil, nil, nil, nil, "", nil, - ) - - req, _ = http.NewRequest(http.MethodGet, "http://test/", http.NoBody) - rr = httptest.NewRecorder() - ) - - assert.NoError(t, api.ReadinessProbe(echo.New().NewContext(req, rr))) - - assert.Equal(t, rr.Code, http.StatusServiceUnavailable) - assert.Contains(t, rr.Body.String(), "connection refused") -} diff --git a/internal/http/handlers/api_metrics.go b/internal/http/handlers/api_metrics.go deleted file mode 100644 index 8d231a99..00000000 --- a/internal/http/handlers/api_metrics.go +++ /dev/null @@ -1,20 +0,0 @@ -package handlers - -import ( - "github.com/labstack/echo/v4" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" -) - -type apiMetrics struct { - registry prometheus.Gatherer -} - -func (s *apiMetrics) AppMetrics(c echo.Context) error { - return echo.WrapHandler( - promhttp.HandlerFor( - s.registry, - promhttp.HandlerOpts{ErrorHandling: promhttp.ContinueOnError}, - ), - )(c) -} diff --git a/internal/http/handlers/api_metrics_test.go b/internal/http/handlers/api_metrics_test.go deleted file mode 100644 index dd00d0b4..00000000 --- a/internal/http/handlers/api_metrics_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package handlers_test - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - - "github.com/labstack/echo/v4" - "github.com/prometheus/client_golang/prometheus" - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/webhook-tester/internal/config" - "gh.tarampamp.am/webhook-tester/internal/http/handlers" -) - -func TestApiMetrics_AppMetrics(t *testing.T) { - var ( - registry = prometheus.NewRegistry() - testMetric = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Namespace: "foo", - Subsystem: "bar", - Name: "test", - Help: "Test metric.", - }, - []string{"foo"}, - ) - ) - - registry.MustRegister(testMetric) - testMetric.WithLabelValues("bar").Set(1) - - var ( - api = handlers.NewAPI( - context.Background(), config.Config{}, nil, nil, nil, nil, registry, "", nil, - ) - - req, _ = http.NewRequest(http.MethodGet, "http://test/", http.NoBody) - rr = httptest.NewRecorder() - ) - - assert.NoError(t, api.AppMetrics(echo.New().NewContext(req, rr))) - - assert.Equal(t, rr.Code, http.StatusOK) - assert.Equal(t, 200, rr.Code) - assert.Equal(t, `# HELP foo_bar_test Test metric. -# TYPE foo_bar_test gauge -foo_bar_test{foo="bar"} 1 -`, rr.Body.String()) - assert.Regexp(t, "^text/plain.*$", rr.Header().Get("Content-Type")) -} diff --git a/internal/http/handlers/api_session.go b/internal/http/handlers/api_session.go deleted file mode 100644 index c1be4633..00000000 --- a/internal/http/handlers/api_session.go +++ /dev/null @@ -1,217 +0,0 @@ -package handlers - -import ( - "encoding/base64" - "fmt" - "net/http" - "sort" - "time" - - "github.com/google/uuid" - "github.com/labstack/echo/v4" - "github.com/pkg/errors" - - "gh.tarampamp.am/webhook-tester/internal/api" - "gh.tarampamp.am/webhook-tester/internal/pubsub" - "gh.tarampamp.am/webhook-tester/internal/storage" -) - -type apiSession struct { - storage storage.Storage - pub pubsub.Publisher -} - -// ApiSessionCreate creates a new session with passed parameters. -func (s *apiSession) ApiSessionCreate(c echo.Context) error { - var payload api.NewSession - - if err := c.Bind(&payload); err != nil { - return c.JSON(http.StatusBadRequest, api.BadRequest{ - Code: http.StatusBadRequest, - Message: err.Error(), - }) - } - - if err := payload.Validate(); err != nil { - return c.JSON(http.StatusBadRequest, api.BadRequest{ - Code: http.StatusBadRequest, - Message: err.Error(), - }) - } - - var ( - content = payload.ResponseContent() - status = payload.GetStatusCode() - contentType = payload.GetContentType() - delay = payload.GetResponseDelay() - ) - - sessionUuid, err := s.storage.CreateSession(content, status, contentType, time.Second*time.Duration(delay)) - if err != nil { - return c.JSON(http.StatusInternalServerError, api.ServerError{ - Code: http.StatusInternalServerError, - Message: err.Error(), - }) - } - - u, _ := uuid.Parse(sessionUuid) - - return c.JSON(http.StatusOK, api.SessionOptions{ - CreatedAtUnix: int(time.Now().Unix()), - Response: api.SessionResponseOptions{ - Code: api.StatusCode(status), - ContentBase64: base64.StdEncoding.EncodeToString(content), - ContentType: contentType, - DelaySec: delay, - }, - Uuid: u, - }) -} - -// ApiSessionDelete deletes the session with the passed UUID (and all associated requests). -func (s *apiSession) ApiSessionDelete(c echo.Context, session api.SessionUUID) error { - // delete session - if result, err := s.storage.DeleteSession(session.String()); err != nil { - return c.JSON(http.StatusInternalServerError, api.ServerError{ - Code: http.StatusInternalServerError, - Message: err.Error(), - }) - } else if !result { - return c.JSON(http.StatusNotFound, api.NotFound{ - Code: http.StatusNotFound, - Message: fmt.Sprintf("the session with UUID %s was not found", session), - }) - } - - // and recorded session requests - if _, err := s.storage.DeleteRequests(session.String()); err != nil { - return c.JSON(http.StatusInternalServerError, api.ServerError{ - Code: http.StatusInternalServerError, - Message: err.Error(), - }) - } - - return c.JSON(http.StatusOK, api.SuccessfulOperation{ - Success: true, - }) -} - -// ApiSessionDeleteAllRequests deletes all recorded session requests. -func (s *apiSession) ApiSessionDeleteAllRequests(c echo.Context, session api.SessionUUID) error { - if deleted, err := s.storage.DeleteRequests(session.String()); err != nil { - return c.JSON(http.StatusInternalServerError, api.ServerError{ - Code: http.StatusInternalServerError, - Message: err.Error(), - }) - } else if !deleted { - return c.JSON(http.StatusNotFound, api.ServerError{ - Code: http.StatusNotFound, - Message: fmt.Sprintf("requests for the session with UUID %s was not found", session.String()), - }) - } - - go func() { _ = s.pub.Publish(session.String(), pubsub.NewAllRequestsDeletedEvent()) }() - - return c.JSON(http.StatusOK, api.SuccessfulOperation{ - Success: true, - }) -} - -func (s *apiSession) convertStoredRequestToApiStruct(in storage.Request) api.SessionRequest { - var ( - headersMap = in.Headers() - headers = make([]api.HttpHeader, 0, len(headersMap)) - ) - - for name, value := range headersMap { - headers = append(headers, api.HttpHeader{Name: name, Value: value}) - } - - sort.SliceStable(headers, func(j, k int) bool { return headers[j].Name < headers[k].Name }) - - u, _ := uuid.Parse(in.UUID()) - - return api.SessionRequest{ - Uuid: u, - ClientAddress: in.ClientAddr(), - Method: api.HttpMethod(in.Method()), - ContentBase64: base64.StdEncoding.EncodeToString(in.Content()), - Headers: headers, - Url: in.URI(), - CreatedAtUnix: api.UnixTime(in.CreatedAt().Unix()), - } -} - -// ApiSessionGetAllRequests returns all session recorded requests. -func (s *apiSession) ApiSessionGetAllRequests(c echo.Context, sessionUuid api.SessionUUID) error { - if session, err := s.storage.GetSession(sessionUuid.String()); session == nil { - if err != nil { - return c.JSON(http.StatusInternalServerError, api.ServerError{ - Code: http.StatusInternalServerError, - Message: errors.Wrap(err, "cannot read session data").Error(), - }) - } - - return c.JSON(http.StatusNotFound, api.ServerError{ - Code: http.StatusNotFound, - Message: fmt.Sprintf("the session with UUID %s was not found", sessionUuid.String()), - }) - } - - requests, err := s.storage.GetAllRequests(sessionUuid.String()) - if err != nil { - return c.JSON(http.StatusInternalServerError, api.ServerError{ - Code: http.StatusInternalServerError, - Message: errors.Wrap(err, "cannot get requests data").Error(), - }) - } - - var result = make([]api.SessionRequest, 0, len(requests)) - - for i := range requests { - result = append(result, s.convertStoredRequestToApiStruct(requests[i])) - } - - // sort requests from newest to oldest - sort.SliceStable(result, func(j, k int) bool { return result[j].CreatedAtUnix > result[k].CreatedAtUnix }) - - return c.JSON(http.StatusOK, result) -} - -// ApiSessionDeleteRequest deletes the request with passed UUID. -func (s *apiSession) ApiSessionDeleteRequest(c echo.Context, session api.SessionUUID, request api.RequestUUID) error { - if deleted, err := s.storage.DeleteRequest(session.String(), request.String()); err != nil { - return c.JSON(http.StatusInternalServerError, api.ServerError{ - Code: http.StatusInternalServerError, - Message: errors.Wrap(err, "cannot delete the request").Error(), - }) - } else if !deleted { - return c.JSON(http.StatusNotFound, api.ServerError{ - Code: http.StatusNotFound, - Message: fmt.Sprintf("request with UUID %s was not found", request.String()), - }) - } - - go func() { _ = s.pub.Publish(session.String(), pubsub.NewRequestDeletedEvent(request.String())) }() - - return c.JSON(http.StatusOK, api.SuccessfulOperation{ - Success: true, - }) -} - -func (s *apiSession) ApiSessionGetRequest(c echo.Context, session api.SessionUUID, requestUuid api.RequestUUID) error { - request, err := s.storage.GetRequest(session.String(), requestUuid.String()) - if err != nil { - return c.JSON(http.StatusInternalServerError, api.ServerError{ - Code: http.StatusInternalServerError, - Message: errors.Wrap(err, "cannot read request data").Error(), - }) - } else if request == nil { - return c.JSON(http.StatusNotFound, api.ServerError{ - Code: http.StatusNotFound, - Message: fmt.Sprintf("request with UUID %s was not found", requestUuid.String()), - }) - } - - return c.JSON(http.StatusOK, s.convertStoredRequestToApiStruct(request)) -} diff --git a/internal/http/handlers/api_settings.go b/internal/http/handlers/api_settings.go deleted file mode 100644 index 669745b1..00000000 --- a/internal/http/handlers/api_settings.go +++ /dev/null @@ -1,25 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/labstack/echo/v4" - - "gh.tarampamp.am/webhook-tester/internal/api" - "gh.tarampamp.am/webhook-tester/internal/config" -) - -type apiSettings struct { - cfg config.Config -} - -// ApiSettings returns application settings. -func (s *apiSettings) ApiSettings(c echo.Context) error { - return c.JSON(http.StatusOK, api.AppSettings{ - Limits: api.AppSettingsLimits{ - MaxRequests: int(s.cfg.MaxRequests), - MaxWebhookBodySize: int(s.cfg.MaxRequestBodySize), - SessionLifetimeSec: int(s.cfg.SessionTTL.Seconds()), - }, - }) -} diff --git a/internal/http/handlers/api_settings_test.go b/internal/http/handlers/api_settings_test.go deleted file mode 100644 index 3cfc3106..00000000 --- a/internal/http/handlers/api_settings_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package handlers_test - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/labstack/echo/v4" - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/webhook-tester/internal/config" - "gh.tarampamp.am/webhook-tester/internal/http/handlers" -) - -func TestApiSettings_ApiSettings(t *testing.T) { - var ( - api = handlers.NewAPI( - context.Background(), config.Config{ - MaxRequests: 123, - SessionTTL: time.Second * 321, - MaxRequestBodySize: 222, - }, nil, nil, nil, nil, nil, "", nil, - ) - - req, _ = http.NewRequest(http.MethodGet, "http://test/", http.NoBody) - rr = httptest.NewRecorder() - ) - - assert.NoError(t, api.ApiSettings(echo.New().NewContext(req, rr))) - - assert.Equal(t, rr.Code, http.StatusOK) - assert.JSONEq(t, `{ - "limits": {"max_requests":123, "session_lifetime_sec":321, "max_webhook_body_size": 222} - }`, rr.Body.String()) -} diff --git a/internal/http/handlers/api_version.go b/internal/http/handlers/api_version.go deleted file mode 100644 index ca94e305..00000000 --- a/internal/http/handlers/api_version.go +++ /dev/null @@ -1,18 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/labstack/echo/v4" - - "gh.tarampamp.am/webhook-tester/internal/api" -) - -type apiVersion struct { - version string -} - -// ApiAppVersion returns application version. -func (s *apiVersion) ApiAppVersion(c echo.Context) error { - return c.JSON(http.StatusOK, api.AppVersion{Version: s.version}) -} diff --git a/internal/http/handlers/api_version_test.go b/internal/http/handlers/api_version_test.go deleted file mode 100644 index 84000085..00000000 --- a/internal/http/handlers/api_version_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package handlers_test - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - - "github.com/labstack/echo/v4" - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/webhook-tester/internal/config" - "gh.tarampamp.am/webhook-tester/internal/http/handlers" -) - -func TestApiVersion_ApiAppVersion(t *testing.T) { - var ( - api = handlers.NewAPI( - context.Background(), config.Config{}, nil, nil, nil, nil, nil, "1.2.3@foo", nil, - ) - - req, _ = http.NewRequest(http.MethodGet, "http://test/", http.NoBody) - rr = httptest.NewRecorder() - ) - - assert.NoError(t, api.ApiAppVersion(echo.New().NewContext(req, rr))) - - assert.Equal(t, rr.Code, http.StatusOK) - assert.JSONEq(t, `{"version":"1.2.3@foo"}`, rr.Body.String()) -} diff --git a/internal/http/handlers/api_websocket.go b/internal/http/handlers/api_websocket.go deleted file mode 100644 index c13f44da..00000000 --- a/internal/http/handlers/api_websocket.go +++ /dev/null @@ -1,144 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "sync/atomic" - "time" - - "github.com/gorilla/websocket" - "github.com/labstack/echo/v4" - "github.com/pkg/errors" - - "gh.tarampamp.am/webhook-tester/internal/api" - "gh.tarampamp.am/webhook-tester/internal/config" - "gh.tarampamp.am/webhook-tester/internal/pubsub" - "gh.tarampamp.am/webhook-tester/internal/storage" -) - -type websocketMetrics interface { - IncrementActiveClients() - DecrementActiveClients() -} - -type apiWebsocket struct { - ctx context.Context - cfg config.Config - stor storage.Storage - pub pubsub.Publisher - sub pubsub.Subscriber - metrics websocketMetrics - - connCounter atomic.Int32 -} - -var upgrader = websocket.Upgrader{ //nolint:gochecknoglobals - ReadBufferSize: 512, //nolint:mnd - WriteBufferSize: 512, //nolint:mnd -} - -const ( - pingInterval = time.Second * 10 // pinging interval - eventsBufferSize = 64 // buffer size for events subscription -) - -// WebsocketSession returns websocket session. -func (s *apiWebsocket) WebsocketSession(c echo.Context, sessionUuid api.SessionUUID) error { //nolint:funlen - // is the limit exceeded? - if limit := s.cfg.WebSockets.MaxClients; limit != 0 { - if s.connCounter.Load() > int32(limit) { - return c.JSON(http.StatusTooManyRequests, api.NotFound{ - Code: http.StatusTooManyRequests, - Message: "Too many active connections", - }) - } - } - - // verify that session exists in a storage - if session, err := s.stor.GetSession(sessionUuid.String()); session == nil { - if err != nil { - return c.JSON(http.StatusInternalServerError, api.ServerError{ - Code: http.StatusInternalServerError, - Message: errors.Wrap(err, "cannot read session data").Error(), - }) - } - - return c.JSON(http.StatusNotFound, api.ServerError{ - Code: http.StatusNotFound, - Message: fmt.Sprintf("the session with UUID %s was not found", sessionUuid.String()), - }) - } - - // upgrade the HTTP server connection to the WebSocket protocol - ws, err := upgrader.Upgrade(c.Response(), c.Request(), http.Header{}) - if err != nil { - return c.JSON(http.StatusInternalServerError, api.ServerError{ - Code: http.StatusInternalServerError, - Message: err.Error(), - }) - } - - defer func() { _ = ws.Close() }() - - s.connCounter.Add(1) // increment active connections count - s.metrics.IncrementActiveClients() - - defer func() { - s.connCounter.Add(-1) - s.metrics.DecrementActiveClients() - }() - - // create channel for events (do NOT close it unless you are sure no one is writing into it) - var eventsCh = make(chan pubsub.Event, eventsBufferSize) - - // subscribe to events - if err = s.sub.Subscribe(sessionUuid.String(), eventsCh); err != nil { - return c.JSON(http.StatusInternalServerError, api.ServerError{ - Code: http.StatusInternalServerError, - Message: err.Error(), - }) - } - - // gracefully unsubscribe from events - defer func() { _ = s.sub.Unsubscribe(sessionUuid.String(), eventsCh) }() - - var ( - ctx context.Context - cancel context.CancelFunc - ) - - if lifetime := s.cfg.WebSockets.MaxLifetime; lifetime > time.Duration(0) { - ctx, cancel = context.WithTimeout(s.ctx, lifetime) - } else { - ctx, cancel = context.WithCancel(s.ctx) - } - - defer cancel() - - pingTicker := time.NewTicker(pingInterval) - defer pingTicker.Stop() - - for { - select { - case <-ctx.Done(): - return c.NoContent(http.StatusGone) - - case event := <-eventsCh: - j, _ := json.Marshal(api.WebsocketPayload{ //nolint:errchkjson - Name: api.WebsocketPayloadName(event.Name()), - Data: string(event.Data()), - }) - - if err = ws.WriteMessage(websocket.TextMessage, j); err != nil { - return c.NoContent(http.StatusGone) // cannot write into websocket (client has left the channel) - } - - case <-pingTicker.C: - if err = ws.WriteMessage(websocket.PingMessage, nil); err != nil { - return c.NoContent(http.StatusUnprocessableEntity) // client pinging using websocket has been failed - } - } - } -} diff --git a/internal/http/handlers/live/handler.go b/internal/http/handlers/live/handler.go new file mode 100644 index 00000000..7af12d37 --- /dev/null +++ b/internal/http/handlers/live/handler.go @@ -0,0 +1,16 @@ +package live + +import "net/http" + +type Handler struct{} + +func New() *Handler { return &Handler{} } + +func (h *Handler) Handle(w http.ResponseWriter, method string) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + + if method == http.MethodGet { + _, _ = w.Write([]byte("OK")) + } +} diff --git a/internal/http/handlers/ready/handler.go b/internal/http/handlers/ready/handler.go new file mode 100644 index 00000000..8fcbe377 --- /dev/null +++ b/internal/http/handlers/ready/handler.go @@ -0,0 +1,39 @@ +package ready + +import ( + "context" + "net/http" +) + +type ( + checker func(context.Context) error + Handler struct{ checker checker } +) + +func New(c checker) *Handler { return &Handler{checker: c} } + +func (h *Handler) Handle(ctx context.Context, w http.ResponseWriter, method string) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + + if h.checker == nil { + w.WriteHeader(http.StatusNoContent) + + return + } + + if err := h.checker(ctx); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + + if method == http.MethodGet { + _, _ = w.Write([]byte(err.Error())) + } + + return + } + + w.WriteHeader(http.StatusOK) + + if method == http.MethodGet { + _, _ = w.Write([]byte("OK")) + } +} diff --git a/internal/http/handlers/request_delete/handler.go b/internal/http/handlers/request_delete/handler.go new file mode 100644 index 00000000..ba2a78b2 --- /dev/null +++ b/internal/http/handlers/request_delete/handler.go @@ -0,0 +1,25 @@ +package request_delete + +import ( + "context" + + "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" + "gh.tarampamp.am/webhook-tester/v2/internal/storage" +) + +type ( + sID = openapi.SessionUUIDInPath + rID = openapi.RequestUUIDInPath + + Handler struct{ db storage.Storage } +) + +func New(db storage.Storage) *Handler { return &Handler{db: db} } + +func (h *Handler) Handle(ctx context.Context, sID sID, rID rID) (*openapi.SuccessfulOperationResponse, error) { + if err := h.db.DeleteRequest(ctx, sID.String(), rID.String()); err != nil { + return nil, err + } + + return &openapi.SuccessfulOperationResponse{Success: true}, nil +} diff --git a/internal/http/handlers/request_get/handler.go b/internal/http/handlers/request_get/handler.go new file mode 100644 index 00000000..f6e20c4e --- /dev/null +++ b/internal/http/handlers/request_get/handler.go @@ -0,0 +1,41 @@ +package request_get + +import ( + "context" + "encoding/base64" + "strings" + + "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" + "gh.tarampamp.am/webhook-tester/v2/internal/storage" +) + +type ( + sID = openapi.SessionUUIDInPath + rID = openapi.RequestUUIDInPath + + Handler struct{ db storage.Storage } +) + +func New(db storage.Storage) *Handler { return &Handler{db: db} } + +func (h *Handler) Handle(ctx context.Context, sID sID, rID rID) (*openapi.CapturedRequestsResponse, error) { + r, rErr := h.db.GetRequest(ctx, sID.String(), rID.String()) + if rErr != nil { + return nil, rErr + } + + var rHeaders = make([]openapi.HttpHeader, len(r.Headers)) + for i, header := range r.Headers { + rHeaders[i].Name, rHeaders[i].Value = header.Name, header.Value + } + + return &openapi.CapturedRequestsResponse{ + CapturedAtUnixMilli: r.CreatedAtUnixMilli, + ClientAddress: r.ClientAddr, + Headers: rHeaders, + Method: strings.ToUpper(r.Method), + RequestPayloadBase64: base64.StdEncoding.EncodeToString(r.Body), + Url: r.URL, + Uuid: rID, + }, nil +} diff --git a/internal/http/handlers/requests_delete_all/handler.go b/internal/http/handlers/requests_delete_all/handler.go new file mode 100644 index 00000000..13338008 --- /dev/null +++ b/internal/http/handlers/requests_delete_all/handler.go @@ -0,0 +1,24 @@ +package requests_delete_all + +import ( + "context" + + "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" + "gh.tarampamp.am/webhook-tester/v2/internal/storage" +) + +type ( + sID = openapi.SessionUUIDInPath + + Handler struct{ db storage.Storage } +) + +func New(db storage.Storage) *Handler { return &Handler{db: db} } + +func (h *Handler) Handle(ctx context.Context, sID sID) (*openapi.SuccessfulOperationResponse, error) { + if err := h.db.DeleteAllRequests(ctx, sID.String()); err != nil { + return nil, err + } + + return &openapi.SuccessfulOperationResponse{Success: true}, nil +} diff --git a/internal/http/handlers/requests_list/handler.go b/internal/http/handlers/requests_list/handler.go new file mode 100644 index 00000000..a24284a8 --- /dev/null +++ b/internal/http/handlers/requests_list/handler.go @@ -0,0 +1,60 @@ +package requests_list + +import ( + "context" + "encoding/base64" + "fmt" + "slices" + "strings" + + "github.com/google/uuid" + + "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" + "gh.tarampamp.am/webhook-tester/v2/internal/storage" +) + +type ( + sID = openapi.SessionUUIDInPath + + Handler struct{ db storage.Storage } +) + +func New(db storage.Storage) *Handler { return &Handler{db: db} } + +func (h *Handler) Handle(ctx context.Context, sID sID) (*openapi.CapturedRequestsListResponse, error) { + rList, lErr := h.db.GetAllRequests(ctx, sID.String()) + if lErr != nil { + return nil, lErr + } + + var list = make([]openapi.CapturedRequest, 0, len(rList)) + + for rID, r := range rList { + rUUID, pErr := uuid.Parse(rID) + if pErr != nil { + return nil, fmt.Errorf("failed to parse request UUID: %w", pErr) + } + + var rHeaders = make([]openapi.HttpHeader, len(r.Headers)) + for i, header := range r.Headers { + rHeaders[i].Name, rHeaders[i].Value = header.Name, header.Value + } + + list = append(list, openapi.CapturedRequest{ + CapturedAtUnixMilli: r.CreatedAtUnixMilli, + ClientAddress: r.ClientAddr, + Headers: rHeaders, + Method: strings.ToUpper(r.Method), + RequestPayloadBase64: base64.StdEncoding.EncodeToString(r.Body), + Url: r.URL, + Uuid: rUUID, + }) + + // sort the list by the captured time from newest to oldest + slices.SortFunc(list, func(a, b openapi.CapturedRequest) int { + return int(b.CapturedAtUnixMilli - a.CapturedAtUnixMilli) + }) + } + + return &list, nil +} diff --git a/internal/http/handlers/requests_subscribe/handler.go b/internal/http/handlers/requests_subscribe/handler.go new file mode 100644 index 00000000..12e47744 --- /dev/null +++ b/internal/http/handlers/requests_subscribe/handler.go @@ -0,0 +1,141 @@ +package requests_subscribe + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" + + "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" + "gh.tarampamp.am/webhook-tester/v2/internal/pubsub" + "gh.tarampamp.am/webhook-tester/v2/internal/storage" +) + +type ( + sID = openapi.SessionUUIDInPath + + Handler struct { + db storage.Storage + sub pubsub.Subscriber[pubsub.CapturedRequest] + upgrader websocket.Upgrader + } +) + +func New(db storage.Storage, sub pubsub.Subscriber[pubsub.CapturedRequest]) *Handler { + return &Handler{db: db, sub: sub} +} + +func (h *Handler) Handle(ctx context.Context, w http.ResponseWriter, r *http.Request, sID sID) error { + if _, err := h.db.GetSession(ctx, sID.String()); err != nil { + return fmt.Errorf("failed to get the session: %w", err) + } + + // upgrade the connection to the WebSocket + ws, upgErr := h.upgrader.Upgrade(w, r, http.Header{}) + if upgErr != nil { + return fmt.Errorf("failed to upgrade the connection: %w", upgErr) + } + + defer func() { _ = ws.Close() }() + + // create a new context for the request + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // uncomment to debug the ping/pong messages + // ws.SetPongHandler(func(appData string) error { fmt.Println(">>> pong", appData); return nil }) + + sub, unsubscribe, err := h.sub.Subscribe(ctx, sID.String()) + if err != nil { + return fmt.Errorf("failed to subscribe to the captured requests for the session %s: %w", sID.String(), err) + } + + defer unsubscribe() + + // read messages from the client in a separate goroutine and cancel the context when the connection is closed or + // an error occurs + go func() { defer cancel(); _ = h.reader(ctx, ws) }() + + // run a loop that sends routing updates to the client and pings the client periodically + return h.writer(ctx, ws, sub) +} + +// reader is a function that reads messages from the client. It must be run in a separate goroutine to prevent +// blocking. This function will exit when the context is canceled, the client closes the connection, or an error +// during the reading occurs. +func (*Handler) reader(ctx context.Context, ws *websocket.Conn) error { + for { + if ctx.Err() != nil { // check if the context is canceled + return nil + } + + var messageType, msgReader, msgErr = ws.NextReader() // TODO: is there any way to avoid locking without context? + if msgErr != nil { + return msgErr + } + + if msgReader != nil { + _, _ = io.Copy(io.Discard, msgReader) // ignore the message body but read it to prevent potential memory leaks + } + + if messageType == websocket.CloseMessage { + return nil // client closed the connection + } + } +} + +// writer is a function that writes messages to the client. It may NOT be run in a separate goroutine because it +// will block until the context is canceled, the client closes the connection, or an error during the writing occurs. +// +// This function sends the captured requests to the client and pings the client periodically. +func (h *Handler) writer(ctx context.Context, ws *websocket.Conn, sub <-chan pubsub.CapturedRequest) error { + const pingInterval, pingDeadline = 10 * time.Second, 5 * time.Second + + // create a ticker for the ping messages + var pingTicker = time.NewTicker(pingInterval) + defer pingTicker.Stop() + + for { + select { + case <-ctx.Done(): // check if the context is canceled + return nil + + case r, isOpened := <-sub: // wait for the captured requests + if !isOpened { + return nil // this should never happen, but just in case + } + + rID, pErr := uuid.Parse(r.ID) + if pErr != nil { + continue + } + + var rHeaders = make([]openapi.HttpHeader, len(r.Headers)) + for i, header := range r.Headers { + rHeaders[i].Name, rHeaders[i].Value = header.Name, header.Value + } + + // write the response to the client + if err := ws.WriteJSON(openapi.CapturedRequest{ + CapturedAtUnixMilli: r.CreatedAtUnixMilli, + ClientAddress: r.ClientAddr, + Headers: rHeaders, + Method: strings.ToUpper(r.Method), + Url: r.URL, + Uuid: rID, + }); err != nil { + return fmt.Errorf("failed to write the message: %w", err) + } + + case <-pingTicker.C: // send ping messages to the client + if err := ws.WriteControl(websocket.PingMessage, nil, time.Now().Add(pingDeadline)); err != nil { + return fmt.Errorf("failed to send the ping message: %w", err) + } + } + } +} diff --git a/internal/http/handlers/session_create/handler.go b/internal/http/handlers/session_create/handler.go new file mode 100644 index 00000000..ac1802d4 --- /dev/null +++ b/internal/http/handlers/session_create/handler.go @@ -0,0 +1,65 @@ +package session_create + +import ( + "context" + "encoding/base64" + "fmt" + "time" + + "github.com/google/uuid" + + "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" + "gh.tarampamp.am/webhook-tester/v2/internal/storage" +) + +type Handler struct{ db storage.Storage } + +func New(db storage.Storage) *Handler { return &Handler{db: db} } + +func (h *Handler) Handle(ctx context.Context, p openapi.CreateSessionRequest) (*openapi.SessionOptionsResponse, error) { + var sHeaders = make([]storage.HttpHeader, len(p.Headers)) + for i, header := range p.Headers { + sHeaders[i] = storage.HttpHeader{Name: header.Name, Value: header.Value} + } + + var responseBody, decErr = base64.StdEncoding.DecodeString(p.ResponseBodyBase64) + if decErr != nil { + return nil, fmt.Errorf("cannot decode response body (wrong base64): %w", decErr) + } + + sID, sErr := h.db.NewSession(ctx, storage.Session{ + Code: uint16(p.StatusCode), //nolint:gosec + Headers: sHeaders, + ResponseBody: responseBody, + Delay: time.Second * time.Duration(p.Delay), + }) + if sErr != nil { + return nil, fmt.Errorf("failed to create a new session: %w", sErr) + } + + sess, sErr := h.db.GetSession(ctx, sID) + if sErr != nil { + return nil, fmt.Errorf("failed to get session: %w", sErr) + } + + sUUID, pErr := uuid.Parse(sID) + if pErr != nil { + return nil, fmt.Errorf("failed to parse session UUID: %w", pErr) + } + + var rHeaders = make([]openapi.HttpHeader, len(sess.Headers)) + for i, header := range sess.Headers { + rHeaders[i].Name, rHeaders[i].Value = header.Name, header.Value + } + + return &openapi.SessionOptionsResponse{ + CreatedAtUnixMilli: sess.CreatedAtUnixMilli, + Response: openapi.SessionResponseOptions{ + Delay: uint16(sess.Delay.Seconds()), + Headers: rHeaders, + ResponseBodyBase64: base64.StdEncoding.EncodeToString(sess.ResponseBody), + StatusCode: openapi.StatusCode(sess.Code), + }, + Uuid: sUUID, + }, nil +} diff --git a/internal/http/handlers/session_delete/handler.go b/internal/http/handlers/session_delete/handler.go new file mode 100644 index 00000000..a0164166 --- /dev/null +++ b/internal/http/handlers/session_delete/handler.go @@ -0,0 +1,24 @@ +package session_delete + +import ( + "context" + + "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" + "gh.tarampamp.am/webhook-tester/v2/internal/storage" +) + +type ( + sID = openapi.SessionUUIDInPath + + Handler struct{ db storage.Storage } +) + +func New(db storage.Storage) *Handler { return &Handler{db: db} } + +func (h *Handler) Handle(ctx context.Context, sID sID) (*openapi.SuccessfulOperationResponse, error) { + if err := h.db.DeleteSession(ctx, sID.String()); err != nil { + return nil, err + } + + return &openapi.SuccessfulOperationResponse{Success: true}, nil +} diff --git a/internal/http/handlers/session_get/handler.go b/internal/http/handlers/session_get/handler.go new file mode 100644 index 00000000..4f8261eb --- /dev/null +++ b/internal/http/handlers/session_get/handler.go @@ -0,0 +1,41 @@ +package session_get + +import ( + "context" + "encoding/base64" + "fmt" + + "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" + "gh.tarampamp.am/webhook-tester/v2/internal/storage" +) + +type ( + sID = openapi.SessionUUIDInPath + + Handler struct{ db storage.Storage } +) + +func New(db storage.Storage) *Handler { return &Handler{db: db} } + +func (h *Handler) Handle(ctx context.Context, sID sID) (*openapi.SessionOptionsResponse, error) { + sess, sErr := h.db.GetSession(ctx, sID.String()) + if sErr != nil { + return nil, fmt.Errorf("failed to get session: %w", sErr) + } + + var sHeaders = make([]openapi.HttpHeader, len(sess.Headers)) + for i, header := range sess.Headers { + sHeaders[i].Name, sHeaders[i].Value = header.Name, header.Value + } + + return &openapi.SessionOptionsResponse{ + CreatedAtUnixMilli: sess.CreatedAtUnixMilli, + Response: openapi.SessionResponseOptions{ + Delay: uint16(sess.Delay.Seconds()), + Headers: sHeaders, + ResponseBodyBase64: base64.StdEncoding.EncodeToString(sess.ResponseBody), + StatusCode: openapi.StatusCode(sess.Code), + }, + Uuid: sID, + }, nil +} diff --git a/internal/http/handlers/settings_get/handler.go b/internal/http/handlers/settings_get/handler.go new file mode 100644 index 00000000..c5acaed3 --- /dev/null +++ b/internal/http/handlers/settings_get/handler.go @@ -0,0 +1,18 @@ +package settings_get + +import ( + "gh.tarampamp.am/webhook-tester/v2/internal/config" + "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" +) + +type Handler struct{ cfg config.AppSettings } + +func New(s config.AppSettings) *Handler { return &Handler{cfg: s} } + +func (h *Handler) Handle() (resp openapi.SettingsResponse) { + resp.Limits.MaxRequestBodySize = h.cfg.MaxRequestBodySize + resp.Limits.MaxRequests = h.cfg.MaxRequests + resp.Limits.SessionTtl = uint32(h.cfg.SessionTTL.Seconds()) + + return +} diff --git a/internal/http/handlers/version/handler.go b/internal/http/handlers/version/handler.go new file mode 100644 index 00000000..2d16fe77 --- /dev/null +++ b/internal/http/handlers/version/handler.go @@ -0,0 +1,9 @@ +package version + +import "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" + +type Handler struct{ ver string } + +func New(ver string) *Handler { return &Handler{ver: ver} } + +func (h *Handler) Handle() openapi.VersionResponse { return openapi.VersionResponse{Version: h.ver} } diff --git a/internal/http/handlers/version_latest/handler.go b/internal/http/handlers/version_latest/handler.go new file mode 100644 index 00000000..aff45ef9 --- /dev/null +++ b/internal/http/handlers/version_latest/handler.go @@ -0,0 +1,53 @@ +package version_latest + +import ( + "context" + "fmt" + "net/http" + "sync" + "time" + + "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" +) + +type ( + versionFetcher func(context.Context) (string, error) + + Handler struct { + mu sync.Mutex // protects the fields below + updatedAt time.Time + cache string + + fetcher versionFetcher + } +) + +func New(fetcher versionFetcher) *Handler { return &Handler{fetcher: fetcher} } + +func (h *Handler) Handle(ctx context.Context, w http.ResponseWriter) (*openapi.VersionResponse, error) { + const cacheTTL, cacheHitHeader = 5 * time.Minute, "X-Cache" + + h.mu.Lock() + defer h.mu.Unlock() + + // check if the cache is still valid + if time.Since(h.updatedAt) < cacheTTL && h.cache != "" { + w.Header().Set(cacheHitHeader, "HIT") + + // return the cached value + return &openapi.VersionResponse{Version: h.cache}, nil + } + + w.Header().Set(cacheHitHeader, "MISS") + + // fetch the latest version + version, fetchErr := h.fetcher(ctx) + if fetchErr != nil { + return nil, fmt.Errorf("failed to fetch the latest version: %w", fetchErr) + } + + // update the cache and the timestamp + h.updatedAt, h.cache = time.Now(), version + + return &openapi.VersionResponse{Version: version}, nil +} diff --git a/internal/http/ip_extractor.go b/internal/http/ip_extractor.go deleted file mode 100644 index 3a2ae5dc..00000000 --- a/internal/http/ip_extractor.go +++ /dev/null @@ -1,39 +0,0 @@ -package http - -import ( - "net" - "net/http" - "strings" - - "github.com/labstack/echo/v4" -) - -func NewIPExtractor() echo.IPExtractor { - // we will trust following HTTP headers for the real ip extracting (priority low -> high). - var trustHeaders = [...]string{"X-Forwarded-For", "X-Real-IP", "CF-Connecting-IP"} - - return func(r *http.Request) string { - var ip string - - for _, name := range trustHeaders { - if value := r.Header.Get(name); value != "" { - // `X-Forwarded-For` can be `10.0.0.1, 10.0.0.2, 10.0.0.3` - if strings.Contains(value, ",") { - parts := strings.Split(value, ",") - - if len(parts) > 0 { - ip = strings.TrimSpace(parts[0]) - } - } else { - ip = strings.TrimSpace(value) - } - } - } - - if net.ParseIP(ip) != nil { - return ip - } - - return strings.Split(r.RemoteAddr, ":")[0] - } -} diff --git a/internal/http/ip_extractor_test.go b/internal/http/ip_extractor_test.go deleted file mode 100644 index c368c83d..00000000 --- a/internal/http/ip_extractor_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package http_test - -import ( - "net/http" - "testing" - - "github.com/stretchr/testify/assert" - - appHttp "gh.tarampamp.am/webhook-tester/internal/http" -) - -func TestNewIPExtractor(t *testing.T) { - var extractor = appHttp.NewIPExtractor() - - for name, tt := range map[string]struct { - giveRequest func() *http.Request - wantIP string - }{ - "IP from remote addr": { - giveRequest: func() *http.Request { - req, _ := http.NewRequest(http.MethodGet, "http://testing", nil) - req.RemoteAddr = "4.3.2.1:567" - - return req - }, - wantIP: "4.3.2.1", - }, - "IP from 'CF-Connecting-IP' header": { - giveRequest: func() *http.Request { - req, _ := http.NewRequest(http.MethodGet, "http://testing", nil) - req.RemoteAddr = "4.4.4.4:567" - req.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2, 10.0.0.3") - req.Header.Set("X-Real-IP", "10.0.1.1") - req.Header.Set("CF-Connecting-IP", "10.1.1.1") - - return req - }, - wantIP: "10.1.1.1", - }, - "IP from 'X-Real-IP' header": { - giveRequest: func() *http.Request { - req, _ := http.NewRequest(http.MethodGet, "http://testing", nil) - req.RemoteAddr = "8.8.8.8:567" - req.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2, 10.0.0.3") - req.Header.Set("X-Real-IP", "10.0.1.1") - - return req - }, - wantIP: "10.0.1.1", - }, - "IP from 'X-Forwarded-For' header": { - giveRequest: func() *http.Request { - req, _ := http.NewRequest(http.MethodGet, "http://testing", nil) - req.RemoteAddr = "1.2.3.4:567" - req.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2, 10.0.0.3") - - return req - }, - wantIP: "10.0.0.1", - }, - } { - t.Run(name, func(t *testing.T) { assert.EqualValues(t, tt.wantIP, extractor(tt.giveRequest())) }) - } -} diff --git a/internal/http/middleware/logreq/middleware.go b/internal/http/middleware/logreq/middleware.go new file mode 100644 index 00000000..4783cc66 --- /dev/null +++ b/internal/http/middleware/logreq/middleware.go @@ -0,0 +1,59 @@ +package logreq + +import ( + "net/http" + "time" + + "go.uber.org/zap" +) + +// New creates a middleware for [http.ServeMux] that logs every incoming request. +// +// The skipper function should return true if the request should be skipped. It's ok to pass nil. +func New(log *zap.Logger, skipper func(*http.Request) bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if skipper != nil && skipper(r) { + next.ServeHTTP(w, r) + + return + } + + var now = time.Now() + + defer func() { + const requestIdHeader, traceIdHeader = "X-Request-Id", "X-Trace-Id" + + var fields = []zap.Field{ + zap.String("useragent", r.UserAgent()), + zap.String("host", r.Host), + zap.String("method", r.Method), + zap.String("url", r.URL.String()), + zap.String("content type", w.Header().Get("Content-Type")), + zap.String("remote addr", r.RemoteAddr), + zap.String("method", r.Method), + zap.Duration("duration", time.Since(now).Round(time.Microsecond)), + } + + if id := r.Header.Get(requestIdHeader); id != "" { + fields = append(fields, zap.String("request id", id)) + } + + if id := w.Header().Get(traceIdHeader); id != "" { + fields = append(fields, zap.String("trace id", id)) + } + + if log.Level() <= zap.DebugLevel { + fields = append(fields, + zap.Any("request headers", r.Header.Clone()), + zap.Any("response headers", w.Header().Clone()), + ) + } + + log.Info("HTTP request processed", fields...) + }() + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/http/middleware/webhook/middleware.go b/internal/http/middleware/webhook/middleware.go new file mode 100644 index 00000000..f6db6d88 --- /dev/null +++ b/internal/http/middleware/webhook/middleware.go @@ -0,0 +1,303 @@ +package webhook + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "slices" + "strconv" + "strings" + "time" + + "go.uber.org/zap" + + "gh.tarampamp.am/webhook-tester/v2/internal/config" + "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" + "gh.tarampamp.am/webhook-tester/v2/internal/pubsub" + "gh.tarampamp.am/webhook-tester/v2/internal/storage" +) + +func New( //nolint:funlen,gocognit,gocyclo + appCtx context.Context, + log *zap.Logger, + db storage.Storage, + pub pubsub.Publisher[pubsub.CapturedRequest], + cfg config.AppSettings, +) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var sID, doIt = shouldCaptureRequest(r) + if !doIt { + next.ServeHTTP(w, r) + + return + } + + var reqCtx = r.Context() + + // get the session from the storage + sess, sErr := db.GetSession(reqCtx, sID) //nolint:contextcheck + if sErr != nil { //nolint:nestif + // if the session is not found + if errors.Is(sErr, storage.ErrNotFound) { + // but the auto-creation is enabled + if cfg.AutoCreateSessions { + // create a new session with some default values + if _, err := db.NewSession(reqCtx, storage.Session{ //nolint:contextcheck + Code: http.StatusOK, + }, sID); err != nil { + respondWithError(w, log, http.StatusInternalServerError, err.Error()) + + return + } else { + // and try to get it again + if sess, sErr = db.GetSession(reqCtx, sID); sErr != nil { //nolint:contextcheck + respondWithError(w, log, http.StatusInternalServerError, sErr.Error()) + + return + } else { + // add the header to indicate that the session has been created automatically + w.Header().Set("X-Wh-Created-Automatically", "1") + } + } + } else { + respondWithError(w, log, http.StatusNotFound, "The webhook has not been created yet or may have expired") + + return + } + } else { + respondWithError(w, log, http.StatusInternalServerError, sErr.Error()) + + return + } + } + + { // increase the session lifetime + var delta = time.Now().Add(cfg.SessionTTL).Sub(time.Unix(0, sess.CreatedAtUnixMilli*int64(time.Millisecond))) + + if err := db.AddSessionTTL(reqCtx, sID, delta); err != nil { //nolint:contextcheck + respondWithError(w, log, http.StatusInternalServerError, err.Error()) + + return + } + } + + // read the request body + var body []byte + + if r.Body != nil { + if b, err := io.ReadAll(r.Body); err == nil { + body = b + } + } + + // check the request body size and respond with an error if it's too large + if cfg.MaxRequestBodySize > 0 && uint32(len(body)) > cfg.MaxRequestBodySize { //nolint:gosec + respondWithError(w, log, + http.StatusRequestEntityTooLarge, + fmt.Sprintf("The request body is too large (current: %d, max: %d)", len(body), cfg.MaxRequestBodySize), + ) + + return + } + + // convert request headers into the storage format + var rHeaders = make([]storage.HttpHeader, 0, len(r.Header)) + for name, value := range r.Header { + rHeaders = append(rHeaders, storage.HttpHeader{Name: name, Value: strings.Join(value, "; ")}) + } + + // sort headers by name + slices.SortFunc(rHeaders, func(i, j storage.HttpHeader) int { return strings.Compare(i.Name, j.Name) }) + + // and save the request to the storage + rID, rErr := db.NewRequest(reqCtx, sID, storage.Request{ //nolint:contextcheck + ClientAddr: extractRealIP(r), + Method: r.Method, + Body: body, + Headers: rHeaders, + URL: extractFullUrl(r), + }) + if rErr != nil { + respondWithError(w, log, http.StatusInternalServerError, rErr.Error()) + + return + } + + // publish the captured request to the pub/sub. important note - we should use the app ctx instead of the req ctx + // because the request context can be canceled before the goroutine finishes (and moreover - before the + // subscribers will receive the event - in this case the event will be lost) + go func() { + // read the actual data from the storage (the main point is the time of creation) + captured, dbErr := db.GetRequest(appCtx, sID, rID) + if dbErr != nil { + log.Error("failed to get a captured request", zap.Error(dbErr)) + + return + } + + var headers = make([]pubsub.HttpHeader, len(captured.Headers)) + for i, h := range captured.Headers { + headers[i] = pubsub.HttpHeader{Name: h.Name, Value: h.Value} + } + + if err := pub.Publish(appCtx, sID, pubsub.CapturedRequest{ + ID: rID, + ClientAddr: captured.ClientAddr, + Method: captured.Method, + Headers: headers, + URL: captured.URL, + CreatedAtUnixMilli: captured.CreatedAtUnixMilli, + }); err != nil { + log.Error("failed to publish a captured request", zap.Error(err)) + } + }() + + // wait for the delay if it's set + if sess.Delay > 0 { + sleep(reqCtx, sess.Delay) //nolint:contextcheck + } + + // set the header to allow CORS requests from any origin and method + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "*") + w.Header().Set("Access-Control-Allow-Headers", "*") + + // set the session headers + for _, h := range sess.Headers { + w.Header().Set(h.Name, h.Value) + } + + // by default, use the status code from the session + var statusCode = int(sess.Code) + + // extract requested status code from the request URL (it should be the last part) + if parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/"); len(parts) > 1 { + // loop over parts slice from the end to the beginning + for i := len(parts) - 1; i >= 0; i-- { + if code, err := strconv.Atoi(parts[i]); err == nil && code >= 100 && code <= 599 { + statusCode = code + + break + } + } + } + + // set the status code + w.WriteHeader(statusCode) + + // write the response body + if _, err := w.Write(sess.ResponseBody); err != nil { + log.Error("failed to write the response body", zap.Error(err)) + } + }) + } +} + +// shouldCaptureRequest checks if the request should be captured (the path starts with a valid UUID). +func shouldCaptureRequest(r *http.Request) (string, bool) { + if r.URL == nil { + return "", false + } + + var clean = strings.TrimLeft(r.URL.Path, "/") + + if len(clean) >= openapi.UUIDLength && openapi.IsValidUUID(clean[:openapi.UUIDLength]) { + return clean[:openapi.UUIDLength], true + } + + return "", false +} + +// TODO: add supporting of format requested by the user (json, html, plain text, etc). +func respondWithError(w http.ResponseWriter, log *zap.Logger, code int, msg string) { + var s strings.Builder + + s.Grow(1024) //nolint:mnd + + s.WriteString(` + + + + + + `) + s.WriteString(http.StatusText(code)) + s.WriteString(` + + + +
+

WebHook: `) + s.WriteString(http.StatusText(code)) + s.WriteString(`

+

`) + s.WriteString(msg) + s.WriteString(`

+
+ +`) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Content-Length", strconv.Itoa(s.Len())) + w.WriteHeader(code) + + if _, err := w.Write([]byte(s.String())); err != nil { + log.Error("failed to respond with an error", zap.Error(err), zap.Int("code", code), zap.String("msg", msg)) + } +} + +// extractFullUrl returns the full URL from the request. +func extractFullUrl(r *http.Request) string { + var scheme = "http" + if r.TLS != nil { + scheme = "https" + } + + return fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI) +} + +// we will trust following HTTP headers for the real ip extracting (priority low -> high). +var trustHeaders = [...]string{"X-Forwarded-For", "X-Real-IP", "CF-Connecting-IP"} //nolint:gochecknoglobals + +func extractRealIP(r *http.Request) string { + var ip string + + for _, name := range trustHeaders { + if value := r.Header.Get(name); value != "" { + // `X-Forwarded-For` can be `10.0.0.1, 10.0.0.2, 10.0.0.3` + if strings.Contains(value, ",") { + parts := strings.Split(value, ",") + + if len(parts) > 0 { + ip = strings.TrimSpace(parts[0]) + } + } else { + ip = strings.TrimSpace(value) + } + } + } + + if net.ParseIP(ip) != nil { + return ip + } + + return strings.Split(r.RemoteAddr, ":")[0] +} + +func sleep(ctx context.Context, d time.Duration) { + var timer = time.NewTimer(d) + defer timer.Stop() + + select { + case <-ctx.Done(): + case <-timer.C: + } +} diff --git a/internal/http/middlewares/logreq/logreq.go b/internal/http/middlewares/logreq/logreq.go deleted file mode 100644 index 2bdf1315..00000000 --- a/internal/http/middlewares/logreq/logreq.go +++ /dev/null @@ -1,46 +0,0 @@ -// Package logreq contains middleware for HTTP requests logging using "zap" package. -package logreq - -import ( - "strings" - - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "go.uber.org/zap" -) - -// New creates echo.MiddlewareFunc for HTTP requests logging using "zap" package. -func New(log *zap.Logger, dbgRoutesPrefixes []string) echo.MiddlewareFunc { - return middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{ - LogUserAgent: true, - LogMethod: true, - LogStatus: true, - LogLatency: true, - - LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error { - var logLvl = zap.InfoLevel // default logging level - - // log the following routes only for debug level - for _, s := range dbgRoutesPrefixes { - if strings.HasPrefix(c.Request().URL.Path, s) { - logLvl = zap.DebugLevel - - break - } - } - - if ce := log.Check(logLvl, "HTTP request processed"); ce != nil { - ce.Write( - zap.String("remote addr", c.RealIP()), - zap.String("useragent", v.UserAgent), - zap.String("method", v.Method), - zap.String("uri", c.Request().URL.String()), - zap.Int("status code", v.Status), - zap.Duration("duration", v.Latency), - ) - } - - return nil - }, - }) -} diff --git a/internal/http/middlewares/logreq/logreq_test.go b/internal/http/middlewares/logreq/logreq_test.go deleted file mode 100644 index f80b9514..00000000 --- a/internal/http/middlewares/logreq/logreq_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package logreq_test - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/kami-zh/go-capturer" - "github.com/labstack/echo/v4" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/zap" - - "gh.tarampamp.am/webhook-tester/internal/http/middlewares/logreq" -) - -func TestNew(t *testing.T) { - e := echo.New() - - for name, tt := range map[string]struct { - giveRequest func() *http.Request - giveHandler echo.HandlerFunc - giveDbgRoutesPrefixes []string - wantOutput bool - checkOutputFields func(t *testing.T, in map[string]any) - }{ - "basic usage": { - giveHandler: func(c echo.Context) error { - time.Sleep(time.Millisecond) - - return c.NoContent(http.StatusUnsupportedMediaType) - }, - giveRequest: func() (req *http.Request) { - req, _ = http.NewRequest(http.MethodGet, "http://unit/test/?foo=bar&baz", http.NoBody) - req.RemoteAddr = "4.3.2.1:567" - req.Header.Set("User-Agent", "Foo Useragent") - - return - }, - wantOutput: true, - checkOutputFields: func(t *testing.T, in map[string]any) { - assert.Equal(t, http.MethodGet, in["method"]) - assert.NotZero(t, in["duration"]) - assert.Equal(t, "info", in["level"]) - assert.Contains(t, in["msg"], "processed") - assert.Equal(t, "4.3.2.1", in["remote addr"]) - assert.Equal(t, float64(http.StatusUnsupportedMediaType), in["status code"]) - assert.Equal(t, "http://unit/test/?foo=bar&baz", in["uri"]) - assert.Equal(t, "Foo Useragent", in["useragent"]) - }, - }, - "IP from 'X-Forwarded-For' header": { - giveHandler: func(c echo.Context) error { - return c.NoContent(http.StatusOK) - }, - giveRequest: func() (req *http.Request) { - req, _ = http.NewRequest(http.MethodGet, "http://testing", http.NoBody) - req.RemoteAddr = "1.2.3.4:567" - req.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2, 10.0.0.3") - - return - }, - wantOutput: true, - checkOutputFields: func(t *testing.T, in map[string]any) { - assert.Equal(t, "10.0.0.1", in["remote addr"]) - }, - }, - "prefix skipped": { - giveDbgRoutesPrefixes: []string{"/foo_bar"}, - giveHandler: func(c echo.Context) error { - return c.NoContent(http.StatusOK) - }, - giveRequest: func() (req *http.Request) { - req, _ = http.NewRequest(http.MethodGet, "http://test/foo_bar/?foo=bar&baz", http.NoBody) - req.Header.Set("User-Agent", "HealthCheck/Internal") - - return - }, - wantOutput: false, - }, - } { - t.Run(name, func(t *testing.T) { - var ( - rr = httptest.NewRecorder() - c = e.NewContext(tt.giveRequest(), rr) - ) - - output := capturer.CaptureStderr(func() { - log, err := zap.NewProduction() - require.NoError(t, err) - - err = logreq.New(log, tt.giveDbgRoutesPrefixes)(tt.giveHandler)(c) - assert.NoError(t, err) - }) - - if tt.wantOutput { - var asJSON map[string]any - - assert.NoError(t, json.Unmarshal([]byte(output), &asJSON), "logger output must be valid JSON") - - tt.checkOutputFields(t, asJSON) - } else { - assert.Empty(t, output) - } - }) - } -} diff --git a/internal/http/middlewares/panic/panic.go b/internal/http/middlewares/panic/panic.go deleted file mode 100644 index b44acf7e..00000000 --- a/internal/http/middlewares/panic/panic.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package panic contains middleware for panics (inside HTTP handlers) logging using "zap" package. -package panic - -import ( - "fmt" - "net/http" - "runtime" - - "github.com/labstack/echo/v4" - "go.uber.org/zap" -) - -type response struct { - Message string `json:"message"` - Code int `json:"code"` -} - -const statusCode = http.StatusInternalServerError - -// New creates mux.MiddlewareFunc for panics (inside HTTP handlers) logging using "zap" package. Also it allows -// to respond with JSON-formatted error string instead empty response. -func New(log *zap.Logger) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - defer func() { - if rec := recover(); rec != nil { - // convert panic reason into error - err, ok := rec.(error) - if !ok { - err = fmt.Errorf("%v", rec) - } - - stackBuf := make([]byte, 1024) //nolint:mnd - - // do NOT use `debug.Stack()` here for skipping one unimportant call trace in stacktrace - for { - n := runtime.Stack(stackBuf, false) - if n < len(stackBuf) { - stackBuf = stackBuf[:n] - - break - } - - stackBuf = make([]byte, 2*len(stackBuf)) //nolint:mnd - } - - // log error with logger - log.Error("HTTP handler panic", zap.Error(err), zap.String("stacktrace", string(stackBuf))) - - if respErr := c.JSON(statusCode, response{ // and respond with JSON (not "empty response") - Message: fmt.Sprintf("%s: %s", http.StatusText(statusCode), err.Error()), - Code: statusCode, - }); respErr != nil { - panic(respErr) - } - } - }() - - return next(c) - } - } -} diff --git a/internal/http/middlewares/panic/panic_test.go b/internal/http/middlewares/panic/panic_test.go deleted file mode 100644 index e00c9b43..00000000 --- a/internal/http/middlewares/panic/panic_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package panic_test - -import ( - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "testing" - - "github.com/kami-zh/go-capturer" - "github.com/labstack/echo/v4" - "github.com/stretchr/testify/assert" - "go.uber.org/zap" - - panicMiddlewares "gh.tarampamp.am/webhook-tester/internal/http/middlewares/panic" -) - -func TestMiddleware(t *testing.T) { - e := echo.New() - - for name, tt := range map[string]struct { - giveHandler echo.HandlerFunc - giveRequest func() *http.Request - checkResult func(t *testing.T, in map[string]any, rr *httptest.ResponseRecorder) - }{ - "panic with error": { - giveHandler: func(c echo.Context) error { - panic(errors.New("foo error")) - }, - giveRequest: func() *http.Request { - rq, _ := http.NewRequest(http.MethodGet, "http://testing/foo/bar", http.NoBody) - - return rq - }, - checkResult: func(t *testing.T, in map[string]any, rr *httptest.ResponseRecorder) { - // check log entry - assert.Equal(t, "foo error", in["error"]) - assert.Contains(t, in["stacktrace"], "/panic.go:") - assert.Contains(t, in["stacktrace"], ".TestMiddleware") - - // check HTTP response - wantJSON, err := json.Marshal(struct { - Message string `json:"message"` - Code int `json:"code"` - }{ - Message: "Internal Server Error: foo error", - Code: http.StatusInternalServerError, - }) - assert.NoError(t, err) - - assert.Equal(t, http.StatusInternalServerError, rr.Code) - assert.JSONEq(t, string(wantJSON), rr.Body.String()) - }, - }, - "panic with string": { - giveHandler: func(c echo.Context) error { - panic("bar error") - }, - giveRequest: func() *http.Request { - rq, _ := http.NewRequest(http.MethodGet, "http://testing/foo/bar", http.NoBody) - - return rq - }, - checkResult: func(t *testing.T, in map[string]any, rr *httptest.ResponseRecorder) { - assert.Equal(t, "bar error", in["error"]) - }, - }, - } { - t.Run(name, func(t *testing.T) { - var ( - rr = httptest.NewRecorder() - c = e.NewContext(tt.giveRequest(), rr) - ) - - output := capturer.CaptureStderr(func() { - log, err := zap.NewProduction() - assert.NoError(t, err) - - err = panicMiddlewares.New(log)(tt.giveHandler)(c) - assert.NoError(t, err) - }) - - var asJSON map[string]any - - assert.NoError(t, json.Unmarshal([]byte(output), &asJSON), "logger output must be valid JSON") - - tt.checkResult(t, asJSON, rr) - }) - } -} diff --git a/internal/http/middlewares/webhook/webhook.go b/internal/http/middlewares/webhook/webhook.go deleted file mode 100644 index 2a5fb543..00000000 --- a/internal/http/middlewares/webhook/webhook.go +++ /dev/null @@ -1,188 +0,0 @@ -package webhook - -import ( - "context" - "fmt" - "io" - "net/http" - "strconv" - "strings" - "time" - - "github.com/google/uuid" - "github.com/labstack/echo/v4" - - "gh.tarampamp.am/webhook-tester/internal/config" - "gh.tarampamp.am/webhook-tester/internal/pubsub" - "gh.tarampamp.am/webhook-tester/internal/storage" -) - -type webhookMetrics interface { - IncrementProcessedWebHooks() -} - -func New( //nolint:funlen,gocognit,gocyclo - ctx context.Context, - cfg config.Config, - storage storage.Storage, - pub pubsub.Publisher, - metrics webhookMetrics, -) echo.MiddlewareFunc { - var ignoreHeaderPrefixes = make([]string, len(cfg.IgnoreHeaderPrefixes)) - - for i := range cfg.IgnoreHeaderPrefixes { - ignoreHeaderPrefixes[i] = strings.ToUpper(strings.TrimSpace(cfg.IgnoreHeaderPrefixes[i])) // normalize each - } - - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - // extract the first URL segment - if parts := strings.Split(strings.TrimLeft(c.Request().URL.RequestURI(), "/"), "/"); len(parts) > 0 { //nolint:nestif - if sessionUuidStruct, uuidErr := uuid.Parse(parts[0]); uuidErr == nil { // and check it's a valid UUID - var ( - sessionUuid = sessionUuidStruct.String() - statusCode int - ) - - c.Response().Header().Set("Access-Control-Allow-Origin", "*") // allow cross-original requests - - if len(parts) == 2 && len(parts[1]) == 3 { // try to extract second URL segment as status code - if code, err := strconv.Atoi(parts[1]); err == nil && code >= 100 && code <= 599 { // and verify it too - statusCode = code - } - } - - session, err := storage.GetSession(sessionUuid) // read current session info - if err != nil { - return respondWithError(c, - http.StatusInternalServerError, - err.Error(), - ) - } - - if session == nil { // is the session exists? - return respondWithError(c, - http.StatusNotFound, - fmt.Sprintf("The session with UUID %s was not found", sessionUuid), - ) - } - - if statusCode == 0 { - statusCode = int(session.Code()) - } - - var body []byte // for request body - - if rb := c.Request().Body; rb != nil { - if body, err = io.ReadAll(rb); err != nil { - return respondWithError(c, http.StatusInternalServerError, err.Error()) - } - } - - if cfg.MaxRequestBodySize > 0 && len(body) > int(cfg.MaxRequestBodySize) { // check the body size - return respondWithError(c, - http.StatusInternalServerError, - fmt.Sprintf("Request body is too large (current: %d, maximal: %d)", len(body), cfg.MaxRequestBodySize), - ) - } - - var requestUuid string - - if requestUuid, err = storage.CreateRequest( // store request in a storage - sessionUuid, - c.RealIP(), - c.Request().Method, - c.Request().URL.RequestURI(), - body, - headersToStringsMap(c.Request().Header, ignoreHeaderPrefixes), - ); err != nil { - return respondWithError(c, - http.StatusInternalServerError, - fmt.Sprintf("Request saving in storage failed: %s", err.Error()), - ) - } - - go func() { - metrics.IncrementProcessedWebHooks() - - _ = pub.Publish(sessionUuid, pubsub.NewRequestRegisteredEvent(requestUuid)) - }() - - if delay := session.Delay(); delay > 0 { - timer := time.NewTimer(delay) - - select { - case <-ctx.Done(): - timer.Stop() - - return respondWithError(c, http.StatusInternalServerError, "canceled") - - case <-timer.C: - timer.Stop() - } - } - - return c.Blob(statusCode, session.ContentType(), session.Content()) - } - } - - return next(c) - } - } -} - -func respondWithError(c echo.Context, code int, msg string) error { - var s strings.Builder - - s.Grow(1024) - - s.WriteString(` - - - - - - `) - s.WriteString(http.StatusText(code)) - s.WriteString(` - - - -
-

WebHook: `) - s.WriteString(http.StatusText(code)) - s.WriteString(`

-

`) - s.WriteString(msg) - s.WriteString(`

-
- -`) - - return c.HTML(code, s.String()) -} - -func headersToStringsMap(header http.Header, ignorePrefixes []string) map[string]string { - result := make(map[string]string, len(header)) - -loop: - for name, values := range header { - if len(ignorePrefixes) > 0 { - upperName := strings.ToUpper(name) - - for i := range ignorePrefixes { - if strings.HasPrefix(upperName, ignorePrefixes[i]) { - continue loop - } - } - } - - result[name] = strings.Join(values, "; ") - } - - return result -} diff --git a/internal/http/middlewares/webhook/webhook_test.go b/internal/http/middlewares/webhook/webhook_test.go deleted file mode 100644 index 3c03d135..00000000 --- a/internal/http/middlewares/webhook/webhook_test.go +++ /dev/null @@ -1,296 +0,0 @@ -package webhook_test - -import ( - "bytes" - "context" - "io" - "net/http" - "net/http/httptest" - "runtime" - "strings" - "testing" - "time" - - "github.com/labstack/echo/v4" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "gh.tarampamp.am/webhook-tester/internal/config" - appHttp "gh.tarampamp.am/webhook-tester/internal/http" - "gh.tarampamp.am/webhook-tester/internal/http/middlewares/webhook" - "gh.tarampamp.am/webhook-tester/internal/pubsub" - "gh.tarampamp.am/webhook-tester/internal/storage" -) - -type fakeMetrics struct { - c int -} - -func (f *fakeMetrics) IncrementProcessedWebHooks() { f.c++ } - -const testBaseUri = "http://test/" - -func BenchmarkHandler(b *testing.B) { - b.ReportAllocs() - - s := storage.NewInMemory(time.Minute, 10) - defer s.Close() - - ps := pubsub.NewInMemory() - defer ps.Close() - - var ( - e = echo.New() - sessionUUID, _ = s.CreateSession([]byte("foo"), 202, "foo/bar", 0) - req, _ = http.NewRequest(http.MethodPut, testBaseUri+sessionUUID+"/222?foo=bar#anchor", http.NoBody) - rr = httptest.NewRecorder() - c = e.NewContext(req, rr) - - h = webhook.New(context.Background(), config.Config{ - IgnoreHeaderPrefixes: []string{"bar", "baz"}, - }, s, ps, &fakeMetrics{}) - ) - - req.Header.Set("foo", "blah") - req.Header.Set("X-Forwarded-For", "4.4.4.4") - req.Header.Set("X-Forwarded-For1", "4.4.4.4") - - for n := 0; n < b.N; n++ { - require.NoError(b, h(func(c echo.Context) error { - return c.NoContent(http.StatusOK) - })(c)) - } -} - -func TestHandler_RequestErrors(t *testing.T) { - s := storage.NewInMemory(time.Minute, 10) - defer s.Close() - - sessionUUID, _ := s.CreateSession([]byte("foo"), 202, "foo/bar", 0) - - ps := pubsub.NewInMemory() - defer ps.Close() - - for name, tt := range map[string]struct { - giveBody io.Reader - giveUrl string - wantStatusCode int - wantSubstring []string - }{ - "with broken session UUID format (middleware should be skipped)": { - giveUrl: "http://test/XXXbbab9-c197-4dd3-bc3f-3cb625382ZZZ", - wantStatusCode: http.StatusOK, - }, - "session was not found": { - giveUrl: "http://test/9b6bbab9-c197-4dd3-bc3f-3cb6253820c7/222?foo=bar", - wantStatusCode: http.StatusNotFound, - wantSubstring: []string{"session with UUID 9b6bbab9-c197-4dd3-bc3f-3cb6253820c7 was not found"}, - }, - "too large body request": { - giveUrl: testBaseUri + sessionUUID + "/222?foo=bar#anchor", - giveBody: bytes.NewBuffer([]byte(strings.Repeat("x", 65))), - wantStatusCode: http.StatusInternalServerError, - wantSubstring: []string{"Request body is too large"}, - }, - } { - t.Run(name, func(t *testing.T) { - var ( - e = echo.New() - req, _ = http.NewRequest(http.MethodPut, tt.giveUrl, tt.giveBody) - rr = httptest.NewRecorder() - c = e.NewContext(req, rr) - - h = webhook.New(context.Background(), config.Config{ - MaxRequestBodySize: 64, - }, s, ps, &fakeMetrics{}) - ) - - require.NoError(t, h(func(c echo.Context) error { - return c.NoContent(http.StatusOK) - })(c)) - - assert.Equal(t, tt.wantStatusCode, rr.Code) - - for i := range len(tt.wantSubstring) { - assert.Contains(t, rr.Body.String(), tt.wantSubstring[i]) - } - }) - } -} - -func TestHandler_Success(t *testing.T) { - s := storage.NewInMemory(time.Minute, 10) - defer s.Close() - - ps := pubsub.NewInMemory() - defer ps.Close() - - sessionUUID, err := s.CreateSession([]byte("foo"), 202, "foo/bar", 0) - require.NoError(t, err) - - var e = echo.New() - e.IPExtractor = appHttp.NewIPExtractor() // just as an additional "feature" test - - var ( - req, _ = http.NewRequest(http.MethodPost, testBaseUri+sessionUUID, bytes.NewBuffer([]byte("foo=bar"))) - rr = httptest.NewRecorder() - m = fakeMetrics{} - c = e.NewContext(req, rr) - - h = webhook.New(context.Background(), config.Config{ - IgnoreHeaderPrefixes: []string{"x-bAr-", "Baz"}, - }, s, ps, &m) - ) - - req.Header.Set("x-bar-foo", "baz") // should be ignored - req.Header.Set("bAZ", "foo") // should be ignored - req.Header.Set("foo", "blah") - req.Header.Set("X-Forwarded-For", "4.4.4.4") - req.Header.Set("X-Real-IP", "3.3.3.3") - req.Header.Set("cf-connecting-ip", "2.2.2.2, 2.1.1.2") - - // subscribe for events - eventsCh := make(chan pubsub.Event, 3) - assert.NoError(t, ps.Subscribe(sessionUUID, eventsCh)) - - require.NoError(t, h(func(c echo.Context) error { - return c.NoContent(http.StatusOK) - })(c)) - - runtime.Gosched() - <-time.After(time.Millisecond) // goroutine must be done - - assert.Equal(t, 202, rr.Code) - assert.Equal(t, "foo", rr.Body.String()) - assert.Equal(t, "foo/bar", rr.Header().Get("Content-Type")) - - requests, err := s.GetAllRequests(sessionUUID) - assert.NoError(t, err) - - event := <-eventsCh - assert.Equal(t, "request-registered", event.Name()) - assert.Equal(t, requests[0].UUID(), string(event.Data())) - - assert.Equal(t, 1, m.c) - - assert.Equal(t, http.MethodPost, requests[0].Method()) - assert.Equal(t, []byte("foo=bar"), requests[0].Content()) - assert.Equal(t, map[string]string{ - "Foo": "blah", - "X-Forwarded-For": "4.4.4.4", - "X-Real-Ip": "3.3.3.3", - "Cf-Connecting-Ip": "2.2.2.2, 2.1.1.2", - }, requests[0].Headers()) - assert.Equal(t, "2.2.2.2", requests[0].ClientAddr()) -} - -func TestHandler_SuccessCustomCode(t *testing.T) { - s := storage.NewInMemory(time.Minute, 10) - defer s.Close() - - ps := pubsub.NewInMemory() - defer ps.Close() - - sessionUUID, err := s.CreateSession([]byte("foo"), 202, "foo/bar", 0) - require.NoError(t, err) - - var ( - req, _ = http.NewRequest(http.MethodPut, testBaseUri+sessionUUID+"/222", http.NoBody) - rr = httptest.NewRecorder() - e = echo.New() - c = e.NewContext(req, rr) - - handler = webhook.New(context.Background(), config.Config{}, s, ps, &fakeMetrics{}) - ) - - require.NoError(t, handler(func(c echo.Context) error { - return c.NoContent(http.StatusOK) - })(c)) - - assert.Equal(t, 222, rr.Code) - assert.Equal(t, "foo", rr.Body.String()) - assert.Equal(t, "foo/bar", rr.Header().Get("Content-Type")) - - requests, err := s.GetAllRequests(sessionUUID) - assert.NoError(t, err) - - assert.Equal(t, http.MethodPut, requests[0].Method()) - assert.Equal(t, []byte(""), requests[0].Content()) - assert.Empty(t, requests[0].Headers()) -} - -func TestHandler_SuccessWrongCustomCode(t *testing.T) { - s := storage.NewInMemory(time.Minute, 10) - defer s.Close() - - ps := pubsub.NewInMemory() - defer ps.Close() - - sessionUUID, err := s.CreateSession([]byte("foo"), 234, "foo/bar", 0) - require.NoError(t, err) - - var ( - req, _ = http.NewRequest(http.MethodPut, testBaseUri+sessionUUID+"/999", http.NoBody) - rr = httptest.NewRecorder() - e = echo.New() - c = e.NewContext(req, rr) - - handler = webhook.New(context.Background(), config.Config{}, s, ps, &fakeMetrics{}) - ) - - require.NoError(t, handler(func(c echo.Context) error { - return c.NoContent(http.StatusOK) - })(c)) - - assert.Equal(t, 234, rr.Code) - assert.Equal(t, "foo", rr.Body.String()) - assert.Equal(t, "foo/bar", rr.Header().Get("Content-Type")) - - requests, err := s.GetAllRequests(sessionUUID) - assert.NoError(t, err) - - assert.Equal(t, http.MethodPut, requests[0].Method()) - assert.Equal(t, []byte(""), requests[0].Content()) - assert.Empty(t, requests[0].Headers()) -} - -func TestHandler_ServeHTTPDelay(t *testing.T) { - s := storage.NewInMemory(time.Minute, 10) - defer s.Close() - - ps := pubsub.NewInMemory() - defer ps.Close() - - sessionUUID, err := s.CreateSession([]byte("foo"), 203, "foo/bar", time.Millisecond*100) - require.NoError(t, err) - - var ( - req, _ = http.NewRequest(http.MethodPut, testBaseUri+sessionUUID, http.NoBody) - rr = httptest.NewRecorder() - e = echo.New() - c = e.NewContext(req, rr) - - handler = webhook.New(context.Background(), config.Config{}, s, ps, &fakeMetrics{}) - ) - - start := time.Now().UnixNano() - - require.NoError(t, handler(func(c echo.Context) error { - return c.NoContent(http.StatusOK) - })(c)) - - end := time.Now().UnixNano() - - assert.InDelta(t, time.Millisecond*100, time.Duration(end-start), float64(time.Millisecond*10)) - - assert.Equal(t, 203, rr.Code) - assert.Equal(t, "foo", rr.Body.String()) - assert.Equal(t, "foo/bar", rr.Header().Get("Content-Type")) - - requests, err := s.GetAllRequests(sessionUUID) - assert.NoError(t, err) - - assert.Equal(t, http.MethodPut, requests[0].Method()) - assert.Equal(t, []byte(""), requests[0].Content()) - assert.Empty(t, requests[0].Headers()) -} diff --git a/internal/http/openapi.go b/internal/http/openapi.go new file mode 100644 index 00000000..467daa41 --- /dev/null +++ b/internal/http/openapi.go @@ -0,0 +1,288 @@ +package http + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + "go.uber.org/zap" + + "gh.tarampamp.am/webhook-tester/v2/internal/config" + "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/live" + "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/ready" + "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/request_delete" + "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/request_get" + "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/requests_delete_all" + "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/requests_list" + "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/requests_subscribe" + "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/session_create" + "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/session_delete" + "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/session_get" + "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/settings_get" + "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/version" + "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/version_latest" + "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" + "gh.tarampamp.am/webhook-tester/v2/internal/pubsub" + "gh.tarampamp.am/webhook-tester/v2/internal/storage" + appVersion "gh.tarampamp.am/webhook-tester/v2/internal/version" +) + +type ( // type aliases for better readability + sID = openapi.SessionUUIDInPath + rID = openapi.RequestUUIDInPath + skip = openapi.ApiSessionRequestsSubscribeParams // it doesn't matter +) + +type OpenAPI struct { + log *zap.Logger + + handlers struct { + settingsGet func() openapi.SettingsResponse + sessionCreate func(context.Context, openapi.CreateSessionRequest) (*openapi.SessionOptionsResponse, error) + sessionGet func(context.Context, sID) (*openapi.SessionOptionsResponse, error) + sessionDelete func(context.Context, sID) (*openapi.SuccessfulOperationResponse, error) + requestsList func(context.Context, sID) (*openapi.CapturedRequestsListResponse, error) + requestsDelete func(context.Context, sID) (*openapi.SuccessfulOperationResponse, error) + requestsSubscribe func(context.Context, http.ResponseWriter, *http.Request, sID) error + requestGet func(context.Context, sID, rID) (*openapi.CapturedRequestsResponse, error) + requestDelete func(context.Context, sID, rID) (*openapi.SuccessfulOperationResponse, error) + appVersion func() openapi.VersionResponse + appVersionLatest func(context.Context, http.ResponseWriter) (*openapi.VersionResponse, error) + readinessProbe func(context.Context, http.ResponseWriter, string) + livenessProbe func(http.ResponseWriter, string) + } +} + +var _ openapi.ServerInterface = (*OpenAPI)(nil) // verify interface implementation + +func NewOpenAPI( + log *zap.Logger, + rdyChecker func(context.Context) error, + lastAppVer func(context.Context) (string, error), + cfg config.AppSettings, + db storage.Storage, + pubSub pubsub.PubSub[pubsub.CapturedRequest], +) *OpenAPI { + var si = &OpenAPI{log: log} + + si.handlers.settingsGet = settings_get.New(cfg).Handle + si.handlers.sessionCreate = session_create.New(db).Handle + si.handlers.sessionGet = session_get.New(db).Handle + si.handlers.sessionDelete = session_delete.New(db).Handle + si.handlers.requestsList = requests_list.New(db).Handle + si.handlers.requestsDelete = requests_delete_all.New(db).Handle + si.handlers.requestsSubscribe = requests_subscribe.New(db, pubSub).Handle + si.handlers.requestGet = request_get.New(db).Handle + si.handlers.requestDelete = request_delete.New(db).Handle + si.handlers.appVersion = version.New(appVersion.Version()).Handle + si.handlers.appVersionLatest = version_latest.New(lastAppVer).Handle + si.handlers.readinessProbe = ready.New(rdyChecker).Handle + si.handlers.livenessProbe = live.New().Handle + + return si +} + +func (o *OpenAPI) ApiSettings(w http.ResponseWriter, _ *http.Request) { + o.respToJson(w, o.handlers.settingsGet()) +} + +func (o *OpenAPI) ApiSessionCreate(w http.ResponseWriter, r *http.Request) { + var payload openapi.CreateSessionRequest + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + o.errorToJson(w, err, http.StatusBadRequest) + + return + } + + if err := payload.Validate(); err != nil { + o.errorToJson(w, err, http.StatusBadRequest) + + return + } + + if resp, err := o.handlers.sessionCreate(r.Context(), payload); err != nil { + o.errorToJson(w, err, http.StatusInternalServerError) + } else { + o.respToJson(w, resp) + } +} + +func (o *OpenAPI) ApiSessionGet(w http.ResponseWriter, r *http.Request, sID sID) { + if resp, err := o.handlers.sessionGet(r.Context(), sID); err != nil { + var statusCode = http.StatusInternalServerError + + if errors.Is(err, storage.ErrNotFound) { + statusCode = http.StatusNotFound + } + + o.errorToJson(w, err, statusCode) + } else { + o.respToJson(w, resp) + } +} + +func (o *OpenAPI) ApiSessionDelete(w http.ResponseWriter, r *http.Request, sID sID) { + if resp, err := o.handlers.sessionDelete(r.Context(), sID); err != nil { + var statusCode = http.StatusInternalServerError + + if errors.Is(err, storage.ErrNotFound) { + statusCode = http.StatusNotFound + } + + o.errorToJson(w, err, statusCode) + } else { + o.respToJson(w, resp) + } +} + +func (o *OpenAPI) ApiSessionListRequests(w http.ResponseWriter, r *http.Request, sID sID) { + if resp, err := o.handlers.requestsList(r.Context(), sID); err != nil { + var statusCode = http.StatusInternalServerError + + if errors.Is(err, storage.ErrNotFound) { + statusCode = http.StatusNotFound + } + + o.errorToJson(w, err, statusCode) + } else { + o.respToJson(w, resp) + } +} + +func (o *OpenAPI) ApiSessionDeleteAllRequests(w http.ResponseWriter, r *http.Request, sID sID) { + if resp, err := o.handlers.requestsDelete(r.Context(), sID); err != nil { + var statusCode = http.StatusInternalServerError + + if errors.Is(err, storage.ErrNotFound) { + statusCode = http.StatusNotFound + } + + o.errorToJson(w, err, statusCode) + } else { + o.respToJson(w, resp) + } +} + +func (o *OpenAPI) ApiSessionRequestsSubscribe(w http.ResponseWriter, r *http.Request, sID sID, _ skip) { + if err := o.handlers.requestsSubscribe(r.Context(), w, r, sID); err != nil { + var statusCode = http.StatusInternalServerError + + if errors.Is(err, storage.ErrNotFound) { + statusCode = http.StatusNotFound + } + + o.errorToJson(w, err, statusCode) + } +} + +func (o *OpenAPI) ApiSessionGetRequest(w http.ResponseWriter, r *http.Request, sID sID, rID rID) { + if resp, err := o.handlers.requestGet(r.Context(), sID, rID); err != nil { + var statusCode = http.StatusInternalServerError + + if errors.Is(err, storage.ErrNotFound) { + statusCode = http.StatusNotFound + } + + o.errorToJson(w, err, statusCode) + } else { + o.respToJson(w, resp) + } +} + +func (o *OpenAPI) ApiSessionDeleteRequest(w http.ResponseWriter, r *http.Request, sID sID, rID rID) { + if resp, err := o.handlers.requestDelete(r.Context(), sID, rID); err != nil { + var statusCode = http.StatusInternalServerError + + if errors.Is(err, storage.ErrNotFound) { + statusCode = http.StatusNotFound + } + + o.errorToJson(w, err, statusCode) + } else { + o.respToJson(w, resp) + } +} + +func (o *OpenAPI) ApiAppVersion(w http.ResponseWriter, _ *http.Request) { + o.respToJson(w, o.handlers.appVersion()) +} + +func (o *OpenAPI) ApiAppVersionLatest(w http.ResponseWriter, r *http.Request) { + if resp, err := o.handlers.appVersionLatest(r.Context(), w); err != nil { + o.errorToJson(w, err, http.StatusInternalServerError) + } else { + o.respToJson(w, resp) + } +} + +func (o *OpenAPI) ReadinessProbe(w http.ResponseWriter, r *http.Request) { + o.handlers.readinessProbe(r.Context(), w, r.Method) +} + +func (o *OpenAPI) ReadinessProbeHead(w http.ResponseWriter, r *http.Request) { + o.handlers.readinessProbe(r.Context(), w, r.Method) +} + +func (o *OpenAPI) LivenessProbe(w http.ResponseWriter, r *http.Request) { + o.handlers.livenessProbe(w, r.Method) +} + +func (o *OpenAPI) LivenessProbeHead(w http.ResponseWriter, r *http.Request) { + o.handlers.livenessProbe(w, r.Method) +} + +// -------------------------------------------------- Error handlers -------------------------------------------------- + +// HandleInternalError is a default error handler for internal server errors (e.g. query parameters binding +// errors, and so on). +func (o *OpenAPI) HandleInternalError(w http.ResponseWriter, _ *http.Request, err error) { + // Invalid format for parameter session_uuid: error unmarshaling 'xxxxxx' text as *uuid.UUID: invalid UUID format + // to + // invalid UUID format + if err != nil && strings.Contains(err.Error(), "invalid UUID") { + err = errors.New("invalid UUID format") + } + + o.errorToJson(w, err, http.StatusBadRequest) +} + +// HandleNotFoundError is a default error handler for "404: not found" errors. +func (o *OpenAPI) HandleNotFoundError(w http.ResponseWriter, _ *http.Request) { + o.errorToJson(w, errors.New("not found"), http.StatusNotFound) +} + +// ------------------------------------------------- Internal helpers ------------------------------------------------- + +const ( + contentTypeHeader = "Content-Type" + contentTypeJSON = "application/json; charset=utf-8" +) + +func (o *OpenAPI) respToJson(w http.ResponseWriter, resp any) { + w.Header().Set(contentTypeHeader, contentTypeJSON) + w.WriteHeader(http.StatusOK) + + if resp == nil { + return + } + + if err := json.NewEncoder(w).Encode(resp); err != nil { + o.log.Error("failed to encode/write response", zap.Error(err)) + } +} + +func (o *OpenAPI) errorToJson(w http.ResponseWriter, err error, status int) { + w.Header().Set(contentTypeHeader, contentTypeJSON) + w.WriteHeader(status) + + if err == nil { + return + } + + if encErr := json.NewEncoder(w).Encode(openapi.ErrorResponse{Error: err.Error()}); encErr != nil { + o.log.Error("failed to encode/write error response", zap.Error(encErr)) + } +} diff --git a/internal/http/openapi/configs/models.yml b/internal/http/openapi/configs/models.yml new file mode 100644 index 00000000..8b99fd32 --- /dev/null +++ b/internal/http/openapi/configs/models.yml @@ -0,0 +1,7 @@ +# The config struct: https://github.com/deepmap/oapi-codegen/blob/master/pkg/codegen/configuration.go#L14-L23 + +generate: + models: true + +compatibility: + always-prefix-enum-values: true diff --git a/internal/http/openapi/configs/server.yml b/internal/http/openapi/configs/server.yml new file mode 100644 index 00000000..4e228fa7 --- /dev/null +++ b/internal/http/openapi/configs/server.yml @@ -0,0 +1,6 @@ +# The config struct: https://github.com/deepmap/oapi-codegen/blob/master/pkg/codegen/configuration.go#L14-L23 + +generate: + std-http-server: true + #echo-server: true + #strict-server: true diff --git a/internal/http/openapi/configs/spec.yml b/internal/http/openapi/configs/spec.yml new file mode 100644 index 00000000..b381d2a8 --- /dev/null +++ b/internal/http/openapi/configs/spec.yml @@ -0,0 +1,62 @@ +# The config struct: https://github.com/deepmap/oapi-codegen/blob/master/pkg/codegen/configuration.go#L14-L23 + +generate: + embedded-spec: true + +output-options: + # Important note - since we are overriding the default templates, we need to update them manually when the + # openapi-generator version changes. Keep in mind - templates are patched, so you probably need to re-patch them + # after updating. + # See the template sources here: https://github.com/deepmap/oapi-codegen/tree/master/pkg/codegen/templates + # + # Keywords: oapi-codegen, openapi-generator, openapi, swagger + user-templates: + inline.tmpl: | + // swaggerSpec is base64 encoded, gzipped, json marshaled Swagger object. + var swaggerSpec = []string{ + {{range .SpecParts}}"{{.}}", + {{end}} + } + + // decodeSpec returns the content of the embedded swagger specification file + // or error if failed to decode. + func decodeSpec() ([]byte, error) { + zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, "")) + if err != nil { + return nil, fmt.Errorf("error base64 decoding spec: %w", err) + } + + zr, err := gzip.NewReader(bytes.NewReader(zipped)) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %w", err) + } + + var buf bytes.Buffer + + _, err = buf.ReadFrom(zr) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %w", err) + } + + return buf.Bytes(), nil + } + + {{/* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! CUSTOM CODE BEGIN !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! */}} + var specCache struct { + data []byte + once sync.Once + } + + // Spec returns the OpenAPI specification in JSON format. + func Spec() []byte { + specCache.once.Do(func() { + if data, err := decodeSpec(); err != nil { + panic(err) // will never happen + } else { + specCache.data = data + } + }) + + return specCache.data + } + {{/* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! CUSTOM CODE END !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! */}} diff --git a/internal/http/openapi/generate.go b/internal/http/openapi/generate.go new file mode 100644 index 00000000..73224c6a --- /dev/null +++ b/internal/http/openapi/generate.go @@ -0,0 +1,8 @@ +package openapi + +import _ "github.com/oapi-codegen/runtime/types" // required for oapi-codegen + +// Generate openapi stubs (`oapi-codegen` is required for this): +//go:generate oapi-codegen -config ./configs/models.yml -o ./models.gen.go -package openapi ./../../../api/openapi.yml +//go:generate oapi-codegen -config ./configs/server.yml -o ./server.gen.go -package openapi ./../../../api/openapi.yml +//go:generate oapi-codegen -config ./configs/spec.yml -o ./spec.gen.go -package openapi ./../../../api/openapi.yml diff --git a/internal/http/openapi/models_validate.go b/internal/http/openapi/models_validate.go new file mode 100644 index 00000000..41a7c07b --- /dev/null +++ b/internal/http/openapi/models_validate.go @@ -0,0 +1,53 @@ +package openapi + +import ( + "encoding/base64" + "fmt" + "strings" + "unicode/utf8" +) + +func (data CreateSessionRequest) Validate() error { + const ( + maxDelaySeconds = 30 // IMPORTANT! Must be less than http/writeTimeout value! + maxHeadersCount = 10 + minHeaderKeyLen, maxHeaderKeyLen = 1, 40 + maxHeaderValueLen = 2048 + maxResponseBodyLen = 10240 + minStatusCode, maxStatusCode = StatusCode(100), StatusCode(530) + ) + + if data.Delay > maxDelaySeconds { + return fmt.Errorf("response delay is too much (max is %d)", maxDelaySeconds) + } + + if len(data.Headers) > maxHeadersCount { + return fmt.Errorf("too many headers (max count is %d)", maxHeadersCount) + } + + for _, header := range data.Headers { + if l := utf8.RuneCountInString(header.Name); l < minHeaderKeyLen || l > maxHeaderKeyLen { + return fmt.Errorf("header key length should be between %d and %d", minHeaderKeyLen, maxHeaderKeyLen) + } + + if strings.TrimSpace(header.Name) == "" { + return fmt.Errorf("header key should not be empty") + } + + if l := utf8.RuneCountInString(header.Value); l > maxHeaderValueLen { + return fmt.Errorf("header value length should be less than %d", maxHeaderValueLen) + } + } + + if v, err := base64.StdEncoding.DecodeString(data.ResponseBodyBase64); err != nil { + return fmt.Errorf("cannot decode response body (wrong base64): %w", err) + } else if utf8.RuneCount(v) > maxResponseBodyLen { + return fmt.Errorf("response content is too large (max length is %d)", maxResponseBodyLen) + } + + if data.StatusCode < minStatusCode || data.StatusCode > maxStatusCode { + return fmt.Errorf("wrong status code (should be between %d and %d)", minStatusCode, maxStatusCode) + } + + return nil +} diff --git a/internal/http/openapi/validation.go b/internal/http/openapi/validation.go new file mode 100644 index 00000000..53483c9d --- /dev/null +++ b/internal/http/openapi/validation.go @@ -0,0 +1,16 @@ +package openapi + +import "github.com/google/uuid" + +const UUIDLength = 36 + +// IsValidUUID checks if passed string is valid UUID v4. +func IsValidUUID(id string) bool { + if len(id) != UUIDLength { + return false + } + + _, err := uuid.Parse(id) + + return err == nil +} diff --git a/internal/http/server.go b/internal/http/server.go index 0b390ed7..c01be7c9 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -2,115 +2,128 @@ package http import ( "context" + "errors" + "net" "net/http" - "strconv" + "strings" "time" - "github.com/go-redis/redis/v8" - "github.com/labstack/echo/v4" "go.uber.org/zap" - "gh.tarampamp.am/webhook-tester/internal/api" - "gh.tarampamp.am/webhook-tester/internal/config" - "gh.tarampamp.am/webhook-tester/internal/http/fileserver" - "gh.tarampamp.am/webhook-tester/internal/http/handlers" - "gh.tarampamp.am/webhook-tester/internal/http/middlewares/logreq" - "gh.tarampamp.am/webhook-tester/internal/http/middlewares/panic" - "gh.tarampamp.am/webhook-tester/internal/http/middlewares/webhook" - "gh.tarampamp.am/webhook-tester/internal/metrics" - "gh.tarampamp.am/webhook-tester/internal/pubsub" - "gh.tarampamp.am/webhook-tester/internal/storage" - "gh.tarampamp.am/webhook-tester/internal/version" - "gh.tarampamp.am/webhook-tester/web" -) - -const ( - readTimeout = time.Second * 5 - writeTimeout = time.Second * 31 // IMPORTANT! Must be grater then create.maxResponseDelay value! + "gh.tarampamp.am/webhook-tester/v2/internal/config" + "gh.tarampamp.am/webhook-tester/v2/internal/http/frontend" + "gh.tarampamp.am/webhook-tester/v2/internal/http/middleware/logreq" + "gh.tarampamp.am/webhook-tester/v2/internal/http/middleware/webhook" + "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" + "gh.tarampamp.am/webhook-tester/v2/internal/pubsub" + "gh.tarampamp.am/webhook-tester/v2/internal/storage" + "gh.tarampamp.am/webhook-tester/v2/web" ) type Server struct { - log *zap.Logger - echo *echo.Echo + http *http.Server + + ShutdownTimeout time.Duration // Maximum amount of time to wait for the server to stop, default is 5 seconds } -func NewServer(log *zap.Logger) *Server { - var srv = echo.New() - - srv.StdLogger = zap.NewStdLog(log) - srv.Server.ReadTimeout = readTimeout - srv.Server.ReadHeaderTimeout = readTimeout - srv.Server.WriteTimeout = writeTimeout - srv.Server.ErrorLog = srv.StdLogger - srv.IPExtractor = NewIPExtractor() - srv.HideBanner = true - srv.HidePort = true - - return &Server{ - log: log, - echo: srv, - } +type ServerOption func(*Server) + +func WithReadTimeout(d time.Duration) ServerOption { + return func(s *Server) { s.http.ReadTimeout = d } } -func (s *Server) Register( - ctx context.Context, - cfg config.Config, - rdb *redis.Client, - stor storage.Storage, - pub pubsub.Publisher, - sub pubsub.Subscriber, -) error { - registry := metrics.NewRegistry() - - s.echo.Use( - logreq.New(s.log, []string{"/ready", "/health"}), - panic.New(s.log), +func WithWriteTimeout(d time.Duration) ServerOption { + return func(s *Server) { s.http.WriteTimeout = d } +} + +func WithIDLETimeout(d time.Duration) ServerOption { + return func(s *Server) { s.http.IdleTimeout = d } +} + +func NewServer(baseCtx context.Context, log *zap.Logger, opts ...ServerOption) *Server { + var ( + server = Server{ + http: &http.Server{ //nolint:gosec + BaseContext: func(net.Listener) context.Context { return baseCtx }, + ErrorLog: zap.NewStdLog(log), + }, + ShutdownTimeout: 5 * time.Second, //nolint:mnd + } ) - websocketMetrics := metrics.NewWebsockets() - if err := websocketMetrics.Register(registry); err != nil { - return err + for _, opt := range opts { + opt(&server) } - api.RegisterHandlers(s.echo, handlers.NewAPI( - ctx, - cfg, - rdb, - stor, - pub, - sub, - registry, - version.Version(), - &websocketMetrics, - )) - - webhookMetrics := metrics.NewWebhooks() - if err := webhookMetrics.Register(registry); err != nil { - return err - } + return &server +} +func (s *Server) Register( + ctx context.Context, + log *zap.Logger, + rdyChk func(context.Context) error, + lastAppVer func(context.Context) (string, error), + cfg config.AppSettings, + db storage.Storage, + pubSub pubsub.PubSub[pubsub.CapturedRequest], + useLiveFrontend bool, +) *Server { var ( - wh = webhook.New(ctx, cfg, stor, pub, &webhookMetrics) - static = fileserver.NewHandler(http.FS(web.Content())) + oAPI = NewOpenAPI(log, rdyChk, lastAppVer, cfg, db, pubSub) // OpenAPI server implementation + spa = frontend.New(web.Dist(useLiveFrontend)) // file server for SPA (also handles 404 errors) + mux = http.NewServeMux() // base router for the OpenAPI server + handler = openapi.HandlerWithOptions(oAPI, openapi.StdHTTPServerOptions{ + ErrorHandlerFunc: oAPI.HandleInternalError, // set error handler for internal server errors + BaseRouter: mux, + }) ) - s.echo.Any("/*", wh(func(c echo.Context) error { // wrap file server into webhook middleware - if method := c.Request().Method; method == http.MethodGet || method == http.MethodHead { - return static(c) + mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // custom logic for handling 404 errors + if strings.HasPrefix(strings.TrimLeft(r.URL.Path, "/"), "api") { + // if the request path starts with "api", return the 404 error in the format required by the API + oAPI.HandleNotFoundError(w, r) + } else { + // otherwise, serve the SPA frontend + spa.ServeHTTP(w, r) } - - s.echo.HTTPErrorHandler(echo.ErrNotFound, c) - - return nil })) - return nil -} + // apply middlewares + s.http.Handler = logreq.New(log, nil)( // logger middleware + webhook.New(ctx, log.Named("webhook"), db, pubSub, cfg)( // webhook capture as a middleware + handler, + ), + ) -// Start the server. -func (s *Server) Start(ip string, port uint16) error { - return s.echo.Start(ip + ":" + strconv.Itoa(int(port))) + return s } -// Stop the server. -func (s *Server) Stop(ctx context.Context) error { return s.echo.Shutdown(ctx) } +// StartHTTP starts the HTTP server. It listens on the provided listener and serves incoming requests. +// To stop the server, cancel the provided context. +// +// It blocks until the context is canceled or the server is stopped by some error. +func (s *Server) StartHTTP(ctx context.Context, ln net.Listener) error { + var errCh = make(chan error) + + go func(ch chan<- error) { defer close(ch); ch <- s.http.Serve(ln) }(errCh) + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.ShutdownTimeout) + defer cancel() + + if err := s.http.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + case err, isOpened := <-errCh: + switch { + case !isOpened: + return nil + case err != nil: + return err + } + } + + return nil +} diff --git a/internal/http/server_test.go b/internal/http/server_test.go new file mode 100644 index 00000000..3f27a34b --- /dev/null +++ b/internal/http/server_test.go @@ -0,0 +1,250 @@ +package http_test + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "gh.tarampamp.am/webhook-tester/v2/internal/config" + appHttp "gh.tarampamp.am/webhook-tester/v2/internal/http" + "gh.tarampamp.am/webhook-tester/v2/internal/pubsub" + "gh.tarampamp.am/webhook-tester/v2/internal/storage" +) + +func TestServer_StartHTTP(t *testing.T) { + t.Parallel() + + var ( + ctx = context.Background() + log = zap.NewNop() + srv = appHttp.NewServer(ctx, log) + db = storage.NewInMemory(time.Minute, 8) + ) + + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + const webhookResponse = "CAPTURED !!! OLOLO" + + sID, err := db.NewSession(ctx, storage.Session{ + Code: http.StatusExpectationFailed, + ResponseBody: []byte(webhookResponse), + Headers: []storage.HttpHeader{{Name: "Content-Type", Value: "text/someShit"}}, + }) + require.NoError(t, err) + + rID, err := db.NewRequest(ctx, sID, storage.Request{}) + require.NoError(t, err) + + srv.Register( + context.Background(), + log, + func(context.Context) error { return nil }, + func(context.Context) (string, error) { return "v1.0.0", nil }, + config.AppSettings{}, + db, + pubsub.NewInMemory[pubsub.CapturedRequest](), + false, + ) + + var baseUrl, stop = startServer(t, ctx, srv) + + t.Cleanup(stop) + + t.Run("index", func(t *testing.T) { + t.Parallel() + + var status, body, headers = sendRequest(t, "GET", baseUrl) + + require.Equal(t, http.StatusOK, status) + require.Contains(t, string(body), " 0 { + for key, value := range headers[0] { + req.Header.Add(key, value) + } + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + body, _ = io.ReadAll(resp.Body) + + require.NoError(t, resp.Body.Close()) + + return resp.StatusCode, body, resp.Header +} + +// startServer is a helper function to start an HTTP server and return its base URL. +func startServer(t *testing.T, pCtx context.Context, srv interface { + StartHTTP(ctx context.Context, ln net.Listener) error +}) (string /* baseurl */, func() /* stop */) { + t.Helper() + + var ( + port = getFreeTcpPort(t) + hostPort = fmt.Sprintf("%s:%d", "127.0.0.1", port) + ) + + // open HTTP port + ln, lnErr := net.Listen("tcp", hostPort) + require.NoError(t, lnErr) + + var ctx, cancel = context.WithCancel(pCtx) + + go func() { + err := srv.StartHTTP(ctx, ln) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + require.NoError(t, err) + } + }() + + // wait until the server starts + for { + if conn, err := net.DialTimeout("tcp", hostPort, time.Second); err == nil { + require.NoError(t, conn.Close()) + + break + } + + <-time.After(5 * time.Millisecond) + } + + return fmt.Sprintf("http://%s", hostPort), cancel +} + +// getFreeTcpPort is a helper function to get a free TCP port number. +func getFreeTcpPort(t *testing.T) uint16 { + t.Helper() + + l, lErr := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, lErr) + + port := l.Addr().(*net.TCPAddr).Port + require.NoError(t, l.Close()) + + // make sure port is closed + for { + conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + break + } + + require.NoError(t, conn.Close()) + <-time.After(5 * time.Millisecond) + } + + return uint16(port) //nolint:gosec +} diff --git a/internal/logger/format.go b/internal/logger/format.go new file mode 100644 index 00000000..cd7e1147 --- /dev/null +++ b/internal/logger/format.go @@ -0,0 +1,68 @@ +package logger + +import ( + "fmt" + "strings" +) + +// A Format is a logging format. +type Format uint8 + +const ( + ConsoleFormat Format = iota // useful for console output (for humans) + JSONFormat // useful for logging aggregation systems (for robots) +) + +// String returns a lower-case ASCII representation of the log format. +func (f Format) String() string { + switch f { + case ConsoleFormat: + return "console" + case JSONFormat: + return "json" + } + + return fmt.Sprintf("format(%d)", f) +} + +// Formats returns a slice of all logging formats. +func Formats() []Format { + return []Format{ConsoleFormat, JSONFormat} +} + +// FormatStrings returns a slice of all logging formats as strings. +func FormatStrings() []string { + var ( + formats = Formats() + result = make([]string, len(formats)) + ) + + for i := range formats { + result[i] = formats[i].String() + } + + return result +} + +// ParseFormat parses a format (case is ignored) based on the ASCII representation of the log format. +// If the provided ASCII representation is invalid an error is returned. +// +// This is particularly useful when dealing with text input to configure log formats. +func ParseFormat[T string | []byte](text T) (Format, error) { + var format string + + if s, ok := any(text).(string); ok { + format = s + } else { + format = string(any(text).([]byte)) + } + + switch strings.ToLower(format) { + case "console", "": // make the zero value useful + return ConsoleFormat, nil + case "json": + return JSONFormat, nil + } + + return Format(0), fmt.Errorf("unrecognized logging format: %q", text) +} diff --git a/internal/logger/format_test.go b/internal/logger/format_test.go new file mode 100644 index 00000000..99737212 --- /dev/null +++ b/internal/logger/format_test.go @@ -0,0 +1,62 @@ +package logger_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "gh.tarampamp.am/webhook-tester/v2/internal/logger" +) + +func TestFormat_String(t *testing.T) { + for name, tt := range map[string]struct { + giveFormat logger.Format + wantString string + }{ + "json": {giveFormat: logger.JSONFormat, wantString: "json"}, + "console": {giveFormat: logger.ConsoleFormat, wantString: "console"}, + "": {giveFormat: logger.Format(255), wantString: "format(255)"}, + } { + t.Run(name, func(t *testing.T) { + require.Equal(t, tt.wantString, tt.giveFormat.String()) + }) + } +} + +func TestParseFormat(t *testing.T) { + for name, tt := range map[string]struct { + giveBytes []byte + giveString string + wantFormat logger.Format + wantError error + }{ + "": {giveBytes: []byte(""), wantFormat: logger.ConsoleFormat}, + " (string)": {giveString: "", wantFormat: logger.ConsoleFormat}, + "console": {giveBytes: []byte("console"), wantFormat: logger.ConsoleFormat}, + "console (string)": {giveString: "console", wantFormat: logger.ConsoleFormat}, + "json": {giveBytes: []byte("json"), wantFormat: logger.JSONFormat}, + "json (string)": {giveString: "json", wantFormat: logger.JSONFormat}, + "foobar": {giveBytes: []byte("foobar"), wantError: errors.New("unrecognized logging format: \"foobar\"")}, //nolint:lll + } { + t.Run(name, func(t *testing.T) { + var ( + f logger.Format + err error + ) + + if tt.giveString != "" { + f, err = logger.ParseFormat(tt.giveString) + } else { + f, err = logger.ParseFormat(tt.giveBytes) + } + + if tt.wantError == nil { + require.NoError(t, err) + require.Equal(t, tt.wantFormat, f) + } else { + require.EqualError(t, err, tt.wantError.Error()) + } + }) + } +} diff --git a/internal/logger/level.go b/internal/logger/level.go new file mode 100644 index 00000000..5977add3 --- /dev/null +++ b/internal/logger/level.go @@ -0,0 +1,83 @@ +package logger + +import ( + "fmt" + "strings" +) + +// A Level is a logging level. +type Level int8 + +const ( + DebugLevel Level = iota - 1 + InfoLevel // default level (zero-value) + WarnLevel + ErrorLevel + FatalLevel +) + +// String returns a lower-case ASCII representation of the log level. +func (l Level) String() string { + switch l { + case DebugLevel: + return "debug" + case InfoLevel: + return "info" + case WarnLevel: + return "warn" + case ErrorLevel: + return "error" + case FatalLevel: + return "fatal" + } + + return fmt.Sprintf("level(%d)", l) +} + +// Levels returns a slice of all logging levels. +func Levels() []Level { + return []Level{DebugLevel, InfoLevel, WarnLevel, ErrorLevel, FatalLevel} +} + +// LevelStrings returns a slice of all logging levels as strings. +func LevelStrings() []string { + var ( + levels = Levels() + result = make([]string, len(levels)) + ) + + for i := range levels { + result[i] = levels[i].String() + } + + return result +} + +// ParseLevel parses a level (case is ignored) based on the ASCII representation of the log level. +// If the provided ASCII representation is invalid an error is returned. +// +// This is particularly useful when dealing with text input to configure log levels. +func ParseLevel[T string | []byte](text T) (Level, error) { + var lvl string + + if s, ok := any(text).(string); ok { + lvl = s + } else { + lvl = string(any(text).([]byte)) + } + + switch strings.ToLower(lvl) { + case "debug", "verbose", "trace": + return DebugLevel, nil + case "info", "": // make the zero value useful + return InfoLevel, nil + case "warn": + return WarnLevel, nil + case "error": + return ErrorLevel, nil + case "fatal": + return FatalLevel, nil + } + + return Level(0), fmt.Errorf("unrecognized logging level: %q", text) +} diff --git a/internal/logger/level_test.go b/internal/logger/level_test.go new file mode 100644 index 00000000..c9ade38a --- /dev/null +++ b/internal/logger/level_test.go @@ -0,0 +1,84 @@ +package logger_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "gh.tarampamp.am/webhook-tester/v2/internal/logger" +) + +func TestLevel_String(t *testing.T) { + for name, tt := range map[string]struct { + giveLevel logger.Level + wantString string + }{ + "debug": {giveLevel: logger.DebugLevel, wantString: "debug"}, + "info": {giveLevel: logger.InfoLevel, wantString: "info"}, + "warn": {giveLevel: logger.WarnLevel, wantString: "warn"}, + "error": {giveLevel: logger.ErrorLevel, wantString: "error"}, + "fatal": {giveLevel: logger.FatalLevel, wantString: "fatal"}, + "": {giveLevel: logger.Level(127), wantString: "level(127)"}, + } { + t.Run(name, func(t *testing.T) { + require.Equal(t, tt.wantString, tt.giveLevel.String()) + }) + } +} + +func TestParseLevel(t *testing.T) { + for name, tt := range map[string]struct { + giveBytes []byte + giveString string + wantLevel logger.Level + wantError error + }{ + "": {giveBytes: []byte(""), wantLevel: logger.InfoLevel}, + " (string)": {giveString: "", wantLevel: logger.InfoLevel}, + "trace": {giveBytes: []byte("debug"), wantLevel: logger.DebugLevel}, + "verbose": {giveBytes: []byte("debug"), wantLevel: logger.DebugLevel}, + "debug": {giveBytes: []byte("debug"), wantLevel: logger.DebugLevel}, + "debug (string)": {giveString: "debug", wantLevel: logger.DebugLevel}, + "info": {giveBytes: []byte("info"), wantLevel: logger.InfoLevel}, + "warn": {giveBytes: []byte("warn"), wantLevel: logger.WarnLevel}, + "error": {giveBytes: []byte("error"), wantLevel: logger.ErrorLevel}, + "fatal": {giveBytes: []byte("fatal"), wantLevel: logger.FatalLevel}, + "fatal (string)": {giveString: "fatal", wantLevel: logger.FatalLevel}, + "foobar": {giveBytes: []byte("foobar"), wantError: errors.New("unrecognized logging level: \"foobar\"")}, //nolint:lll + } { + t.Run(name, func(t *testing.T) { + var ( + l logger.Level + err error + ) + + if tt.giveString != "" { + l, err = logger.ParseLevel(tt.giveString) + } else { + l, err = logger.ParseLevel(tt.giveBytes) + } + + if tt.wantError == nil { + require.NoError(t, err) + require.Equal(t, tt.wantLevel, l) + } else { + require.EqualError(t, err, tt.wantError.Error()) + } + }) + } +} + +func TestLevels(t *testing.T) { + require.Equal(t, []logger.Level{ + logger.DebugLevel, + logger.InfoLevel, + logger.WarnLevel, + logger.ErrorLevel, + logger.FatalLevel, + }, logger.Levels()) +} + +func TestLevelStrings(t *testing.T) { + require.Equal(t, []string{"debug", "info", "warn", "error", "fatal"}, logger.LevelStrings()) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index b503de80..6c8d877e 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -1,21 +1,27 @@ -// Package logger contains functions for a working with application logging. package logger import ( + "errors" + "go.uber.org/zap" "go.uber.org/zap/zapcore" ) -// New creates new "zap" logger with little customization. -func New(verbose, debug, logJSON bool) (*zap.Logger, error) { +// New creates new "zap" echoLogger with a small customization. +func New(l Level, f Format) (*zap.Logger, error) { var config zap.Config - if logJSON { - config = zap.NewProductionConfig() - } else { + switch f { + case ConsoleFormat: config = zap.NewDevelopmentConfig() config.EncoderConfig.EncodeLevel = zapcore.LowercaseColorLevelEncoder config.EncoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout("15:04:05") + + case JSONFormat: + config = zap.NewProductionConfig() // json encoder is used by default + + default: + return nil, errors.New("unsupported logging format") } // default configuration for all encoders @@ -24,15 +30,31 @@ func New(verbose, debug, logJSON bool) (*zap.Logger, error) { config.DisableStacktrace = true config.DisableCaller = true - if debug { + // enable additional features for debugging + if l <= DebugLevel { config.Development = true config.DisableStacktrace = false config.DisableCaller = false } - if verbose || debug { - config.Level = zap.NewAtomicLevelAt(zap.DebugLevel) + var zapLvl zapcore.Level + + switch l { // convert level to zap.Level + case DebugLevel: + zapLvl = zap.DebugLevel + case InfoLevel: + zapLvl = zap.InfoLevel + case WarnLevel: + zapLvl = zap.WarnLevel + case ErrorLevel: + zapLvl = zap.ErrorLevel + case FatalLevel: + zapLvl = zap.FatalLevel + default: + return nil, errors.New("unsupported logging level") } + config.Level = zap.NewAtomicLevelAt(zapLvl) + return config.Build() } diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index 3cc80040..0067fe8e 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -1,81 +1,75 @@ package logger_test -import ( - "regexp" - "strings" - "testing" - "time" - - "github.com/kami-zh/go-capturer" - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/webhook-tester/internal/logger" -) - -func TestNewNotVerboseDebugJSON(t *testing.T) { - output := capturer.CaptureStderr(func() { - log, err := logger.New(false, false, false) - assert.NoError(t, err) - - log.Info("inf msg") - log.Debug("dbg msg") - log.Error("err msg") - }) - - assert.Contains(t, output, time.Now().Format("15:04:05")) - assert.Regexp(t, `\t.+info.+\tinf msg`, output) - assert.NotContains(t, output, "dbg msg") - assert.Contains(t, output, "err msg") -} - -func TestNewVerboseNotDebugJSON(t *testing.T) { - output := capturer.CaptureStderr(func() { - log, err := logger.New(true, false, false) - assert.NoError(t, err) - - log.Info("inf msg") - log.Debug("dbg msg") - log.Error("err msg") - }) - - assert.Contains(t, output, time.Now().Format("15:04:05")) - assert.Regexp(t, `\t.+info.+\tinf msg`, output) - assert.Contains(t, output, "dbg msg") - assert.Contains(t, output, "err msg") -} - -func TestNewVerboseDebugNotJSON(t *testing.T) { - output := capturer.CaptureStderr(func() { - log, err := logger.New(true, true, false) - assert.NoError(t, err) - - log.Info("inf msg") - log.Debug("dbg msg") - log.Error("err msg") - }) - - assert.Contains(t, output, time.Now().Format("15:04:05")) - assert.Regexp(t, `\t.+info.+\t.+logger_test\.go:\d+\tinf msg`, output) - assert.Contains(t, output, "dbg msg") - assert.Contains(t, output, "err msg") -} - -func TestNewNotVerboseDebugButJSON(t *testing.T) { - output := capturer.CaptureStderr(func() { - log, err := logger.New(false, false, true) - assert.NoError(t, err) - - log.Info("inf msg") - log.Debug("dbg msg") - log.Error("err msg") - }) - - // replace timestamp field with fixed value - fakeTimestamp := regexp.MustCompile(`"ts":\d+\.\d+,`) - output = fakeTimestamp.ReplaceAllString(output, `"ts":0.1,`) - - lines := strings.Split(strings.Trim(output, "\n"), "\n") - - assert.JSONEq(t, `{"level":"info","ts":0.1,"msg":"inf msg"}`, lines[0]) - assert.JSONEq(t, `{"level":"error","ts":0.1,"msg":"err msg"}`, lines[1]) -} +// import ( +// "regexp" +// "strings" +// "testing" +// "time" +// +// "github.com/kami-zh/go-capturer" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// +// "gh.tarampamp.am/webhook-tester/v2/internal/logger" +// ) + +// func TestNewDebugLevelConsoleFormat(t *testing.T) { +// output := capturer.CaptureStderr(func() { +// log, err := logger.New(logger.DebugLevel, logger.ConsoleFormat) +// require.NoError(t, err) +// +// log.Debug("dbg msg") +// log.Info("inf msg") +// log.Error("err msg") +// }) +// +// assert.Contains(t, output, time.Now().Format("15:04:05")) +// assert.Regexp(t, `\t.+info.+\tinf msg`, output) +// assert.Regexp(t, `\t.+info.+\t.+logger_test\.go:\d+\tinf msg`, output) +// assert.Contains(t, output, "dbg msg") +// assert.Contains(t, output, "err msg") +// } +// +// func TestNewErrorLevelConsoleFormat(t *testing.T) { +// output := capturer.CaptureStderr(func() { +// log, err := logger.New(logger.ErrorLevel, logger.ConsoleFormat) +// require.NoError(t, err) +// +// log.Debug("dbg msg") +// log.Info("inf msg") +// log.Error("err msg") +// }) +// +// assert.NotContains(t, output, "inf msg") +// assert.NotContains(t, output, "dbg msg") +// assert.Contains(t, output, "err msg") +// } +// +// func TestNewWarnLevelJSONFormat(t *testing.T) { +// output := capturer.CaptureStderr(func() { +// log, err := logger.New(logger.WarnLevel, logger.JSONFormat) +// require.NoError(t, err) +// +// log.Debug("dbg msg") +// log.Info("inf msg") +// log.Warn("warn msg") +// log.Error("err msg") +// }) +// +// // replace timestamp field with fixed value +// fakeTimestamp := regexp.MustCompile(`"ts":\d+\.\d+,`) +// output = fakeTimestamp.ReplaceAllString(output, `"ts":0.1,`) +// +// lines := strings.Split(strings.Trim(output, "\n"), "\n") +// +// assert.JSONEq(t, `{"level":"warn","ts":0.1,"msg":"warn msg"}`, lines[0]) +// assert.JSONEq(t, `{"level":"error","ts":0.1,"msg":"err msg"}`, lines[1]) +// } +// +// func TestNewErrors(t *testing.T) { +// _, err := logger.New(logger.Level(127), logger.ConsoleFormat) +// require.EqualError(t, err, "unsupported logging level") +// +// _, err = logger.New(logger.WarnLevel, logger.Format(255)) +// require.EqualError(t, err, "unsupported logging format") +// } diff --git a/internal/logger/redis_bridge.go b/internal/logger/redis_bridge.go index a798d3f0..cd5b9138 100644 --- a/internal/logger/redis_bridge.go +++ b/internal/logger/redis_bridge.go @@ -7,14 +7,12 @@ import ( "go.uber.org/zap" ) -type redisBridge struct { - zap *zap.Logger -} +type redisBridge struct{ zap *zap.Logger } // NewRedisBridge creates instance that can ba used as a bridge between zap and redis client for logging. func NewRedisBridge(zap *zap.Logger) *redisBridge { return &redisBridge{zap: zap} } //nolint:golint // Printf implements redis logger interface. func (rb *redisBridge) Printf(_ context.Context, format string, v ...any) { - rb.zap.Warn(fmt.Sprintf(format, v...), zap.String("source", "redis")) + rb.zap.Warn(fmt.Sprintf(format, v...)) } diff --git a/internal/logger/redis_bridge_test.go b/internal/logger/redis_bridge_test.go deleted file mode 100644 index 502115f0..00000000 --- a/internal/logger/redis_bridge_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package logger_test - -import ( - "context" - "testing" - - "github.com/kami-zh/go-capturer" - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/webhook-tester/internal/logger" -) - -func TestRedisBridge_Printf(t *testing.T) { - output := capturer.CaptureStderr(func() { - log, err := logger.New(false, false, false) - assert.NoError(t, err) - - br := logger.NewRedisBridge(log) - - br.Printf(context.Background(), "%s", "foobar") - }) - - assert.Contains(t, output, "warn") - assert.Contains(t, output, "foobar") -} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go deleted file mode 100644 index 7154483f..00000000 --- a/internal/metrics/metrics.go +++ /dev/null @@ -1,20 +0,0 @@ -// Package metrics contains custom prometheus metrics and registry factories. -package metrics - -import ( - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/collectors" -) - -// NewRegistry creates new prometheus registry with pre-registered common collectors. -func NewRegistry() *prometheus.Registry { - registry := prometheus.NewRegistry() - - // register common metric collectors // TODO add application uptime metric? - registry.MustRegister( - collectors.NewGoCollector(), - collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), - ) - - return registry -} diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go deleted file mode 100644 index f48d53b7..00000000 --- a/internal/metrics/metrics_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package metrics_test - -import ( - "testing" - - "github.com/prometheus/client_golang/prometheus/testutil" - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/webhook-tester/internal/metrics" -) - -func TestNewRegistry(t *testing.T) { - registry := metrics.NewRegistry() - - count, err := testutil.GatherAndCount(registry) - - assert.NoError(t, err) - assert.True(t, count >= 30, "not enough common metrics") -} diff --git a/internal/metrics/utils_for_test.go b/internal/metrics/utils_for_test.go deleted file mode 100644 index 67549fa7..00000000 --- a/internal/metrics/utils_for_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package metrics_test - -import ( - "github.com/prometheus/client_golang/prometheus" - dto "github.com/prometheus/client_model/go" -) - -type registerer interface { - Register(prometheus.Registerer) error -} - -func getMetric(m registerer, name string) *dto.Metric { - registry := prometheus.NewRegistry() - _ = m.Register(registry) - - families, _ := registry.Gather() - - for _, family := range families { - if family.GetName() == name { - return family.Metric[0] - } - } - - return nil -} diff --git a/internal/metrics/webhooks.go b/internal/metrics/webhooks.go deleted file mode 100644 index a1f19cca..00000000 --- a/internal/metrics/webhooks.go +++ /dev/null @@ -1,31 +0,0 @@ -package metrics - -import "github.com/prometheus/client_golang/prometheus" - -type WebHooks struct { - processedCounter prometheus.Counter -} - -// NewWebhooks creates new WebHooks metrics collector. -func NewWebhooks() WebHooks { - return WebHooks{ - processedCounter: prometheus.NewCounter(prometheus.CounterOpts{ //nolint:promlinter - Namespace: "webhooks", - Subsystem: "processed", - Name: "count", - Help: "The count of processed webhooks.", - }), - } -} - -// IncrementProcessedWebHooks increments processed webhooks counter. -func (w *WebHooks) IncrementProcessedWebHooks() { w.processedCounter.Inc() } - -// Register metrics with registerer. -func (w *WebHooks) Register(reg prometheus.Registerer) error { - if e := reg.Register(w.processedCounter); e != nil { - return e - } - - return nil -} diff --git a/internal/metrics/webhooks_test.go b/internal/metrics/webhooks_test.go deleted file mode 100644 index 183ee4b2..00000000 --- a/internal/metrics/webhooks_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package metrics_test - -import ( - "testing" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/testutil" - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/webhook-tester/internal/metrics" -) - -func TestWebHooks_Register(t *testing.T) { - var ( - registry = prometheus.NewRegistry() - wh = metrics.NewWebhooks() - ) - - assert.NoError(t, wh.Register(registry)) - - count, err := testutil.GatherAndCount(registry, "webhooks_processed_count") - assert.NoError(t, err) - - assert.Equal(t, 1, count) -} - -func TestWebHooks_IncrementProcessedWebHooks(t *testing.T) { - wh := metrics.NewWebhooks() - - wh.IncrementProcessedWebHooks() - - metric := getMetric(&wh, "webhooks_processed_count") - assert.Equal(t, float64(1), metric.Counter.GetValue()) -} diff --git a/internal/metrics/websockets.go b/internal/metrics/websockets.go deleted file mode 100644 index 82f77bc9..00000000 --- a/internal/metrics/websockets.go +++ /dev/null @@ -1,34 +0,0 @@ -package metrics - -import "github.com/prometheus/client_golang/prometheus" - -type WebSockets struct { - clientsCounter prometheus.Gauge -} - -// NewWebsockets creates new WebSockets metrics collector. -func NewWebsockets() WebSockets { - return WebSockets{ - clientsCounter: prometheus.NewGauge(prometheus.GaugeOpts{ //nolint:promlinter - Namespace: "websockets", - Subsystem: "active_clients", - Name: "count", - Help: "The count of active websocket clients.", - }), - } -} - -// IncrementActiveClients increments active websocket clients count. -func (w *WebSockets) IncrementActiveClients() { w.clientsCounter.Inc() } - -// DecrementActiveClients decrements active websocket clients count. -func (w *WebSockets) DecrementActiveClients() { w.clientsCounter.Dec() } - -// Register metrics with registerer. -func (w *WebSockets) Register(reg prometheus.Registerer) error { - if e := reg.Register(w.clientsCounter); e != nil { - return e - } - - return nil -} diff --git a/internal/metrics/websockets_test.go b/internal/metrics/websockets_test.go deleted file mode 100644 index 5900bdde..00000000 --- a/internal/metrics/websockets_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package metrics_test - -import ( - "testing" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/testutil" - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/webhook-tester/internal/metrics" -) - -func TestWebSockets_Register(t *testing.T) { - var ( - registry = prometheus.NewRegistry() - ws = metrics.NewWebsockets() - ) - - assert.NoError(t, ws.Register(registry)) - - count, err := testutil.GatherAndCount(registry, "websockets_active_clients_count") - assert.NoError(t, err) - - assert.Equal(t, 1, count) -} - -func TestWebSockets_IncrementActiveClients(t *testing.T) { - ws := metrics.NewWebsockets() - - ws.IncrementActiveClients() - - metric := getMetric(&ws, "websockets_active_clients_count") - assert.Equal(t, float64(1), metric.Gauge.GetValue()) -} - -func TestWebSockets_DecrementActiveClients(t *testing.T) { - ws := metrics.NewWebsockets() - - ws.DecrementActiveClients() - - metric := getMetric(&ws, "websockets_active_clients_count") - assert.Equal(t, float64(-1), metric.Gauge.GetValue()) -} diff --git a/internal/pubsub/events.go b/internal/pubsub/events.go deleted file mode 100644 index 07b372f5..00000000 --- a/internal/pubsub/events.go +++ /dev/null @@ -1,36 +0,0 @@ -package pubsub - -// Event is something that happens (sorry for the tautology). -type Event interface { - // Name returns event name. - Name() string - - // Data returns event payload. - Data() []byte -} - -type event struct { - name string - data []byte -} - -// Name returns event name. -func (e *event) Name() string { return e.name } - -// Data returns event payload. -func (e *event) Data() []byte { return e.data } - -// NewRequestRegisteredEvent creates an event, that means "new request with passed ID was registered". -func NewRequestRegisteredEvent(requestID string) Event { - return &event{name: "request-registered", data: []byte(requestID)} -} - -// NewRequestDeletedEvent creates an event, that means "request with passed ID was deleted". -func NewRequestDeletedEvent(requestID string) Event { - return &event{name: "request-deleted", data: []byte(requestID)} -} - -// NewAllRequestsDeletedEvent creates an event, that means "all requests was deleted". -func NewAllRequestsDeletedEvent() Event { - return &event{name: "requests-deleted", data: []byte("*")} -} diff --git a/internal/pubsub/events_test.go b/internal/pubsub/events_test.go deleted file mode 100644 index 8a2833b3..00000000 --- a/internal/pubsub/events_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package pubsub_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/webhook-tester/internal/pubsub" -) - -func TestNewRequestRegisteredEvent(t *testing.T) { - e := pubsub.NewRequestRegisteredEvent("foo") - - assert.Equal(t, []byte("foo"), e.Data()) - assert.Equal(t, "request-registered", e.Name()) -} - -func TestNewRequestDeletedEvent(t *testing.T) { - e := pubsub.NewRequestDeletedEvent("foo") - - assert.Equal(t, []byte("foo"), e.Data()) - assert.Equal(t, "request-deleted", e.Name()) -} - -func TestNewAllRequestsDeletedEvent(t *testing.T) { - e := pubsub.NewAllRequestsDeletedEvent() - - assert.Equal(t, []byte("*"), e.Data()) - assert.Equal(t, "requests-deleted", e.Name()) -} diff --git a/internal/pubsub/inmemory.go b/internal/pubsub/inmemory.go index 3a2b8e9a..44ddddf3 100644 --- a/internal/pubsub/inmemory.go +++ b/internal/pubsub/inmemory.go @@ -1,162 +1,95 @@ package pubsub import ( - "errors" + "context" "sync" ) -// InMemory publisher/subscriber uses memory for events publishing and delivering to the subscribers. Useful for -// application "single node" mode running or unit testing. -// -// Publishing/subscribing events order is NOT guaranteed. -// -// Node: Do not forget to Close it after all. Closed publisher/subscriber cannot be opened back. -type InMemory struct { - subsMu sync.Mutex - subs map[string]map[chan<- Event]chan struct{} // mapmapstopping signal ch. - - closedMu sync.Mutex - closed bool -} - -// NewInMemory creates new inmemory publisher/subscriber. -func NewInMemory() *InMemory { - return &InMemory{ - subs: make(map[string]map[chan<- Event]chan struct{}), +type ( + InMemory[T any] struct { + subsMu sync.Mutex + subs map[ /* topic */ string]map[ /* subscription */ chan<- T]*inMemorySubState } -} -func (ps *InMemory) createSubscriptionIfNeeded(channelName string) { - ps.subsMu.Lock() - if _, exists := ps.subs[channelName]; !exists { - ps.subs[channelName] = make(map[chan<- Event]chan struct{}) - } - ps.subsMu.Unlock() -} - -// Publish an event into passed channel. Publishing is non-blocking operation. -func (ps *InMemory) Publish(channelName string, event Event) error { - if channelName == "" { - return errors.New("empty channel name is not allowed") - } - - if ps.isClosed() { - return errors.New("closed") - } - - ps.createSubscriptionIfNeeded(channelName) - - ps.subsMu.Lock() - - for target, stop := range ps.subs[channelName] { - go func(target chan<- Event, stop <-chan struct{}) { // send an event without blocking - select { - case <-stop: - return - - case target <- event: // <- panic can be occurred here (if channel was closed too early outside) - } - }(target, stop) + inMemorySubState struct { + wg sync.WaitGroup + stop chan struct{} } +) - ps.subsMu.Unlock() +var ( // ensure interface implementation + _ Publisher[any] = (*InMemory[any])(nil) + _ Subscriber[any] = (*InMemory[any])(nil) +) - return nil +func NewInMemory[T any]() *InMemory[T] { + return &InMemory[T]{subs: make(map[string]map[chan<- T]*inMemorySubState)} } -// Subscribe to the named channel and receive Event's into the passed channel. Channel must be created on the calling -// side and NOT to be closed until subscription is not Unsubscribe*ed. -// -// Note: do not forget to call Unsubscribe when all is done. -func (ps *InMemory) Subscribe(channelName string, channel chan<- Event) error { - if channelName == "" { - return errors.New("empty channel name is not allowed") - } - - if ps.isClosed() { - return errors.New("closed") +func (ps *InMemory[T]) Publish(ctx context.Context, topic string, event T) error { + if err := ctx.Err(); err != nil { + return err // context is done } - ps.createSubscriptionIfNeeded(channelName) - ps.subsMu.Lock() defer ps.subsMu.Unlock() - if _, exists := ps.subs[channelName][channel]; exists { - return errors.New("already subscribed") + if _, exists := ps.subs[topic]; !exists { // if there are no subscribers - do not publish + return nil } - ps.subs[channelName][channel] = make(chan struct{}, 1) + for sub, state := range ps.subs[topic] { + state.wg.Add(1) // tell the subscriber that we are about to send an event - return nil -} + go func(sub chan<- T, stop <-chan struct{}, wg *sync.WaitGroup) { + defer wg.Done() // notify the subscriber that we are done -// Unsubscribe the subscription to the named channel for the passed events channel. Be careful with channel closing, -// this can call the panics if some Event's scheduled for publishing. -func (ps *InMemory) Unsubscribe(channelName string, channel chan Event) error { - if channelName == "" { - return errors.New("empty channel name is not allowed") + select { + case <-ctx.Done(): // check the context + case <-stop: // stopping notification + case sub <- event: // and in the same time try to send the event + } + }(sub, state.stop, &state.wg) } - if ps.isClosed() { - return errors.New("closed") + return nil +} + +func (ps *InMemory[T]) Subscribe(ctx context.Context, topic string) (<-chan T, func(), error) { + if err := ctx.Err(); err != nil { + return nil, func() { /* noop */ }, err // context is done } ps.subsMu.Lock() defer ps.subsMu.Unlock() - if _, exists := ps.subs[channelName]; !exists { - return errors.New("subscription does not exists") + if _, exists := ps.subs[topic]; !exists { // create a subscription if needed + ps.subs[topic] = make(map[chan<- T]*inMemorySubState) } - if _, exists := ps.subs[channelName][channel]; !exists { - return errors.New("channel was not subscribed") - } + var sub, state = make(chan T), &inMemorySubState{stop: make(chan struct{})} - // send "cancellation" signal to all publishing goroutines - ps.subs[channelName][channel] <- struct{}{} - close(ps.subs[channelName][channel]) + ps.subs[topic][sub] = state - // unsubscribe channel - delete(ps.subs[channelName], channel) + return sub, sync.OnceFunc(func() { + close(state.stop) // notify all the publishers to stop - // cleanup subscriptions map, if needed - if len(ps.subs[channelName]) == 0 { - delete(ps.subs, channelName) - } + ps.subsMu.Lock() - return nil -} - -func (ps *InMemory) isClosed() (isClosed bool) { - ps.closedMu.Lock() - isClosed = ps.closed - ps.closedMu.Unlock() - - return -} + delete(ps.subs[topic], sub) // remove subscription -// Close this publisher/subscriber. This function can be called only once. -func (ps *InMemory) Close() error { - if ps.isClosed() { - return errors.New("already closed") - } + if len(ps.subs[topic]) == 0 { // remove channel if there are no subscribers (cleanup) + delete(ps.subs, topic) + } - ps.closedMu.Lock() - ps.closed = true - ps.closedMu.Unlock() + ps.subsMu.Unlock() - ps.subsMu.Lock() - for channelName, channels := range ps.subs { - for _, cancelCh := range channels { - // send "cancellation" signal to the all publishing goroutines - cancelCh <- struct{}{} - close(cancelCh) + for len(sub) > 0 { + <-sub } - delete(ps.subs, channelName) - } - ps.subsMu.Unlock() + state.wg.Wait() // wait until all the publishers are done - return nil + close(sub) // and close the subscription channel + }), nil } diff --git a/internal/pubsub/inmemory_test.go b/internal/pubsub/inmemory_test.go index 38c63dc0..fb9ce126 100644 --- a/internal/pubsub/inmemory_test.go +++ b/internal/pubsub/inmemory_test.go @@ -1,189 +1,19 @@ package pubsub_test import ( - "context" - "runtime" - "sync" "testing" - "time" - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/webhook-tester/internal/pubsub" + "gh.tarampamp.am/webhook-tester/v2/internal/pubsub" ) -func TestInMemory_PublishErrors(t *testing.T) { - ps := pubsub.NewInMemory() - defer func() { _ = ps.Close() }() - - assert.EqualError(t, ps.Publish("", pubsub.NewRequestRegisteredEvent("bar")), "empty channel name is not allowed") -} - -func TestInMemory_PublishAndReceive(t *testing.T) { - ps := pubsub.NewInMemory() - defer func() { _ = ps.Close() }() - - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - var event1, event2 = pubsub.NewRequestRegisteredEvent("bar"), pubsub.NewRequestRegisteredEvent("baz") - - var wg sync.WaitGroup - - for range 10 { - wg.Add(1) - - go func() { // each of subscriber must receive a copy of published event - defer wg.Done() - - ch := make(chan pubsub.Event) - defer close(ch) - - assert.NoError(t, ps.Subscribe("foo", ch)) - - defer func() { assert.NoError(t, ps.Unsubscribe("foo", ch)) }() - - receivedEvents := make([]pubsub.Event, 0, 2) - - for range cap(receivedEvents) { - select { - case <-ctx.Done(): - t.Error(ctx.Err()) - - return +func TestInMemory_Publish_and_Receive(t *testing.T) { + t.Parallel() - case e := <-ch: - receivedEvents = append(receivedEvents, e) - } - } - - assert.Len(t, receivedEvents, 2) - - for j := range receivedEvents { - if event := receivedEvents[j]; event != event1 && event != event2 { - t.Error("received events must be one of expected") - } - } - }() - } - - runtime.Gosched() - <-time.After(time.Millisecond) // make sure that all subscribes was subscribed successfully - - assert.NoError(t, ps.Publish("foo", event1)) - assert.NoError(t, ps.Publish("foo", event2)) - - wg.Wait() - - assert.NoError(t, ps.Close()) + testPublishAndReceive(t, func() pubSub[any] { return pubsub.NewInMemory[any]() }) } -func TestInMemory_Close(t *testing.T) { - ps := pubsub.NewInMemory() - - assert.NoError(t, ps.Close()) - - ch := make(chan pubsub.Event) - - assert.EqualError(t, ps.Publish("foo", pubsub.NewRequestRegisteredEvent("bar")), "closed") - assert.EqualError(t, ps.Subscribe("foo", ch), "closed") - assert.EqualError(t, ps.Unsubscribe("foo", ch), "closed") - assert.EqualError(t, ps.Close(), "already closed") -} - -func TestInMemory_Unsubscribe(t *testing.T) { - ps := pubsub.NewInMemory() - defer func() { _ = ps.Close() }() - - ch1, ch2 := make(chan pubsub.Event, 3), make(chan pubsub.Event, 3) - // defer func() { close(ch1); close(ch2) }() // <- do not do thue due race reasons - - assert.NoError(t, ps.Subscribe("foo", ch1)) - assert.NoError(t, ps.Subscribe("foo", ch2)) - - assert.EqualError(t, ps.Unsubscribe("", ch1), "empty channel name is not allowed") - - assert.NoError(t, ps.Unsubscribe("foo", ch2)) - assert.EqualError(t, ps.Unsubscribe("foo", ch2), "channel was not subscribed") // repeated op - assert.EqualError(t, ps.Unsubscribe("baz", ch2), "subscription does not exists") - - assert.NoError(t, ps.Publish("foo", pubsub.NewRequestRegisteredEvent("bar"))) - - runtime.Gosched() - <-time.After(time.Millisecond) - - assert.Len(t, ch1, 1) - assert.Len(t, ch2, 0) -} - -func TestInMemory_Subscribe(t *testing.T) { - ps := pubsub.NewInMemory() - defer func() { _ = ps.Close() }() - - ch := make(chan pubsub.Event) - defer close(ch) - - assert.NoError(t, ps.Subscribe("foo", ch)) - - defer func() { assert.NoError(t, ps.Unsubscribe("foo", ch)) }() - - assert.EqualError(t, ps.Subscribe("", ch), "empty channel name is not allowed") - - assert.EqualError(t, ps.Subscribe("foo", ch), "already subscribed") // repeated -} - -func TestInMemory_UnsubscribeWithChannelClosingWithoutReading(t *testing.T) { - ps := pubsub.NewInMemory() - defer func() { _ = ps.Close() }() - - for range 1_000 { - ch := make(chan pubsub.Event) - - assert.NoError(t, ps.Subscribe("foo", ch)) - - assert.NoError(t, ps.Publish("foo", pubsub.NewRequestRegisteredEvent("bar"))) - - assert.NoError(t, ps.Unsubscribe("foo", ch)) - } - - for range 1_000 { - ps2 := pubsub.NewInMemory() - ch := make(chan pubsub.Event) - - assert.NoError(t, ps2.Subscribe("foo", ch)) - - assert.NoError(t, ps2.Publish("foo", pubsub.NewRequestRegisteredEvent("bar"))) - - assert.NoError(t, ps2.Close()) - } -} - -func BenchmarkInMemory_PublishAndReceive(b *testing.B) { - b.ReportAllocs() - - ps := pubsub.NewInMemory() - defer func() { _ = ps.Close() }() - - ch := make(chan pubsub.Event) - defer close(ch) - - if err := ps.Subscribe("foo", ch); err != nil { - b.Error(err) - } - - defer func() { _ = ps.Unsubscribe("foo", ch) }() - - event := pubsub.NewRequestRegisteredEvent("bar") - - b.ResetTimer() - - for i := 0; i < b.N; i++ { - if err := ps.Publish("foo", event); err != nil { - b.Error(err) - } +func TestInMemory_RaceProvocation(t *testing.T) { + t.Parallel() - if e := <-ch; e != event { - b.Error("wrong event received") - } - } + testRaceProvocation(t, func() pubSub[any] { return pubsub.NewInMemory[any]() }) } diff --git a/internal/pubsub/pubsub.go b/internal/pubsub/pubsub.go index 35d152e7..0e9411a6 100644 --- a/internal/pubsub/pubsub.go +++ b/internal/pubsub/pubsub.go @@ -1,22 +1,39 @@ -// Package pubsub is used for events publishing and subscribing for them. package pubsub -// Publisher allows to publish Event*s. -type Publisher interface { - // Publish an event into passed channel. - Publish(channelName string, event Event) error -} +import ( + "context" +) + +type ( + Publisher[T any] interface { + // Publish an event into the topic. + Publish(_ context.Context, topic string, event T) error + } -// Subscriber allows to Subscribe and Unsubscribe for Event*s. -type Subscriber interface { - // Subscribe to the named channel and receive Event's into the passed channel. - // - // Keep in mind - passed channel (chan) must be created on the caller side and channels without active readers - // (or closed too early) can block application working (or break it at all). - // - // Also do not forget to Unsubscribe from the channel. - Subscribe(channelName string, channel chan<- Event) error + Subscriber[T any] interface { + // Subscribe to the topic. The returned channel will receive events. + // The returned function should be called to unsubscribe. + Subscribe(_ context.Context, topic string) (_ <-chan T, unsubscribe func(), _ error) + } +) - // Unsubscribe the subscription to the named channel for the passed events channel. - Unsubscribe(channelName string, channel chan Event) error +type PubSub[T any] interface { + Publisher[T] + Subscriber[T] } + +type ( + CapturedRequest struct { + ID string `json:"id"` + ClientAddr string `json:"client_addr"` + Method string `json:"method"` + Headers []HttpHeader `json:"headers"` + URL string `json:"url"` + CreatedAtUnixMilli int64 `json:"created_at_unix_milli"` + } + + HttpHeader struct { + Name string `json:"name"` + Value string `json:"value"` + } +) diff --git a/internal/pubsub/pubsub_shared_test.go b/internal/pubsub/pubsub_shared_test.go new file mode 100644 index 00000000..0773b612 --- /dev/null +++ b/internal/pubsub/pubsub_shared_test.go @@ -0,0 +1,130 @@ +package pubsub_test + +import ( + "context" + "encoding/json" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "gh.tarampamp.am/webhook-tester/v2/internal/pubsub" +) + +type jsonSerializer struct{} + +func (jsonSerializer) Encode(v any) ([]byte, error) { return json.Marshal(v) } +func (jsonSerializer) Decode(data []byte, v any) error { return json.Unmarshal(data, v) } + +var encDec = new(jsonSerializer) + +type pubSub[T any] interface { + pubsub.Publisher[T] + pubsub.Subscriber[T] +} + +func testPublishAndReceive(t *testing.T, new func() pubSub[any]) { + t.Helper() + + const ( + topic1name, topic2name = "foo", "bar" + event1data, event2data = "event1", "event2" + ) + + var ( + ps = new() + ctx = context.Background() + ) + + var ( + sub1, close1, sub1err = ps.Subscribe(ctx, topic1name) + sub2, close2, sub2err = ps.Subscribe(ctx, topic2name) + ) + + require.NotNil(t, sub1) + require.NotNil(t, close1) + require.NoError(t, sub1err) + + require.NotNil(t, sub2) + require.NotNil(t, close2) + require.NoError(t, sub2err) + + t.Run("publish", func(t *testing.T) { + require.NoError(t, ps.Publish(ctx, topic1name, event1data)) + require.NoError(t, ps.Publish(ctx, topic2name, event2data)) + + var ( + event1, isSub1open = <-sub1 + event2, isSub2open = <-sub2 + ) + + require.Equal(t, event1data, event1) + require.True(t, isSub1open) + + require.Equal(t, event2data, event2) + require.True(t, isSub2open) + }) + + require.NoError(t, ps.Publish(ctx, topic1name, event1data)) // will not be delivered + require.NoError(t, ps.Publish(ctx, topic2name, event2data)) // will not be delivered + + close1() + close2() + + require.NoError(t, ps.Publish(ctx, topic1name, event1data)) // will not be delivered + require.NoError(t, ps.Publish(ctx, topic2name, event2data)) // will not be delivered + + t.Run("read from closed", func(t *testing.T) { + var ( + event1, isSub1open = <-sub1 + event2, isSub2open = <-sub2 + ) + + require.Empty(t, event1) + require.False(t, isSub1open) + + require.Empty(t, event2) + require.False(t, isSub2open) + }) + + t.Run("publish into non-existing channel", func(t *testing.T) { + require.NoError(t, ps.Publish(ctx, "baz", "event3")) + }) +} + +func testRaceProvocation(t *testing.T, new func() pubSub[any]) { + t.Helper() + + var ( + ps = new() + ctx = context.Background() + wg sync.WaitGroup + ) + + const topicName, eventData = "foo", "event" + + for range 1_000 { + sub, unsubscribe, err := ps.Subscribe(ctx, topicName) // subscribe + require.NoError(t, err) + + wg.Add(1) + + go func() { + defer wg.Done() + + require.Equal(t, <-sub, eventData) // receive (block until event is received) + + unsubscribe() // unsubscribe + }() + + wg.Add(1) + + go func() { + defer wg.Done() + + require.NoError(t, ps.Publish(ctx, topicName, eventData)) // publish + }() + } + + wg.Wait() +} diff --git a/internal/pubsub/redis.go b/internal/pubsub/redis.go index 4f6eaa60..51af4f6f 100644 --- a/internal/pubsub/redis.go +++ b/internal/pubsub/redis.go @@ -2,224 +2,90 @@ package pubsub import ( "context" - "errors" "sync" - "github.com/go-redis/redis/v8" - "github.com/vmihailenco/msgpack/v5" -) - -type ( - // Redis publisher/subscriber uses redis server for events publishing and delivering to the subscribers. Useful for - // application "distributed" mode running. - // - // Publishing/subscribing events order and delivering (in cases then there is no one active subscriber for the - // channel) is NOT guaranteed. - // - // Node: Do not forget to Close it after all. Closed publisher/subscriber cannot be opened back. - Redis struct { - ctx context.Context - rdb *redis.Client - - subsMu sync.Mutex - subs map[string]*redisSubscription - - closedMu sync.Mutex - closed bool - } - - redisSubscription struct { - start sync.Once - stop chan struct{} + "github.com/redis/go-redis/v9" - subscribersMu sync.Mutex - subscribers map[chan<- Event]struct{} - } + "gh.tarampamp.am/webhook-tester/v2/internal/encoding" ) -// NewRedis creates new redis publisher/subscriber. -func NewRedis(ctx context.Context, rdb *redis.Client) *Redis { - return &Redis{ - ctx: ctx, - rdb: rdb, - subs: make(map[string]*redisSubscription), - } +type redisClient interface { + redis.Cmdable + Subscribe(ctx context.Context, channels ...string) *redis.PubSub } -// redisEvent is an internal structure for events serialization. -type redisEvent struct { - Name string `msgpack:"n"` - Data []byte `msgpack:"d"` +type Redis[T any] struct { + client redisClient + encDec encoding.EncoderDecoder } -// Publish an event into passed channel. -func (ps *Redis) Publish(channelName string, event Event) error { - if channelName == "" { - return errors.New("empty channel name is not allowed") - } - - if ps.isClosed() { - return errors.New("closed") - } - - b, err := msgpack.Marshal(redisEvent{Name: event.Name(), Data: event.Data()}) - if err != nil { - return err - } +var ( // ensure interface implementation + _ Publisher[any] = (*Redis[any])(nil) + _ Subscriber[any] = (*Redis[any])(nil) +) - return ps.rdb.Publish(ps.ctx, channelName, string(b)).Err() +func NewRedis[T any](c redisClient, encDec encoding.EncoderDecoder) *Redis[T] { + return &Redis[T]{client: c, encDec: encDec} } -// Subscribe to the named channel and receive Event's into the passed channel. -// -// Note: that this function does not wait on a response from redis server, so the subscription may not be active -// immediately. -func (ps *Redis) Subscribe(channelName string, channel chan<- Event) error { //nolint:funlen - if channelName == "" { - return errors.New("empty channel name is not allowed") - } - - if ps.isClosed() { - return errors.New("closed") - } - - // create subscription if needed - ps.subsMu.Lock() - if _, exists := ps.subs[channelName]; !exists { - ps.subs[channelName] = &redisSubscription{ - stop: make(chan struct{}, 1), - subscribers: make(map[chan<- Event]struct{}), - } - } - ps.subsMu.Unlock() - - ps.subs[channelName].subscribersMu.Lock() - defer ps.subs[channelName].subscribersMu.Unlock() - - // append passed channel into subscribers map - if _, exists := ps.subs[channelName].subscribers[channel]; exists { - return errors.New("already subscribed") - } - - ps.subs[channelName].subscribers[channel] = struct{}{} - - ps.subs[channelName].start.Do(func() { - started := make(chan struct{}, 1) - - go func(sub *redisSubscription) { - var ( - pubSub = ps.rdb.Subscribe(ps.ctx, channelName) - ch = pubSub.Channel() - ) +func (ps *Redis[T]) Subscribe(ctx context.Context, topic string) (_ <-chan T, unsubscribe func(), _ error) { + var ( + pubSub = ps.client.Subscribe(ctx, topic) + sub = make(chan T) + stop, stopped = make(chan struct{}), make(chan struct{}) + ) + + go func() { + defer close(stopped) // notify unsubscribe that the goroutine is stopped + + var channel = pubSub.Channel() // get the channel for the topic + + defer func() { _ = pubSub.Close() }() // guaranty that pubSub will be closed + + for { + select { + case <-ctx.Done(): + return // check the context + case <-stop: + return // check the stopping notification + case msg := <-channel: // wait for the message + if msg == nil { + continue + } - defer func() { - _ = pubSub.Close() - _ = pubSub.Unsubscribe(ps.ctx, channelName) - }() + var event T - started <- struct{}{} - close(started) + if err := ps.encDec.Decode([]byte(msg.Payload), &event); err != nil { + continue + } - for { - select { - case <-sub.stop: + select { // send the event to the subscriber + case <-ctx.Done(): return - - case msg, opened := <-ch: - if !opened { - return - } - - var rawEvent redisEvent - if err := msgpack.Unmarshal([]byte(msg.Payload), &rawEvent); err != nil { - continue - } - - e := event{name: rawEvent.Name, data: rawEvent.Data} - - sub.subscribersMu.Lock() - - for receiver := range sub.subscribers { // iterate over all subscribed channels - go func(target chan<- Event) { - target <- &e // <- panic can be occurred here (if channel was closed too early from outside) - }(receiver) - } - - sub.subscribersMu.Unlock() + case <-stop: + return + case sub <- event: } } - }(ps.subs[channelName]) - - <-started // make sure that subscription was started - }) - - return nil -} - -// Unsubscribe the subscription to the named channel for the passed events channel. Be careful with channel closing, -// this can call the panics if some Event's scheduled for publishing. -func (ps *Redis) Unsubscribe(channelName string, channel chan Event) error { - if channelName == "" { - return errors.New("empty channel name is not allowed") - } - - if ps.isClosed() { - return errors.New("closed") - } - - ps.subsMu.Lock() - defer ps.subsMu.Unlock() - - if _, exists := ps.subs[channelName]; !exists { - return errors.New("subscription does not exists") - } - - if _, exists := ps.subs[channelName].subscribers[channel]; !exists { - return errors.New("channel was not subscribed") - } + } + }() - // cancel subscription - ps.subs[channelName].subscribersMu.Lock() - delete(ps.subs[channelName].subscribers, channel) - subscribersCount := len(ps.subs[channelName].subscribers) - ps.subs[channelName].subscribersMu.Unlock() - - // in case when there is no one active subscriber for the channel - we should to notify redis subscriber about - // stopping and clean up - if subscribersCount == 0 { - ps.subs[channelName].stop <- struct{}{} - close(ps.subs[channelName].stop) - delete(ps.subs, channelName) - } + return sub, sync.OnceFunc(func() { + _ = pubSub.Close() // close the subscription - return nil -} + close(stop) // notify the goroutine to stop -func (ps *Redis) isClosed() (isClosed bool) { - ps.closedMu.Lock() - isClosed = ps.closed - ps.closedMu.Unlock() + <-stopped // wait for the goroutine to stop - return + close(sub) // close the subscription channel + }), nil } -// Close this publisher/subscriber. This function can be called only once. -func (ps *Redis) Close() error { - if ps.isClosed() { - return errors.New("already closed") - } - - ps.closedMu.Lock() - ps.closed = true - ps.closedMu.Unlock() - - ps.subsMu.Lock() - for channelName, sub := range ps.subs { - sub.stop <- struct{}{} - close(sub.stop) - delete(ps.subs, channelName) +func (ps *Redis[T]) Publish(ctx context.Context, topic string, event T) error { + data, mErr := ps.encDec.Encode(event) + if mErr != nil { + return mErr } - ps.subsMu.Unlock() - return nil + return ps.client.Publish(ctx, topic, data).Err() } diff --git a/internal/pubsub/redis_test.go b/internal/pubsub/redis_test.go index 2f7d3844..fc265e7c 100644 --- a/internal/pubsub/redis_test.go +++ b/internal/pubsub/redis_test.go @@ -1,256 +1,36 @@ package pubsub_test import ( - "bytes" - "context" - "runtime" - "strconv" - "sync" "testing" - "time" "github.com/alicebob/miniredis/v2" - "github.com/go-redis/redis/v8" - "github.com/stretchr/testify/assert" + "github.com/redis/go-redis/v9" - "gh.tarampamp.am/webhook-tester/internal/pubsub" + "gh.tarampamp.am/webhook-tester/v2/internal/pubsub" ) -func TestRedis_PublishErrors(t *testing.T) { - mini, err := miniredis.Run() - assert.NoError(t, err) - - defer mini.Close() - - ps := pubsub.NewRedis(context.Background(), redis.NewClient(&redis.Options{Addr: mini.Addr()})) - defer func() { _ = ps.Close() }() - - assert.EqualError(t, ps.Publish("", pubsub.NewRequestRegisteredEvent("bar")), "empty channel name is not allowed") -} - -func TestRedis_PublishAndReceive(t *testing.T) { - t.Parallel() - - mini, err := miniredis.Run() - assert.NoError(t, err) - - defer mini.Close() - - ps := pubsub.NewRedis(context.Background(), redis.NewClient(&redis.Options{Addr: mini.Addr()})) - defer func() { _ = ps.Close() }() - - ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) - defer cancel() - - var event1, event2 = pubsub.NewRequestRegisteredEvent("bar"), pubsub.NewRequestRegisteredEvent("baz") - - eventsAreEquals := func(t *testing.T, a, b pubsub.Event) bool { - t.Helper() - - if !bytes.Equal(a.Data(), b.Data()) { - return false - } - - if a.Name() != b.Name() { - return false - } - - return true - } - - var wg sync.WaitGroup - - for range 50 { - wg.Add(1) - - go func() { // each of subscriber must receive a copy of published event - defer wg.Done() - - ch := make(chan pubsub.Event, 2) - defer close(ch) - - assert.NoError(t, ps.Subscribe("foo", ch)) - - defer func() { assert.NoError(t, ps.Unsubscribe("foo", ch)) }() - - receivedEvents := make([]pubsub.Event, 0, 2) - - for range cap(receivedEvents) { - select { - case <-ctx.Done(): - t.Error(ctx.Err()) - - return - - case e := <-ch: - receivedEvents = append(receivedEvents, e) - } - } - - assert.Len(t, receivedEvents, 2) - - for j := range receivedEvents { - if e := receivedEvents[j]; !eventsAreEquals(t, e, event1) && !eventsAreEquals(t, e, event2) { - t.Errorf("received events must be one of expected, but got: %+v", e) - } - } - }() - } - - runtime.Gosched() - <-time.After(time.Millisecond * 50) // make sure that all subscribes was subscribed successfully - - assert.NoError(t, ps.Publish("foo", event1)) - assert.NoError(t, ps.Publish("foo", event2)) - - wg.Wait() -} - -func TestRedis_Close(t *testing.T) { - mini, err := miniredis.Run() - assert.NoError(t, err) - - defer mini.Close() - - ps := pubsub.NewRedis(context.Background(), redis.NewClient(&redis.Options{Addr: mini.Addr()})) - defer func() { _ = ps.Close() }() - - assert.NoError(t, ps.Close()) - - ch := make(chan pubsub.Event) - - assert.EqualError(t, ps.Publish("foo", pubsub.NewRequestRegisteredEvent("bar")), "closed") - assert.EqualError(t, ps.Subscribe("foo", ch), "closed") - assert.EqualError(t, ps.Unsubscribe("foo", ch), "closed") - assert.EqualError(t, ps.Close(), "already closed") -} - -func TestRedis_Unsubscribe(t *testing.T) { - t.Parallel() - - mini, err := miniredis.Run() - assert.NoError(t, err) - - defer mini.Close() - - ps := pubsub.NewRedis(context.Background(), redis.NewClient(&redis.Options{Addr: mini.Addr()})) - defer func() { _ = ps.Close() }() - - for i := range 20 { - t.Run("attempt #"+strconv.Itoa(i), func(t *testing.T) { - ch1, ch2 := make(chan pubsub.Event, 1), make(chan pubsub.Event, 1) - - assert.NoError(t, ps.Subscribe("foo", ch1)) - - <-time.After(time.Millisecond * 5) - runtime.Gosched() - - assert.NoError(t, ps.Subscribe("foo", ch2)) // will be not unsubscribed for a test - assert.EqualError(t, ps.Unsubscribe("", ch1), "empty channel name is not allowed") - - assert.NoError(t, ps.Unsubscribe("foo", ch1)) - assert.EqualError(t, ps.Unsubscribe("baz", ch1), "subscription does not exists") - assert.EqualError(t, ps.Unsubscribe("foo", ch1), "channel was not subscribed") // repeated op - - assert.NoError(t, ps.Publish("foo", pubsub.NewRequestRegisteredEvent("bar"))) - - <-time.After(time.Millisecond * 50) - runtime.Gosched() - - assert.Len(t, ch1, 0) - assert.Len(t, ch2, 1) - - // close(ch1); close(ch2) // <- do not do this due race reasons - - assert.NoError(t, ps.Unsubscribe("foo", ch2)) - }) - } -} - -func TestRedis_Subscribe(t *testing.T) { - mini, err := miniredis.Run() - assert.NoError(t, err) - - defer mini.Close() - - ps := pubsub.NewRedis(context.Background(), redis.NewClient(&redis.Options{Addr: mini.Addr()})) - defer func() { _ = ps.Close() }() - - ch := make(chan pubsub.Event) - defer close(ch) - - assert.NoError(t, ps.Subscribe("foo", ch)) - - assert.EqualError(t, ps.Subscribe("", ch), "empty channel name is not allowed") - - assert.EqualError(t, ps.Subscribe("foo", ch), "already subscribed") // repeated -} - -func TestRedis_UnsubscribeWithChannelClosingWithoutReading(t *testing.T) { +func TestRedis_Publish_and_Receive(t *testing.T) { t.Parallel() - mini, err := miniredis.Run() - assert.NoError(t, err) - - defer mini.Close() - - ps := pubsub.NewRedis(context.Background(), redis.NewClient(&redis.Options{Addr: mini.Addr()})) - defer func() { _ = ps.Close() }() - - for range 1_000 { - ch := make(chan pubsub.Event) - - assert.NoError(t, ps.Subscribe("foo", ch)) - - assert.NoError(t, ps.Publish("foo", pubsub.NewRequestRegisteredEvent("bar"))) - - assert.NoError(t, ps.Unsubscribe("foo", ch)) - } - - for range 1_000 { - ps2 := pubsub.NewInMemory() - ch := make(chan pubsub.Event) - - assert.NoError(t, ps2.Subscribe("foo", ch)) - - assert.NoError(t, ps2.Publish("foo", pubsub.NewRequestRegisteredEvent("bar"))) + var mini = miniredis.RunT(t) - assert.NoError(t, ps2.Close()) - } + testPublishAndReceive(t, func() pubSub[any] { + return pubsub.NewRedis[any]( + redis.NewClient(&redis.Options{Addr: mini.Addr()}), + encDec, + ) + }) } -func BenchmarkRedis_PublishAndReceive(b *testing.B) { - b.ReportAllocs() - - mini, err := miniredis.Run() - if err != nil { - b.Fatal(err) - } - defer mini.Close() - - ps := pubsub.NewRedis(context.Background(), redis.NewClient(&redis.Options{Addr: mini.Addr()})) - defer func() { _ = ps.Close() }() - - ch := make(chan pubsub.Event) - defer close(ch) - - if err = ps.Subscribe("foo", ch); err != nil { - b.Fatal(err) - } - - defer func() { _ = ps.Unsubscribe("foo", ch) }() - - event := pubsub.NewRequestRegisteredEvent("bar") - - b.ResetTimer() - - for i := 0; i < b.N; i++ { - if err = ps.Publish("foo", event); err != nil { - b.Fatal(err) - } - - if e := <-ch; !bytes.Equal(e.Data(), event.Data()) || e.Name() != event.Name() { - b.Fatal("wrong event received") - } - } -} +// func TestRedis_RaceProvocation(t *testing.T) { +// t.Parallel() +// +// var mini = miniredis.RunT(t) +// +// testRaceProvocation(t, func() pubSub[any] { +// return pubsub.NewRedis[any]( +// redis.NewClient(&redis.Options{Addr: mini.Addr()}), +// encDec, +// ) +// }) +// } diff --git a/internal/storage/inmemory.go b/internal/storage/inmemory.go index 1075c0a8..07f988a4 100644 --- a/internal/storage/inmemory.go +++ b/internal/storage/inmemory.go @@ -1,369 +1,376 @@ package storage import ( + "context" "errors" + "fmt" + "io" "sort" "sync" + "sync/atomic" "time" -) - -type inmemorySession struct { - uuid string - content []byte - code uint16 - contentType string - delay time.Duration - createdAt time.Time - requests map[string]*inmemoryRequest // key is request UUID - - expiresAtNano int64 -} -func (s *inmemorySession) UUID() string { return s.uuid } // UUID unique session ID. -func (s *inmemorySession) Content() []byte { return s.content } // Content session server response content. -func (s *inmemorySession) Code() uint16 { return s.code } // Code default server response code. -func (s *inmemorySession) ContentType() string { return s.contentType } // ContentType response content type. -func (s *inmemorySession) Delay() time.Duration { return s.delay } // Delay before response sending. -func (s *inmemorySession) CreatedAt() time.Time { return s.createdAt } // CreatedAt creation time. - -type inmemoryRequest struct { - uuid string - clientAddr string - method string - content []byte - headers map[string]string - uri string - createdAt time.Time -} + "github.com/google/uuid" +) -func (r *inmemoryRequest) UUID() string { return r.uuid } // UUID returns unique request ID. -func (r *inmemoryRequest) ClientAddr() string { return r.clientAddr } // ClientAddr client hostname or IP. -func (r *inmemoryRequest) Method() string { return r.method } // Method HTTP method name. -func (r *inmemoryRequest) Content() []byte { return r.content } // Content request body (payload). -func (r *inmemoryRequest) Headers() map[string]string { return r.headers } // Headers HTTP request headers. -func (r *inmemoryRequest) URI() string { return r.uri } // URI Uniform Resource Identifier. -func (r *inmemoryRequest) CreatedAt() time.Time { return r.createdAt } // CreatedAt creation time. +type ( + InMemory struct { + sessionTTL time.Duration + maxRequests uint32 + sessions syncMap[ /* sID */ string, *sessionData] + cleanupInterval time.Duration -var ErrClosed = errors.New("closed") + close chan struct{} + closed atomic.Bool + } -type InMemory struct { - sessionTTL time.Duration - maxRequests uint16 + sessionData struct { + sync.Mutex + session Session + requests syncMap[ /* rID */ string, Request] + } +) - cleanupInterval time.Duration +var ( // ensure interface implementation + _ Storage = (*InMemory)(nil) + _ io.Closer = (*InMemory)(nil) +) - storageMu sync.RWMutex - storage map[string]*inmemorySession // key is session UUID +type InMemoryOption func(*InMemory) - close chan struct{} - closedMu sync.RWMutex - closed bool +// WithInMemoryCleanupInterval sets the cleanup interval for expired sessions. +func WithInMemoryCleanupInterval(v time.Duration) InMemoryOption { + return func(s *InMemory) { s.cleanupInterval = v } } -const defaultInMemoryCleanupInterval = time.Second // default cleanup interval - -// NewInMemory creates inmemory storage. -func NewInMemory(sessionTTL time.Duration, maxRequests uint16, cleanup ...time.Duration) *InMemory { - ci := defaultInMemoryCleanupInterval +// NewInMemory creates a new in-memory storage with the given session TTL and the maximum number of stored requests. +// Note that the cleanup goroutine is started automatically if the cleanup interval is greater than zero. +// To stop the cleanup goroutine and close the storage, call the InMemory.Close method. +func NewInMemory(sessionTTL time.Duration, maxRequests uint32, opts ...InMemoryOption) *InMemory { + var s = InMemory{ + sessionTTL: sessionTTL, + maxRequests: maxRequests, + close: make(chan struct{}), + cleanupInterval: time.Second, // default cleanup interval + } - if len(cleanup) > 0 { - ci = cleanup[0] + for _, opt := range opts { + opt(&s) } - s := &InMemory{ - sessionTTL: sessionTTL, - maxRequests: maxRequests, - cleanupInterval: ci, - storage: make(map[string]*inmemorySession), - close: make(chan struct{}, 1), + if s.cleanupInterval > time.Duration(0) { + go s.cleanup() // start cleanup goroutine } - go s.cleanup() - return s + return &s } -func (s *InMemory) cleanup() { - defer close(s.close) +// newID generates a new (unique) ID. +func (*InMemory) newID() string { return uuid.New().String() } - timer := time.NewTimer(s.cleanupInterval) +func (s *InMemory) cleanup() { + var timer = time.NewTimer(s.cleanupInterval) defer timer.Stop() + var ctx = context.Background() + + defer func() { // cleanup on exit + s.sessions.Range(func(sID string, _ *sessionData) bool { + _ = s.DeleteSession(ctx, sID) + + return true + }) + }() + for { select { - case <-s.close: - s.storageMu.Lock() - for id := range s.storage { - delete(s.storage, id) - } - s.storageMu.Unlock() - + case <-s.close: // close signal received return - case <-timer.C: - s.storageMu.Lock() - var now = time.Now().UnixNano() + var now = time.Now() - for id, session := range s.storage { - if now > session.expiresAtNano { - delete(s.storage, id) + s.sessions.Range(func(sID string, data *sessionData) bool { + data.Lock() + var expiresAt = data.session.ExpiresAt + data.Unlock() + + if expiresAt.Before(now) { + _ = s.DeleteSession(ctx, sID) } - } - s.storageMu.Unlock() + + return true + }) timer.Reset(s.cleanupInterval) } } } -func (s *InMemory) isClosed() (closed bool) { - s.closedMu.RLock() - closed = s.closed - s.closedMu.RUnlock() +func (s *InMemory) NewSession(ctx context.Context, session Session, id ...string) (sID string, _ error) { + if err := ctx.Err(); err != nil { + return "", err // context is done + } else if s.closed.Load() { + return "", ErrClosed // storage is closed + } - return -} + var now = time.Now() -// Close current storage with data invalidation. -func (s *InMemory) Close() error { - if s.isClosed() { - return ErrClosed + if len(id) > 0 { // use the specified ID + if len(id[0]) == 0 { + return "", errors.New("empty session ID") + } + + sID = id[0] + + // check if the session with the specified ID already exists + if _, ok := s.sessions.Load(sID); ok { + return "", fmt.Errorf("session %s already exists", sID) + } + } else { + sID = s.newID() // generate a new ID } - s.closedMu.Lock() - s.closed = true - s.closedMu.Unlock() + session.CreatedAtUnixMilli, session.ExpiresAt = now.UnixMilli(), now.Add(s.sessionTTL) - s.close <- struct{}{} + s.sessions.Store(sID, &sessionData{session: session}) - return nil + return } -// GetSession returns session data. -func (s *InMemory) GetSession(uuid string) (Session, error) { - if s.isClosed() { - return nil, ErrClosed +func (s *InMemory) GetSession(ctx context.Context, sID string) (*Session, error) { + if err := ctx.Err(); err != nil { + return nil, err // context is done + } else if s.closed.Load() { + return nil, ErrClosed // storage is closed } - s.storageMu.RLock() - session, ok := s.storage[uuid] - s.storageMu.RUnlock() + data, ok := s.sessions.Load(sID) + if !ok { + return nil, ErrSessionNotFound // not found + } - if ok { - // session has been expired? - if time.Now().UnixNano() > session.expiresAtNano { - s.storageMu.Lock() - delete(s.storage, uuid) - s.storageMu.Unlock() + data.Lock() + var expiresAt = data.session.ExpiresAt + data.Unlock() - return nil, nil // session has been expired (not found) - } + if expiresAt.Before(time.Now()) { + s.sessions.Delete(sID) - return session, nil + return nil, ErrSessionNotFound // session has been expired } - return nil, nil // not found + return &data.session, nil } -// CreateSession creates new session in storage using passed data. -func (s *InMemory) CreateSession(content []byte, code uint16, contentType string, delay time.Duration, sessionUUID ...string) (string, error) { //nolint:lll - if s.isClosed() { - return "", ErrClosed +func (s *InMemory) AddSessionTTL(ctx context.Context, sID string, howMuch time.Duration) error { + if err := ctx.Err(); err != nil { + return err // context is done + } else if s.closed.Load() { + return ErrClosed // storage is closed } - var id string - - if len(sessionUUID) == 1 && IsValidUUID(sessionUUID[0]) { - id = sessionUUID[0] - } else { - id = NewUUID() + data, ok := s.sessions.Load(sID) + if !ok { + return ErrSessionNotFound // session not found } - now := time.Now() - - s.storageMu.Lock() - s.storage[id] = &inmemorySession{ - uuid: id, - content: content, - code: code, - contentType: contentType, - delay: delay, - createdAt: now, - requests: make(map[string]*inmemoryRequest, s.maxRequests), - expiresAtNano: now.UnixNano() + s.sessionTTL.Nanoseconds(), - } - s.storageMu.Unlock() + data.Lock() + data.session.ExpiresAt = data.session.ExpiresAt.Add(howMuch) + data.Unlock() - return id, nil + return nil } -// DeleteSession deletes session with passed UUID. -func (s *InMemory) DeleteSession(uuid string) (bool, error) { - session, err := s.GetSession(uuid) - if err != nil { - return false, err +func (s *InMemory) DeleteSession(ctx context.Context, sID string) error { + if err := ctx.Err(); err != nil { + return err // context is done + } else if s.closed.Load() { + return ErrClosed // storage is closed } - if session != nil { - s.storageMu.Lock() - delete(s.storage, uuid) - s.storageMu.Unlock() + if data, ok := s.sessions.LoadAndDelete(sID); !ok { + return ErrSessionNotFound // session not found + } else { + data.requests.Range(func(rID string, _ Request) bool { // delete all session requests + data.requests.Delete(rID) - return true, nil // found and deleted + return true + }) } - return false, nil // session was not found + return nil } -// DeleteRequests deletes stored requests for session with passed UUID. -func (s *InMemory) DeleteRequests(uuid string) (bool, error) { - session, err := s.GetSession(uuid) - if err != nil { - return false, err +func (s *InMemory) NewRequest(ctx context.Context, sID string, r Request) (rID string, _ error) { + if err := ctx.Err(); err != nil { + return "", err // context is done + } else if s.closed.Load() { + return "", ErrClosed // storage is closed } - if session != nil { - s.storageMu.Lock() - defer s.storageMu.Unlock() + data, ok := s.sessions.Load(sID) + if !ok { + return "", ErrSessionNotFound // session not found + } - if len(s.storage[uuid].requests) == 0 { - return false, nil // nothing to delete - } + rID, r.CreatedAtUnixMilli = s.newID(), time.Now().UnixMilli() - for id := range s.storage[uuid].requests { - delete(s.storage[uuid].requests, id) + data.requests.Store(rID, r) + + { // limit stored requests count + type rq struct { // a runtime representation of the request, used for sorting + id string + ts int64 } - return true, nil // requests deleted + var all = make([]rq, 0) // a slice for all session requests + + data.requests.Range(func(id string, req Request) bool { // iterate over all session requests and fill the slice + all = append(all, rq{id, req.CreatedAtUnixMilli}) + + return true + }) + + if len(all) > int(s.maxRequests) { // if the number of requests exceeds the limit + sort.Slice(all, func(i, j int) bool { return all[i].ts > all[j].ts }) // sort requests by creation time + + for i := int(s.maxRequests); i < len(all); i++ { // delete the oldest requests + data.requests.Delete(all[i].id) + } + } } - return false, nil // session was not found + return } -// CreateRequest creates new request in storage using passed data and updates expiration time for session and all -// stored requests for the session. -func (s *InMemory) CreateRequest(sessionUUID, clientAddr, method, uri string, content []byte, headers map[string]string) (string, error) { //nolint:lll - session, err := s.GetSession(sessionUUID) - if err != nil { - return "", err +func (s *InMemory) GetRequest(ctx context.Context, sID, rID string) (*Request, error) { + if err := ctx.Err(); err != nil { + return nil, err // context is done + } else if s.closed.Load() { + return nil, ErrClosed // storage is closed } - if session != nil { - s.storageMu.Lock() - defer s.storageMu.Unlock() - - now := time.Now() - id := NewUUID() - - // append new request - s.storage[sessionUUID].requests[id] = &inmemoryRequest{ - uuid: id, - clientAddr: clientAddr, - method: method, - content: content, - headers: headers, - uri: uri, - createdAt: now, - } + session, sessionOk := s.sessions.Load(sID) + if !sessionOk { + return nil, ErrSessionNotFound // session not found + } - // update session TTL - s.storage[sessionUUID].expiresAtNano = now.UnixNano() + s.sessionTTL.Nanoseconds() + if request, ok := session.requests.Load(rID); ok { + return &request, nil + } - // limit stored requests count - if rl := len(s.storage[sessionUUID].requests); rl > int(s.maxRequests) { - type rq struct { - id string - ts int64 - } + return nil, ErrRequestNotFound // request not found +} - allReq := make([]rq, 0, rl) +func (s *InMemory) GetAllRequests(ctx context.Context, sID string) (map[string]Request, error) { + if err := ctx.Err(); err != nil { + return nil, err // context is done + } else if s.closed.Load() { + return nil, ErrClosed // storage is closed + } - for k := range s.storage[sessionUUID].requests { - allReq = append(allReq, rq{k, s.storage[sessionUUID].requests[k].createdAt.UnixNano()}) - } + session, sessionOk := s.sessions.Load(sID) + if !sessionOk { + return nil, ErrSessionNotFound // session not found + } - sort.Slice(allReq, func(i, j int) bool { return allReq[i].ts > allReq[j].ts }) + var all = make(map[string]Request) - for i, plan := 0, allReq[int(s.maxRequests):]; i < len(plan); i++ { - delete(s.storage[sessionUUID].requests, plan[i].id) - } - } + session.requests.Range(func(id string, req Request) bool { + all[id] = req - return id, nil // request added - } + return true + }) - return "", nil // session was not found + return all, nil } -// GetRequest returns request data. -func (s *InMemory) GetRequest(sessionUUID, requestUUID string) (Request, error) { - session, err := s.GetSession(sessionUUID) - if err != nil { - return nil, err +func (s *InMemory) DeleteRequest(ctx context.Context, sID, rID string) error { + if err := ctx.Err(); err != nil { + return err // context is done + } else if s.closed.Load() { + return ErrClosed // storage is closed } - if session != nil { - s.storageMu.RLock() - defer s.storageMu.RUnlock() - - if _, reqOk := s.storage[sessionUUID].requests[requestUUID]; reqOk { - return s.storage[sessionUUID].requests[requestUUID], nil - } + session, sessionOk := s.sessions.Load(sID) + if !sessionOk { + return ErrSessionNotFound // session not found + } - return nil, nil // request was not found + if _, ok := session.requests.LoadAndDelete(rID); ok { + return nil } - return nil, nil // session was not found + return ErrRequestNotFound // request not found } -// GetAllRequests returns all request as a slice of structures. -func (s *InMemory) GetAllRequests(sessionUUID string) ([]Request, error) { - session, err := s.GetSession(sessionUUID) - if err != nil { - return nil, err +func (s *InMemory) DeleteAllRequests(ctx context.Context, sID string) error { + if err := ctx.Err(); err != nil { + return err // context is done + } else if s.closed.Load() { + return ErrClosed // storage is closed } - if session != nil { - s.storageMu.RLock() - defer s.storageMu.RUnlock() + session, sessionOk := s.sessions.Load(sID) + if !sessionOk { + return ErrSessionNotFound // session not found + } - if len(s.storage[sessionUUID].requests) == 0 { - return nil, nil // no requests - } + // delete all session requests + session.requests.Range(func(rID string, _ Request) bool { + session.requests.Delete(rID) - result := make([]Request, 0, len(s.storage[sessionUUID].requests)) - for id := range s.storage[sessionUUID].requests { - result = append(result, s.storage[sessionUUID].requests[id]) - } + return true + }) - sort.Slice(result, func(i, j int) bool { - return result[i].(*inmemoryRequest).createdAt.UnixNano() < result[j].(*inmemoryRequest).createdAt.UnixNano() - }) + return nil +} + +// Close closes the storage and stops the cleanup goroutine. Any further calls to the storage methods will +// return ErrClosed. +func (s *InMemory) Close() error { + if s.closed.CompareAndSwap(false, true) { + close(s.close) - return result, nil + return nil } - return nil, nil // session was not found + return ErrClosed } -// DeleteRequest deletes stored request with passed session and request UUIDs. -func (s *InMemory) DeleteRequest(sessionUUID, requestUUID string) (bool, error) { - session, err := s.GetSession(sessionUUID) - if err != nil { - return false, err - } +// syncMap is a thread-safe map with strong-typed keys and values. +type syncMap[K comparable, V any] struct{ m sync.Map } - if session != nil { - s.storageMu.Lock() - defer s.storageMu.Unlock() +// Delete deletes the value for a key. +func (m *syncMap[K, V]) Delete(key K) { m.m.Delete(key) } - if _, ok := s.storage[sessionUUID].requests[requestUUID]; ok { - delete(s.storage[sessionUUID].requests, requestUUID) +// Load returns the value stored in the map for a key, or nil if no value is present. +// The ok result indicates whether value was found in the map. +func (m *syncMap[K, V]) Load(key K) (value V, ok bool) { + v, ok := m.m.Load(key) + if !ok { + return value, ok + } - return true, nil // deleted - } + return v.(V), ok +} - return false, nil // request was not found +// LoadAndDelete deletes the value for a key, returning the previous value if any. +// The loaded result reports whether the key was present. +func (m *syncMap[K, V]) LoadAndDelete(key K) (value V, loaded bool) { + v, loaded := m.m.LoadAndDelete(key) + if !loaded { + return value, loaded } - return false, nil // session was not found + return v.(V), loaded } + +// Range calls f sequentially for each key and value present in the map. +// If f returns false, range stops the iteration. +func (m *syncMap[K, V]) Range(f func(key K, value V) bool) { + m.m.Range(func(key, value any) bool { return f(key.(K), value.(V)) }) +} + +// Store sets the value for a key. +func (m *syncMap[K, V]) Store(key K, value V) { m.m.Store(key, value) } diff --git a/internal/storage/inmemory_test.go b/internal/storage/inmemory_test.go index 0d4234de..62ce9043 100644 --- a/internal/storage/inmemory_test.go +++ b/internal/storage/inmemory_test.go @@ -1,298 +1,71 @@ package storage_test import ( - "runtime" - "sync" + "context" "testing" "time" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" - "gh.tarampamp.am/webhook-tester/internal/storage" + "gh.tarampamp.am/webhook-tester/v2/internal/storage" ) -func TestInMemoryWebSockets_SessionCreateReadDelete(t *testing.T) { - s := storage.NewInMemory(time.Minute, 1, time.Second) - defer s.Close() +func TestInMemory_Session_CreateReadDelete(t *testing.T) { + t.Parallel() - sessionUUID, creationErr := s.CreateSession([]byte("foo bar"), 201, "text/javascript", time.Second*123) - assert.NoError(t, creationErr) - - noSession, noSessionErr := s.GetSession("foo") - assert.Nil(t, noSession) - assert.NoError(t, noSessionErr) - - gotSession, gotSessionErr := s.GetSession(sessionUUID) - - assert.NoError(t, gotSessionErr) - assert.Equal(t, sessionUUID, gotSession.UUID()) - assert.Equal(t, time.Now().Unix(), gotSession.CreatedAt().Unix()) - assert.Equal(t, (time.Second * 123).Nanoseconds(), gotSession.Delay().Nanoseconds()) - assert.Equal(t, "text/javascript", gotSession.ContentType()) - assert.Equal(t, []byte("foo bar"), gotSession.Content()) - assert.Equal(t, uint16(201), gotSession.Code()) - assert.Equal(t, sessionUUID, gotSession.UUID()) - - delNonExists, errDelNonExists := s.DeleteSession("foo") - assert.False(t, delNonExists) - assert.NoError(t, errDelNonExists) - - delExists, errDelExists := s.DeleteSession(sessionUUID) - assert.True(t, delExists) - assert.NoError(t, errDelExists) - - gotSessionAgain, gotSessionAgainErr := s.GetSession(sessionUUID) - assert.Nil(t, gotSessionAgain) - assert.NoError(t, gotSessionAgainErr) -} - -func TestInMemoryWebSockets_RequestCreateReadDelete(t *testing.T) { - s := storage.NewInMemory(time.Minute, 10, time.Nanosecond*100) - defer s.Close() - - sessionUUID, sessionCreationErr := s.CreateSession([]byte("foo bar"), 201, "text/javascript", 0) - assert.Nil(t, sessionCreationErr) - - requestUUID, creationErr := s.CreateRequest( - sessionUUID, - "2.3.4.5", - "GET", - "https://example.com/test", - []byte(`{"foo":123}`), - map[string]string{"foo": "bar"}, + testSessionCreateReadDelete(t, + func(sTTL time.Duration, maxReq uint32) storage.Storage { return storage.NewInMemory(sTTL, maxReq) }, + func(t time.Duration) { <-time.After(t) }, ) - assert.Nil(t, creationErr) - assert.NotEmpty(t, requestUUID) - - noRequest, noRequestErr := s.GetRequest(sessionUUID, "foo") - assert.Nil(t, noRequest) - assert.Nil(t, noRequestErr) - - request, getRequestErr := s.GetRequest(sessionUUID, requestUUID) - assert.Nil(t, getRequestErr) - assert.Equal(t, "2.3.4.5", request.ClientAddr()) - assert.Equal(t, []byte(`{"foo":123}`), request.Content()) - assert.Equal(t, map[string]string{"foo": "bar"}, request.Headers()) - assert.Equal(t, "https://example.com/test", request.URI()) - assert.Equal(t, "GET", request.Method()) - assert.Equal(t, time.Now().Unix(), request.CreatedAt().Unix()) - assert.Equal(t, requestUUID, request.UUID()) - - noDelResult, noDelErr := s.DeleteRequest(sessionUUID, "foo") - assert.False(t, noDelResult) - assert.NoError(t, noDelErr) - - delResult, delErr := s.DeleteRequest(sessionUUID, requestUUID) - assert.True(t, delResult) - assert.NoError(t, delErr) - - nowNoRequest, nowNoRequestErr := s.GetRequest(sessionUUID, requestUUID) - assert.Nil(t, nowNoRequest) - assert.NoError(t, nowNoRequestErr) -} - -func TestInMemoryWebSockets_RequestCreationLimit(t *testing.T) { - s := storage.NewInMemory(time.Minute, 2, time.Nanosecond*100) - defer s.Close() - - sessionUUID, _ := s.CreateSession([]byte("foo bar"), 201, "text/javascript", 0) - - _, _ = s.CreateRequest(sessionUUID, "1.1.1.1", "GET", "https://example.com/test", []byte(`{"foo":123}`), nil) - - requests, _ := s.GetAllRequests(sessionUUID) - assert.Len(t, requests, 1) - - _, _ = s.CreateRequest(sessionUUID, "2.2.2.2", "GET", "https://example.com/test", []byte(`{"foo":123}`), nil) - - requests, _ = s.GetAllRequests(sessionUUID) - assert.Len(t, requests, 2) - assert.Equal(t, "1.1.1.1", requests[0].ClientAddr()) - assert.Equal(t, "2.2.2.2", requests[1].ClientAddr()) - - _, _ = s.CreateRequest(sessionUUID, "3.3.3.3", "GET", "https://example.com/test", []byte(`{"foo":123}`), nil) - - requests, _ = s.GetAllRequests(sessionUUID) - assert.Len(t, requests, 2) - assert.Equal(t, "2.2.2.2", requests[0].ClientAddr()) - assert.Equal(t, "3.3.3.3", requests[1].ClientAddr()) } -func TestInMemoryWebSockets_GetAllRequests(t *testing.T) { - s := storage.NewInMemory(time.Minute, 10, time.Nanosecond*100) - defer s.Close() - - sessionUUID, sessionCreationErr := s.CreateSession([]byte("foo bar"), 201, "text/javascript", 0) - assert.NoError(t, sessionCreationErr) - - noRequests, noRequestsErr := s.GetAllRequests(sessionUUID) - assert.Nil(t, noRequests) - assert.NoError(t, noRequestsErr) - - noRequestsWrongSession, noRequestsWrongSessionErr := s.GetAllRequests("foo") - assert.Nil(t, noRequestsWrongSession) - assert.NoError(t, noRequestsWrongSessionErr) - - requestUUID, creationErr := s.CreateRequest(sessionUUID, "1.2.3.4", "GET", "https://test", []byte(`{"foo":123}`), nil) - assert.NoError(t, creationErr) - assert.NotEmpty(t, requestUUID) - - requests, requestsErr := s.GetAllRequests(sessionUUID) - assert.Len(t, requests, 1) - assert.NoError(t, requestsErr) - assert.Equal(t, "1.2.3.4", requests[0].ClientAddr()) -} - -func TestInMemoryWebSockets_DeleteRequests(t *testing.T) { - s := storage.NewInMemory(time.Minute, 10, time.Nanosecond*100) - defer s.Close() - - sessionUUID, sessionCreationErr := s.CreateSession([]byte("foo bar"), 201, "text/javascript", 0) - assert.NoError(t, sessionCreationErr) +func TestInMemory_Request_CreateReadDelete(t *testing.T) { + t.Parallel() - res, delErr := s.DeleteRequests(sessionUUID) - assert.False(t, res) - assert.NoError(t, delErr) - - _, _ = s.CreateRequest(sessionUUID, "1.1.1.1", "GET", "https://test", []byte(`{"foo":123}`), nil) - _, _ = s.CreateRequest(sessionUUID, "1.1.1.1", "GET", "https://test", []byte(`{"foo":123}`), nil) - - requests, _ := s.GetAllRequests(sessionUUID) - assert.Len(t, requests, 2) - - res2, delErr2 := s.DeleteRequests(sessionUUID) - assert.True(t, res2) - assert.NoError(t, delErr2) - - requests2, _ := s.GetAllRequests(sessionUUID) - assert.Nil(t, requests2) -} - -func TestInMemoryWebSockets_CreateRequestExpired(t *testing.T) { - s := storage.NewInMemory(time.Millisecond*10, 10, time.Minute) - defer s.Close() - - sessionUUID, err := s.CreateSession([]byte("foo bar"), 201, "text/javascript", 0) - assert.NoError(t, err) - assert.NotEmpty(t, sessionUUID) - - session, err := s.GetSession(sessionUUID) - assert.NoError(t, err) - assert.NotNil(t, session) - - runtime.Gosched() - <-time.After(time.Millisecond * 11) - - session, err = s.GetSession(sessionUUID) - assert.NoError(t, err) - assert.Nil(t, session) // important + testRequestCreateReadDelete(t, + func(sTTL time.Duration, maxReq uint32) storage.Storage { return storage.NewInMemory(sTTL, maxReq) }, + func(t time.Duration) { <-time.After(t) }, + ) } -func TestInMemoryWebSockets_GetRequestExpired(t *testing.T) { - s := storage.NewInMemory(time.Millisecond*10, 10, time.Minute) - defer s.Close() - - sessionUUID, err := s.CreateSession([]byte("foo bar"), 201, "text/javascript", 0) - assert.NoError(t, err) - assert.NotEmpty(t, sessionUUID) - requestUUID, err := s.CreateRequest(sessionUUID, "1.1.1.1", "GET", "", []byte{}, nil) - assert.NoError(t, err) - - request, err := s.GetRequest(sessionUUID, requestUUID) - assert.NoError(t, err) - assert.NotNil(t, request) - - runtime.Gosched() - <-time.After(time.Millisecond * 11) - - request, err = s.GetRequest(sessionUUID, requestUUID) - assert.NoError(t, err) - assert.Nil(t, request) // important -} +func TestInMemory_Close(t *testing.T) { + t.Parallel() -func TestInMemoryWebSockets_ClosedStateProducesError(t *testing.T) { - s := storage.NewInMemory(time.Nanosecond*10, 10, time.Nanosecond*20) - assert.NoError(t, s.Close()) + var ctx = context.Background() - assert.ErrorIs(t, s.Close(), storage.ErrClosed) // 2nd call produces error + impl := storage.NewInMemory(time.Minute, 1) + require.NoError(t, impl.Close()) + require.ErrorIs(t, impl.Close(), storage.ErrClosed) // second close - _, err := s.GetSession("foo") - assert.ErrorIs(t, err, storage.ErrClosed) + _, err := impl.NewSession(ctx, storage.Session{}) + require.ErrorIs(t, err, storage.ErrClosed) - _, err = s.CreateSession([]byte("foo"), 202, "foo/bar", time.Second) - assert.ErrorIs(t, err, storage.ErrClosed) + _, err = impl.GetSession(ctx, "foo") + require.ErrorIs(t, err, storage.ErrClosed) - _, err = s.DeleteSession("foo") - assert.ErrorIs(t, err, storage.ErrClosed) + err = impl.DeleteSession(ctx, "foo") + require.ErrorIs(t, err, storage.ErrClosed) - _, err = s.DeleteRequests("foo") - assert.ErrorIs(t, err, storage.ErrClosed) + _, err = impl.NewRequest(ctx, "foo", storage.Request{}) + require.ErrorIs(t, err, storage.ErrClosed) - _, err = s.CreateRequest("foo", "1.1.1.1", "GET", "", []byte{}, nil) - assert.ErrorIs(t, err, storage.ErrClosed) + _, err = impl.GetRequest(ctx, "foo", "bar") + require.ErrorIs(t, err, storage.ErrClosed) - _, err = s.GetRequest("foo", "bar") - assert.ErrorIs(t, err, storage.ErrClosed) + _, err = impl.GetAllRequests(ctx, "foo") + require.ErrorIs(t, err, storage.ErrClosed) - _, err = s.GetAllRequests("foo") - assert.ErrorIs(t, err, storage.ErrClosed) + err = impl.DeleteRequest(ctx, "foo", "bar") + require.ErrorIs(t, err, storage.ErrClosed) - _, err = s.DeleteRequest("foo", "bar") - assert.ErrorIs(t, err, storage.ErrClosed) + err = impl.DeleteAllRequests(ctx, "foo") + require.ErrorIs(t, err, storage.ErrClosed) } -func TestInMemoryWebSockets_Concurrent(t *testing.T) { - var maxRequests = 10 - - s := storage.NewInMemory(time.Second, uint16(maxRequests), time.Nanosecond*10) - defer s.Close() - - var wg sync.WaitGroup - - for range 100 { - wg.Add(1) - - go func() { - sUUID, err := s.CreateSession([]byte("foo"), 202, "foo/bar", time.Second) - assert.NoError(t, err) - - _, err = s.GetSession(sUUID) - assert.NoError(t, err) - - for j := 1; j < 50; j++ { - reqUUID, err2 := s.CreateRequest(sUUID, "1.1.1.1", "GET", "", []byte{}, nil) - assert.NotEmpty(t, reqUUID) - assert.NoError(t, err2) - - req, err3 := s.GetRequest(sUUID, reqUUID) - assert.NotNil(t, req) - assert.NoError(t, err3) - - all, err4 := s.GetAllRequests(sUUID) - - if j >= maxRequests { - assert.Len(t, all, maxRequests) - } else { - assert.Len(t, all, j) - } - - assert.NoError(t, err4) - } - - allReq, err5 := s.GetAllRequests(sUUID) - assert.NoError(t, err5) - - _, err7 := s.DeleteRequest(sUUID, allReq[0].UUID()) - assert.NoError(t, err7) - - _, err8 := s.DeleteRequests(sUUID) - assert.NoError(t, err8) - - _, err6 := s.DeleteSession(sUUID) - assert.NoError(t, err6) - - wg.Done() - }() - } +func TestInMemory_RaceProvocation(t *testing.T) { + t.Parallel() - wg.Wait() + testRaceProvocation(t, func(sTTL time.Duration, maxReq uint32) storage.Storage { + return storage.NewInMemory(sTTL, maxReq, storage.WithInMemoryCleanupInterval(10*time.Nanosecond)) + }) } diff --git a/internal/storage/redis.go b/internal/storage/redis.go index 578fae7d..b16c469e 100644 --- a/internal/storage/redis.go +++ b/internal/storage/redis.go @@ -3,157 +3,198 @@ package storage import ( "context" "errors" - "sort" + "fmt" "time" - "github.com/go-redis/redis/v8" - "github.com/vmihailenco/msgpack/v5" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + + "gh.tarampamp.am/webhook-tester/v2/internal/encoding" ) -// Redis is redis storage implementation. -type Redis struct { - ctx context.Context - rdb *redis.Client - ttl time.Duration - maxRequests uint16 -} +type ( + Redis struct { + sessionTTL time.Duration + maxRequests uint32 + client redis.Cmdable + encDec encoding.EncoderDecoder + } +) + +var _ Storage = (*Redis)(nil) // ensure interface implementation + +type RedisOption func(*Redis) -// NewRedis creates new redis storage instance. -func NewRedis(ctx context.Context, rdb *redis.Client, sessionTTL time.Duration, maxRequests uint16) *Redis { //nolint:lll - return &Redis{ - ctx: ctx, - rdb: rdb, - ttl: sessionTTL, - maxRequests: maxRequests, +// NewRedis creates a new Redis storage. +// Notes: +// - sTTL is the session TTL (redis accuracy is in milliseconds) +// - maxReq is the maximum number of requests to store for the session +func NewRedis(c redis.Cmdable, sTTL time.Duration, maxReq uint32, opts ...RedisOption) *Redis { + var s = Redis{ + sessionTTL: sTTL, + maxRequests: maxReq, + client: c, + encDec: encoding.JSON{}, } + + for _, opt := range opts { + opt(&s) + } + + return &s } -// GetSession returns session data. -func (s *Redis) GetSession(uuid string) (Session, error) { - value, err := s.rdb.Get(s.ctx, redisKey(uuid).session()).Bytes() +// sessionKey returns the key for the session data. +func (*Redis) sessionKey(sID string) string { return "webhook-tester-v2:session:" + sID } - if err != nil { - if errors.Is(err, redis.Nil) { - return nil, nil // not found - } +// requestsKey returns the key for the requests list. +func (s *Redis) requestsKey(sID string) string { return s.sessionKey(sID) + ":requests" } - return nil, err - } +// requestKey returns the key for the request data. +func (s *Redis) requestKey(sID, rID string) string { return s.sessionKey(sID) + ":requests:" + rID } - var sData = redisSession{} +// newID generates a new (unique) ID. +func (*Redis) newID() string { return uuid.New().String() } - if msgpackErr := msgpack.Unmarshal(value, &sData); msgpackErr != nil { - return nil, msgpackErr +func (s *Redis) isSessionExists(ctx context.Context, sID string) (bool, error) { + count, err := s.client.Exists(ctx, s.sessionKey(sID)).Result() + if err != nil { + return false, err } - sData.Uuid = uuid - - return &sData, nil + return count == 1, nil } -// CreateSession creates new session in storage using passed data. -func (s *Redis) CreateSession(content []byte, code uint16, contentType string, delay time.Duration, sessionUUID ...string) (string, error) { //nolint:lll - sData := redisSession{ - RespContent: content, - RespCode: code, - RespContentType: contentType, - RespDelay: delay.Nanoseconds(), - TS: time.Now().Unix(), - } +func (s *Redis) NewSession(ctx context.Context, session Session, id ...string) (sID string, _ error) { + if len(id) > 0 { // use the specified ID + if len(id[0]) == 0 { + return "", errors.New("empty session ID") + } - packed, msgpackErr := msgpack.Marshal(sData) - if msgpackErr != nil { - return "", msgpackErr + sID = id[0] + + // check if the session with the specified ID already exists + if exists, err := s.isSessionExists(ctx, sID); err != nil { + return "", err + } else if exists { + return "", fmt.Errorf("session %s already exists", sID) + } + } else { + sID = s.newID() } - var id string + session.CreatedAtUnixMilli = time.Now().UnixMilli() - if len(sessionUUID) == 1 && IsValidUUID(sessionUUID[0]) { - id = sessionUUID[0] - } else { - id = NewUUID() + data, mErr := s.encDec.Encode(session) + if mErr != nil { + return "", mErr } - if err := s.rdb.Set(s.ctx, redisKey(id).session(), packed, s.ttl).Err(); err != nil { + if err := s.client.Set(ctx, s.sessionKey(sID), data, s.sessionTTL).Err(); err != nil { return "", err } - return id, nil + return sID, nil } -func (s *Redis) deleteKeys(keys ...string) (bool, error) { - cmdResult := s.rdb.Del(s.ctx, keys...) +func (s *Redis) GetSession(ctx context.Context, sID string) (*Session, error) { + data, rErr := s.client.Get(ctx, s.sessionKey(sID)).Bytes() + if rErr != nil { + if errors.Is(rErr, redis.Nil) { + return nil, ErrSessionNotFound + } + + return nil, rErr + } - if err := cmdResult.Err(); err != nil { - return false, err + expire, err := s.client.PTTL(ctx, s.sessionKey(sID)).Result() + if err != nil { + return nil, err } - if count, err := cmdResult.Result(); err != nil { - return false, err - } else if count == 0 { - return false, nil + var session Session + if uErr := s.encDec.Decode(data, &session); uErr != nil { + return nil, uErr } - return true, nil -} + session.ExpiresAt = time.Now().Add(expire) -// DeleteSession deletes session with passed UUID. -func (s *Redis) DeleteSession(uuid string) (bool, error) { - return s.deleteKeys(redisKey(uuid).session()) + return &session, nil } -// DeleteRequests deletes stored requests for session with passed UUID. -func (s *Redis) DeleteRequests(sessionUUID string) (bool, error) { - key := redisKey(sessionUUID) +func (s *Redis) AddSessionTTL(ctx context.Context, sID string, howMuch time.Duration) error { + currentTTL, tErr := s.client.PTTL(ctx, s.sessionKey(sID)).Result() + if tErr != nil { + return tErr + } + + if currentTTL < 0 { + switch { // https://redis.io/docs/latest/commands/ttl/ + case currentTTL == -2: + return ErrSessionNotFound + case currentTTL == -1: + return fmt.Errorf("no associated expire: %w", ErrSessionNotFound) + } + + return errors.New("unexpected TTL value") + } - // get request UUIDs, associated with session - requestUUIDs, readErr := s.rdb.ZRangeByScore(s.ctx, key.requests(), &redis.ZRangeBy{ - Min: "-inf", - Max: "+inf", - }).Result() - if readErr != nil { - return false, readErr + // read all stored request UUIDs + rIDs, rErr := s.client.ZRangeByScore(ctx, s.requestsKey(sID), &redis.ZRangeBy{Min: "-inf", Max: "+inf"}).Result() + if rErr != nil { + return rErr } - // removing plan - var keys = []string{key.requests()} + // update the expiration date for the session and all requests + // https://redis.io/docs/latest/commands/expire/ + if _, err := s.client.Pipelined(ctx, func(pipe redis.Pipeliner) error { + var newTTL = currentTTL + howMuch + for _, rID := range rIDs { + pipe.PExpire(ctx, s.requestKey(sID, rID), newTTL) + } + + pipe.PExpire(ctx, s.requestsKey(sID), newTTL) + pipe.PExpire(ctx, s.sessionKey(sID), newTTL) - for i := range requestUUIDs { - keys = append(keys, key.request(requestUUIDs[i])) + return nil + }); err != nil { + return err } - return s.deleteKeys(keys...) + return nil } -// CreateRequest creates new request in storage using passed data and updates expiration time for session and all -// stored requests for the session. -func (s *Redis) CreateRequest(sessionUUID, clientAddr, method, uri string, content []byte, headers map[string]string) (string, error) { //nolint:funlen,lll - var ( - now = time.Now() - key = redisKey(sessionUUID) - ) +func (s *Redis) DeleteSession(ctx context.Context, sID string) error { + if result := s.client.Del(ctx, s.sessionKey(sID)); result.Err() != nil { + return result.Err() + } else if count, rErr := result.Result(); rErr != nil { + return rErr + } else if count == 0 { + return ErrSessionNotFound + } + + return nil +} - packed, msgpackErr := msgpack.Marshal(redisRequest{ - ReqClientAddr: clientAddr, - ReqMethod: method, - ReqContent: content, - ReqHeaders: headers, - ReqURI: uri, - TS: now.Unix(), - }) - if msgpackErr != nil { - return "", msgpackErr +func (s *Redis) NewRequest(ctx context.Context, sID string, r Request) (rID string, _ error) { + // check the session existence + if exists, err := s.isSessionExists(ctx, sID); err != nil { + return "", err + } else if !exists { + return "", ErrSessionNotFound } - id := NewUUID() + rID, r.CreatedAtUnixMilli = s.newID(), time.Now().UnixMilli() + + data, mErr := s.encDec.Encode(r) + if mErr != nil { + return "", mErr + } - // save request data - if _, err := s.rdb.Pipelined(s.ctx, func(pipe redis.Pipeliner) error { - pipe.ZAdd(s.ctx, key.requests(), &redis.Z{ - Score: float64(now.UnixNano()), - Member: id, - }) - pipe.Set(s.ctx, key.request(id), packed, s.ttl) + // save the request data + if _, err := s.client.Pipelined(ctx, func(pipe redis.Pipeliner) error { + pipe.ZAdd(ctx, s.requestsKey(sID), redis.Z{Score: float64(r.CreatedAtUnixMilli), Member: rID}) + pipe.Set(ctx, s.requestKey(sID, rID), data, s.sessionTTL) return nil }); err != nil { @@ -161,20 +202,17 @@ func (s *Redis) CreateRequest(sessionUUID, clientAddr, method, uri string, conte } // read all stored request UUIDs - requestUUIDs, readErr := s.rdb.ZRangeByScore(s.ctx, key.requests(), &redis.ZRangeBy{ - Min: "-inf", - Max: "+inf", - }).Result() - if readErr != nil { - return "", readErr - } - - // if currently we have more than allowed requests - remove unnecessary - if len(requestUUIDs) > int(s.maxRequests) { - if _, err := s.rdb.Pipelined(s.ctx, func(pipe redis.Pipeliner) error { - for _, k := range requestUUIDs[:len(requestUUIDs)-int(s.maxRequests)] { - pipe.ZRem(s.ctx, key.requests(), k) - pipe.Del(s.ctx, key.request(k)) + ids, rErr := s.client.ZRangeByScore(ctx, s.requestsKey(sID), &redis.ZRangeBy{Min: "-inf", Max: "+inf"}).Result() + if rErr != nil { + return "", rErr + } + + // if we have too many requests - remove unnecessary + if len(ids) > int(s.maxRequests) { + if _, err := s.client.Pipelined(ctx, func(pipe redis.Pipeliner) error { + for _, id := range ids[:len(ids)-int(s.maxRequests)] { + pipe.ZRem(ctx, s.requestsKey(sID), id) + pipe.Del(ctx, s.requestKey(sID, id)) } return nil @@ -183,155 +221,137 @@ func (s *Redis) CreateRequest(sessionUUID, clientAddr, method, uri string, conte } } - // update expiring date - if _, err := s.rdb.Pipelined(s.ctx, func(pipe redis.Pipeliner) error { - if len(requestUUIDs) > 0 { - forUpdate := make([]string, 0, len(requestUUIDs)) + return rID, nil +} - if len(requestUUIDs) > int(s.maxRequests) { - forUpdate = requestUUIDs[len(requestUUIDs)-int(s.maxRequests):] - } else { - forUpdate = append(forUpdate, requestUUIDs...) - } +func (s *Redis) GetRequest(ctx context.Context, sID, rID string) (*Request, error) { + // check the session existence + if exists, err := s.isSessionExists(ctx, sID); err != nil { + return nil, err + } else if !exists { + return nil, ErrSessionNotFound + } - for i := range forUpdate { - pipe.Expire(s.ctx, key.request(forUpdate[i]), s.ttl) - } + data, rErr := s.client.Get(ctx, s.requestKey(sID, rID)).Bytes() + if rErr != nil { + if errors.Is(rErr, redis.Nil) { + return nil, ErrRequestNotFound } - pipe.Expire(s.ctx, key.requests(), s.ttl) - pipe.Expire(s.ctx, key.session(), s.ttl) - return nil - }); err != nil { - return "", err + return nil, rErr } - return id, nil -} - -// GetRequest returns request data. -func (s *Redis) GetRequest(sessionUUID, requestUUID string) (Request, error) { - value, err := s.rdb.Get(s.ctx, redisKey(sessionUUID).request(requestUUID)).Bytes() + var request Request + if uErr := s.encDec.Decode(data, &request); uErr != nil { + return nil, uErr + } - if err != nil { - if errors.Is(err, redis.Nil) { - return nil, nil // not found - } + return &request, nil +} +func (s *Redis) GetAllRequests(ctx context.Context, sID string) (map[string]Request, error) { + // check the session existence + if exists, err := s.isSessionExists(ctx, sID); err != nil { return nil, err + } else if !exists { + return nil, ErrSessionNotFound } - rData := redisRequest{} - if msgpackErr := msgpack.Unmarshal(value, &rData); msgpackErr != nil { - return nil, msgpackErr + // read all stored request IDs + ids, rErr := s.client.ZRangeByScore(ctx, s.requestsKey(sID), &redis.ZRangeBy{Min: "-inf", Max: "+inf"}).Result() + if rErr != nil { + return nil, rErr } - rData.Uuid = requestUUID - - return &rData, nil -} - -// GetAllRequests returns all request as a slice of structures. -func (s *Redis) GetAllRequests(sessionUUID string) ([]Request, error) { - var key = redisKey(sessionUUID) - - if exists, existsErr := s.rdb.Exists(s.ctx, key.requests()).Result(); existsErr != nil { - return nil, existsErr - } else if exists == 0 { - return nil, nil // not found + if len(ids) == 0 { + return make(map[string]Request), nil } - UUIDs, allErr := s.rdb.ZRangeByScore(s.ctx, key.requests(), &redis.ZRangeBy{ - Min: "-inf", - Max: "+inf", - }).Result() + var ( + all = make(map[string]Request, len(ids)) + keys = make([]string, len(ids)) + ) - if allErr != nil { - return nil, allErr + // convert request IDs to keys + for i, id := range ids { + keys[i] = s.requestKey(sID, id) } - result := make([]Request, 0, 8) //nolint:mnd - - if len(UUIDs) > 0 { - // convert request UUIDs into storage keys - keys := make([]string, len(UUIDs)) + // read all request data + data, mErr := s.client.MGet(ctx, keys...).Result() + if mErr != nil { + return nil, mErr + } - for i := range UUIDs { - keys[i] = key.request(UUIDs[i]) + for i, d := range data { + if d == nil { + continue } - // read all requests in a one request - rawRequests, gettingErr := s.rdb.MGet(s.ctx, keys...).Result() - if gettingErr != nil { - return nil, gettingErr + var request Request + if uErr := s.encDec.Decode([]byte(d.(string)), &request); uErr != nil { + return nil, uErr } - for i := range UUIDs { - if packed, ok := rawRequests[i].(string); ok { - rData := redisRequest{} + all[ids[i]] = request + } - if err := msgpack.Unmarshal([]byte(packed), &rData); err == nil { // errors with wrong data ignored - rData.Uuid = UUIDs[i] - result = append(result, &rData) - } - } - } + return all, nil +} + +func (s *Redis) DeleteRequest(ctx context.Context, sID, rID string) error { + // check the session existence + if exists, err := s.isSessionExists(ctx, sID); err != nil { + return err + } else if !exists { + return ErrSessionNotFound } - sort.Slice(result, func(i, j int) bool { - return result[i].(*redisRequest).TS < result[j].(*redisRequest).TS - }) + var deleted *redis.IntCmd - return result, nil -} + // delete the request + if _, err := s.client.Pipelined(ctx, func(pipe redis.Pipeliner) error { + pipe.ZRem(ctx, s.requestsKey(sID), rID) + deleted = pipe.Del(ctx, s.requestKey(sID, rID)) -// DeleteRequest deletes stored request with passed session and request UUIDs. -func (s *Redis) DeleteRequest(sessionUUID, requestUUID string) (bool, error) { - var key = redisKey(sessionUUID) + return nil + }); err != nil { + return err + } - if _, err := s.rdb.ZRem(s.ctx, key.requests(), requestUUID).Result(); err != nil { - return false, err + if deleted.Val() == 0 { + return ErrRequestNotFound } - return s.deleteKeys(key.request(requestUUID)) + return nil } -type redisKey string +func (s *Redis) DeleteAllRequests(ctx context.Context, sID string) error { + // check the session existence + if exists, err := s.isSessionExists(ctx, sID); err != nil { + return err + } else if !exists { + return ErrSessionNotFound + } -func (s redisKey) session() string { return "webhook-tester:session:" + string(s) } // session data. -func (s redisKey) requests() string { return s.session() + ":requests" } // requests list. -func (s redisKey) request(id string) string { return s.session() + ":requests:" + id } // request data. + // read all stored request IDs + ids, rErr := s.client.ZRangeByScore(ctx, s.requestsKey(sID), &redis.ZRangeBy{Min: "-inf", Max: "+inf"}).Result() + if rErr != nil { + return rErr + } -type redisSession struct { - Uuid string `msgpack:"-"` //nolint:golint,stylecheck - RespContent []byte `msgpack:"c"` - RespCode uint16 `msgpack:"cd"` - RespContentType string `msgpack:"ct"` - RespDelay int64 `msgpack:"d"` - TS int64 `msgpack:"t"` -} + // delete all requests + if _, err := s.client.Pipelined(ctx, func(pipe redis.Pipeliner) error { + for _, id := range ids { + pipe.Del(ctx, s.requestKey(sID, id)) + } -func (s *redisSession) UUID() string { return s.Uuid } // UUID unique session ID. -func (s *redisSession) Content() []byte { return s.RespContent } // Content session server content. -func (s *redisSession) Code() uint16 { return s.RespCode } // Code default server response code. -func (s *redisSession) ContentType() string { return s.RespContentType } // ContentType response content type. -func (s *redisSession) Delay() time.Duration { return time.Duration(s.RespDelay) } // Delay before response sending. -func (s *redisSession) CreatedAt() time.Time { return time.Unix(s.TS, 0) } // CreatedAt creation time. - -type redisRequest struct { - Uuid string `msgpack:"-"` //nolint:golint,stylecheck - ReqClientAddr string `msgpack:"a"` - ReqMethod string `msgpack:"m"` - ReqContent []byte `msgpack:"c"` - ReqHeaders map[string]string `msgpack:"h"` - ReqURI string `msgpack:"u"` - TS int64 `msgpack:"t"` -} + pipe.Del(ctx, s.requestsKey(sID)) -func (r *redisRequest) UUID() string { return r.Uuid } // UUID returns unique request ID. -func (r *redisRequest) ClientAddr() string { return r.ReqClientAddr } // ClientAddr client hostname or IP. -func (r *redisRequest) Method() string { return r.ReqMethod } // Method HTTP method name. -func (r *redisRequest) Content() []byte { return r.ReqContent } // Content request body (payload). -func (r *redisRequest) Headers() map[string]string { return r.ReqHeaders } // Headers HTTP request headers. -func (r *redisRequest) URI() string { return r.ReqURI } // URI Uniform Resource Identifier. -func (r *redisRequest) CreatedAt() time.Time { return time.Unix(r.TS, 0) } // CreatedAt creation time. + return nil + }); err != nil { + return err + } + + return nil +} diff --git a/internal/storage/redis_test.go b/internal/storage/redis_test.go index b11a8075..03fe5e6d 100644 --- a/internal/storage/redis_test.go +++ b/internal/storage/redis_test.go @@ -1,188 +1,60 @@ package storage_test import ( - "context" "testing" "time" "github.com/alicebob/miniredis/v2" - "github.com/go-redis/redis/v8" - "github.com/stretchr/testify/assert" + "github.com/redis/go-redis/v9" - "gh.tarampamp.am/webhook-tester/internal/storage" + "gh.tarampamp.am/webhook-tester/v2/internal/storage" ) -func TestRedis_SessionCreateReadDelete(t *testing.T) { - mini, err := miniredis.Run() - assert.NoError(t, err) +func TestRedis_Session_CreateReadDelete(t *testing.T) { + t.Parallel() - defer mini.Close() + var mini = miniredis.RunT(t) - s := storage.NewRedis(context.TODO(), redis.NewClient(&redis.Options{Addr: mini.Addr()}), time.Minute, 1) - - sessionUUID, creationErr := s.CreateSession([]byte("foo bar"), 201, "text/javascript", time.Second*123) - assert.NoError(t, creationErr) - - noSession, noSessionErr := s.GetSession("foo") - assert.Nil(t, noSession) - assert.NoError(t, noSessionErr) - - gotSession, gotSessionErr := s.GetSession(sessionUUID) - assert.NoError(t, gotSessionErr) - assert.Equal(t, sessionUUID, gotSession.UUID()) - assert.Equal(t, time.Now().Unix(), gotSession.CreatedAt().Unix()) - assert.Equal(t, (time.Second * 123).Nanoseconds(), gotSession.Delay().Nanoseconds()) - assert.Equal(t, "text/javascript", gotSession.ContentType()) - assert.Equal(t, []byte("foo bar"), gotSession.Content()) - assert.Equal(t, uint16(201), gotSession.Code()) - assert.Equal(t, sessionUUID, gotSession.UUID()) - - delNonExists, errDelNonExists := s.DeleteSession("foo") - assert.False(t, delNonExists) - assert.NoError(t, errDelNonExists) - - delExists, errDelExists := s.DeleteSession(sessionUUID) - assert.True(t, delExists) - assert.NoError(t, errDelExists) - - gotSessionAgain, gotSessionAgainErr := s.GetSession(sessionUUID) - assert.Nil(t, gotSessionAgain) - assert.NoError(t, gotSessionAgainErr) -} - -func TestRedis_RequestCreateReadDelete(t *testing.T) { - mini, err := miniredis.Run() - assert.NoError(t, err) - - defer mini.Close() - - s := storage.NewRedis(context.TODO(), redis.NewClient(&redis.Options{Addr: mini.Addr()}), time.Minute, 10) - - sessionUUID, sessionCreationErr := s.CreateSession([]byte("foo bar"), 201, "text/javascript", 0) - assert.Nil(t, sessionCreationErr) - - requestUUID, creationErr := s.CreateRequest( - sessionUUID, - "2.3.4.5", - "GET", - "https://example.com/test", - []byte(`{"foo":123}`), - map[string]string{"foo": "bar"}, + testSessionCreateReadDelete(t, + func(sTTL time.Duration, maxReq uint32) storage.Storage { + return storage.NewRedis( + redis.NewClient(&redis.Options{Addr: mini.Addr()}), + sTTL, + maxReq, + ) + }, + func(t time.Duration) { mini.FastForward(t) }, ) - assert.Nil(t, creationErr) - assert.NotEmpty(t, requestUUID) - - noRequest, noRequestErr := s.GetRequest(sessionUUID, "foo") - assert.Nil(t, noRequest) - assert.Nil(t, noRequestErr) - - request, getRequestErr := s.GetRequest(sessionUUID, requestUUID) - assert.Nil(t, getRequestErr) - assert.Equal(t, "2.3.4.5", request.ClientAddr()) - assert.Equal(t, []byte(`{"foo":123}`), request.Content()) - assert.Equal(t, map[string]string{"foo": "bar"}, request.Headers()) - assert.Equal(t, "https://example.com/test", request.URI()) - assert.Equal(t, "GET", request.Method()) - assert.Equal(t, time.Now().Unix(), request.CreatedAt().Unix()) - assert.Equal(t, requestUUID, request.UUID()) - - noDelResult, noDelErr := s.DeleteRequest(sessionUUID, "foo") - assert.False(t, noDelResult) - assert.NoError(t, noDelErr) - - delResult, delErr := s.DeleteRequest(sessionUUID, requestUUID) - assert.True(t, delResult) - assert.NoError(t, delErr) - - nowNoRequest, nowNoRequestErr := s.GetRequest(sessionUUID, requestUUID) - assert.Nil(t, nowNoRequest) - assert.NoError(t, nowNoRequestErr) } -func TestRedis_RequestCreationLimit(t *testing.T) { - mini, err := miniredis.Run() - assert.NoError(t, err) - - defer mini.Close() - - s := storage.NewRedis(context.TODO(), redis.NewClient(&redis.Options{Addr: mini.Addr()}), time.Minute, 2) - - sessionUUID, _ := s.CreateSession([]byte("foo bar"), 201, "text/javascript", 0) - - _, _ = s.CreateRequest(sessionUUID, "1.1.1.1", "GET", "https://example.com/test", []byte(`{"foo":123}`), nil) - - requests, _ := s.GetAllRequests(sessionUUID) - assert.Len(t, requests, 1) +func TestRedis_Request_CreateReadDelete(t *testing.T) { + t.Parallel() - _, _ = s.CreateRequest(sessionUUID, "2.2.2.2", "GET", "https://example.com/test", []byte(`{"foo":123}`), nil) + var mini = miniredis.RunT(t) - requests, _ = s.GetAllRequests(sessionUUID) - assert.Len(t, requests, 2) - assert.Equal(t, "1.1.1.1", requests[0].ClientAddr()) - assert.Equal(t, "2.2.2.2", requests[1].ClientAddr()) - - _, _ = s.CreateRequest(sessionUUID, "3.3.3.3", "GET", "https://example.com/test", []byte(`{"foo":123}`), nil) - - requests, _ = s.GetAllRequests(sessionUUID) - assert.Len(t, requests, 2) - assert.Equal(t, "2.2.2.2", requests[0].ClientAddr()) - assert.Equal(t, "3.3.3.3", requests[1].ClientAddr()) -} - -func TestRedis_GetAllRequests(t *testing.T) { - mini, err := miniredis.Run() - assert.NoError(t, err) - - defer mini.Close() - - s := storage.NewRedis(context.TODO(), redis.NewClient(&redis.Options{Addr: mini.Addr()}), time.Minute, 10) - - sessionUUID, sessionCreationErr := s.CreateSession([]byte("foo bar"), 201, "text/javascript", 0) - assert.NoError(t, sessionCreationErr) - - noRequests, noRequestsErr := s.GetAllRequests(sessionUUID) - assert.Nil(t, noRequests) - assert.NoError(t, noRequestsErr) - - noRequestsWrongSession, noRequestsWrongSessionErr := s.GetAllRequests("foo") - assert.Nil(t, noRequestsWrongSession) - assert.NoError(t, noRequestsWrongSessionErr) - - requestUUID, creationErr := s.CreateRequest(sessionUUID, "1.2.3.4", "GET", "https://test", []byte(`{"foo":123}`), nil) - assert.NoError(t, creationErr) - assert.NotEmpty(t, requestUUID) - - requests, requestsErr := s.GetAllRequests(sessionUUID) - assert.Len(t, requests, 1) - assert.NoError(t, requestsErr) - assert.Equal(t, "1.2.3.4", requests[0].ClientAddr()) + testRequestCreateReadDelete(t, + func(sTTL time.Duration, maxReq uint32) storage.Storage { + return storage.NewRedis( + redis.NewClient(&redis.Options{Addr: mini.Addr()}), + sTTL, + maxReq, + ) + }, + func(t time.Duration) { mini.FastForward(t) }, + ) } -func TestRedis_DeleteRequests(t *testing.T) { - mini, err := miniredis.Run() - assert.NoError(t, err) - - defer mini.Close() - - s := storage.NewRedis(context.TODO(), redis.NewClient(&redis.Options{Addr: mini.Addr()}), time.Minute, 10) - - sessionUUID, sessionCreationErr := s.CreateSession([]byte("foo bar"), 201, "text/javascript", 0) - assert.NoError(t, sessionCreationErr) - - res, delErr := s.DeleteRequests(sessionUUID) - assert.False(t, res) - assert.NoError(t, delErr) - - _, _ = s.CreateRequest(sessionUUID, "1.1.1.1", "GET", "https://test", []byte(`{"foo":123}`), nil) - _, _ = s.CreateRequest(sessionUUID, "1.1.1.1", "GET", "https://test", []byte(`{"foo":123}`), nil) - - requests, _ := s.GetAllRequests(sessionUUID) - assert.Len(t, requests, 2) - - res2, delErr2 := s.DeleteRequests(sessionUUID) - assert.True(t, res2) - assert.NoError(t, delErr2) - - requests2, _ := s.GetAllRequests(sessionUUID) - assert.Nil(t, requests2) -} +// func TestRedis_RaceProvocation(t *testing.T) { +// t.Parallel() +// +// var mini = miniredis.RunT(t) +// +// testRaceProvocation(t, func(sTTL time.Duration, maxReq uint32) storage.Storage { +// return storage.NewRedis( +// redis.NewClient(&redis.Options{Addr: mini.Addr()}), +// encDec, +// sTTL, +// maxReq, +// ) +// }) +// } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 1af993b4..ff3bd456 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -1,72 +1,85 @@ package storage import ( + "context" + "errors" + "fmt" "time" +) + +var ( + ErrNotFound = errors.New("not found") + ErrSessionNotFound = fmt.Errorf("session %w", ErrNotFound) + ErrRequestNotFound = fmt.Errorf("request %w", ErrNotFound) - "github.com/google/uuid" + ErrClosed = errors.New("closed") ) -// Storage is a Session's and Request's storage. +// Storage manages Session and Request data. type Storage interface { - // GetSession returns session data. - // If session was not found - `nil, nil` will be returned. - GetSession(uuid string) (Session, error) + // NewSession creates a new session and returns a session ID on success. + // The Session.CreatedAt field will be set to the current time. + NewSession(_ context.Context, _ Session, id ...string) (sID string, _ error) - // CreateSession creates new session in storage using passed data. - // Session UUID without error will be returned on success. - CreateSession(content []byte, code uint16, contentType string, delay time.Duration, id ...string) (string, error) + // GetSession retrieves session data. + // If the session is not found, ErrSessionNotFound will be returned. + GetSession(_ context.Context, sID string) (*Session, error) - // DeleteSession deletes session with passed UUID. - DeleteSession(uuid string) (bool, error) + // AddSessionTTL adds the specified TTL to the session (and all its requests) with the specified ID. + AddSessionTTL(_ context.Context, sID string, howMuch time.Duration) error - // DeleteRequests deletes stored requests for session with passed UUID. - DeleteRequests(uuid string) (bool, error) + // DeleteSession removes the session with the specified ID. + // If the session is not found, ErrSessionNotFound will be returned. + DeleteSession(_ context.Context, sID string) error - // CreateRequest creates new request in storage using passed data and updates expiration time for session and all - // stored requests for the session. - // Session with passed UUID must exist. - // Request UUID without error will be returned on success. - CreateRequest(sessionUUID, clientAddr, method, uri string, content []byte, headers map[string]string) (string, error) + // NewRequest creates a new request for the session with the specified ID and returns a request ID on success. + // The session with the specified ID must exist. The Request.CreatedAtUnixMilli field will be set to the + // current time. The storage may limit the number of requests per session - in this case the oldest request + // will be removed. + // If the session is not found, ErrSessionNotFound will be returned. + NewRequest(_ context.Context, sID string, _ Request) (rID string, _ error) - // GetRequest returns request data. - // If request was not found - `nil, nil` will be returned. - GetRequest(sessionUUID, requestUUID string) (Request, error) + // GetRequest retrieves request data. + // If the request or session is not found, ErrNotFound (ErrSessionNotFound or ErrRequestNotFound) will be returned. + GetRequest(_ context.Context, sID, rID string) (*Request, error) - // GetAllRequests returns all request as a slice of structures. - // If requests was not found - `nil, nil` will be returned. - GetAllRequests(sessionUUID string) ([]Request, error) + // GetAllRequests returns all requests for the session with the specified ID. + // If the session is not found, ErrSessionNotFound will be returned. If there are no requests, an empty map + // will be returned. + GetAllRequests(_ context.Context, sID string) (map[string]Request, error) - // DeleteRequest deletes stored request with passed session and request UUIDs. - DeleteRequest(sessionUUID, requestUUID string) (bool, error) -} + // DeleteRequest removes the request with the specified ID. + // If the request or session is not found, ErrNotFound (ErrSessionNotFound or ErrRequestNotFound) will be returned. + DeleteRequest(_ context.Context, sID, rID string) error -// Session describes session settings (like response data and any additional information). -type Session interface { - UUID() string // UUID returns unique session identifier. - Content() []byte // Content returns session server response content. - Code() uint16 // Code returns default server response code. - ContentType() string // ContentType returns response content type. - Delay() time.Duration // Delay returns delay before response sending. - CreatedAt() time.Time // CreatedAt returns creation time (accuracy to seconds). + // DeleteAllRequests removes all requests for the session with the specified ID. + // If the session is not found, ErrSessionNotFound will be returned. + DeleteAllRequests(_ context.Context, sID string) error } -// Request describes recorded request and additional meta-data. -type Request interface { - UUID() string // UUID returns unique request identifier. - ClientAddr() string // ClientAddr returns client hostname or IP address (who sent this request). - Method() string // Method returns HTTP method name (eg.: 'GET', 'POST'). - Content() []byte // Content returns request body (payload). - Headers() map[string]string // Headers returns HTTP request headers. - URI() string // URI returns Uniform Resource Identifier. - CreatedAt() time.Time // CreatedAt returns creation time (accuracy to seconds). -} +type ( + // Session describes session settings (like response data and any additional information). + Session struct { + Code uint16 `json:"code"` // default server response code + Headers []HttpHeader `json:"headers"` // server response headers + ResponseBody []byte `json:"body"` // server response body (payload) + Delay time.Duration `json:"delay"` // delay before response sending + CreatedAtUnixMilli int64 `json:"created_at_unit_milli"` // creation time + ExpiresAt time.Time `json:"-"` // expiration time (doesn't store in the storage) + } -// NewUUID generates new UUID v4. -func NewUUID() string { return uuid.New().String() } + // Request describes recorded request and additional meta-data. + Request struct { + ClientAddr string `json:"client_addr"` // client hostname or IP address + Method string `json:"method"` // HTTP method name (i.e., 'GET', 'POST') + Body []byte `json:"body"` // request body (payload) + Headers []HttpHeader `json:"headers"` // HTTP request headers + URL string `json:"url"` // Uniform Resource Identifier + CreatedAtUnixMilli int64 `json:"created_at_unit_milli"` // creation time + } -// IsValidUUID checks if passed string is valid UUID v4. -func IsValidUUID(id string) bool { - _, err := uuid.Parse(id) - - return err == nil -} + HttpHeader struct { + Name string `json:"name"` // the name of the header, e.g., "Content-Type" + Value string `json:"value"` // the value of the header, e.g., "application/json" + } +) diff --git a/internal/storage/storage_shared_test.go b/internal/storage/storage_shared_test.go new file mode 100644 index 00000000..9e62bc29 --- /dev/null +++ b/internal/storage/storage_shared_test.go @@ -0,0 +1,529 @@ +package storage_test + +import ( + "context" + "io" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gh.tarampamp.am/webhook-tester/v2/internal/storage" +) + +func toCloser(s storage.Storage) io.Closer { + if c, ok := s.(io.Closer); ok { + return c + } + + return io.NopCloser(nil) +} + +func testSessionCreateReadDelete( + t *testing.T, + new func(sessionTTL time.Duration, maxRequests uint32) storage.Storage, + sleep func(time.Duration), +) { + t.Helper() + + var ctx = context.Background() + + t.Run("create, read, delete", func(t *testing.T) { + t.Parallel() + + var impl = new(time.Minute, 1) + defer func() { _ = toCloser(impl).Close() }() + + var sessionHeaders = []storage.HttpHeader{{"foo", "bar"}, {"bar", "baz"}} + + const ( + code uint16 = 201 + delay = time.Second * 123 + ) + + // create + var sID, newErr = impl.NewSession(ctx, storage.Session{ + Code: code, + Headers: sessionHeaders, + Delay: delay, + }) + + require.NoError(t, newErr) + require.NotEmpty(t, sID) + + // read + got, getErr := impl.GetSession(ctx, sID) + require.NoError(t, getErr) + require.Equal(t, code, got.Code) + require.Equal(t, sessionHeaders, got.Headers) + require.Equal(t, delay, got.Delay) + assert.NotZero(t, got.CreatedAtUnixMilli) + + // delete + require.NoError(t, impl.DeleteSession(ctx, sID)) // success + require.ErrorIs(t, impl.DeleteSession(ctx, sID), storage.ErrNotFound) // already deleted + require.ErrorIs(t, impl.DeleteSession(ctx, sID), storage.ErrSessionNotFound) + + // read again + got, getErr = impl.GetSession(ctx, sID) + require.Nil(t, got) + require.ErrorIs(t, getErr, storage.ErrNotFound) + require.ErrorIs(t, getErr, storage.ErrSessionNotFound) + }) + + t.Run("not found", func(t *testing.T) { + t.Parallel() + + var impl = new(time.Minute, 1) + defer func() { _ = toCloser(impl).Close() }() + + got, err := impl.GetSession(ctx, "foo") + require.Nil(t, got) + require.ErrorIs(t, err, storage.ErrSessionNotFound) + }) + + t.Run("delete not existing", func(t *testing.T) { + t.Parallel() + + var impl = new(time.Minute, 1) + defer func() { _ = toCloser(impl).Close() }() + + require.ErrorIs(t, impl.DeleteSession(ctx, "foo"), storage.ErrSessionNotFound) + }) + + t.Run("expired", func(t *testing.T) { + t.Parallel() + + const sessionTTL = time.Millisecond + + var impl = new(sessionTTL, 1) + defer func() { _ = toCloser(impl).Close() }() + + sID, err := impl.NewSession(ctx, storage.Session{}) + require.NoError(t, err) + require.NotEmpty(t, sID) + + sleep(sessionTTL * 2) // wait for expiration + + _, err = impl.GetSession(ctx, sID) + + require.ErrorIs(t, err, storage.ErrSessionNotFound) + }) + + t.Run("add session TTL", func(t *testing.T) { + t.Parallel() + + const sessionTTL = time.Millisecond * 10 + + var impl = new(sessionTTL, 2) + defer func() { _ = toCloser(impl).Close() }() + + // create session + sID, err := impl.NewSession(ctx, storage.Session{}) + require.NoError(t, err) + require.NotEmpty(t, sID) + + // get it + sess, err := impl.GetSession(ctx, sID) + require.NoError(t, err) + + { // check the created and expiration time + var now = time.Now() + + require.InDelta(t, now.UnixMilli(), sess.CreatedAtUnixMilli, 50) + require.InDelta(t, now.Add(sessionTTL).UnixMilli(), sess.ExpiresAt.UnixMilli(), 5) + } + + var ( // store the original values + originalCreatedAt = sess.CreatedAtUnixMilli + originalTTL = sess.ExpiresAt + ) + + // reload the session + sess, err = impl.GetSession(ctx, sID) + require.NoError(t, err) + require.Equal(t, originalCreatedAt, sess.CreatedAtUnixMilli) // should be the same + require.InDelta(t, originalTTL.UnixMilli(), sess.ExpiresAt.UnixMilli(), 5) + + // add TTL + require.NoError(t, impl.AddSessionTTL(ctx, sID, sessionTTL*2)) // current ttl = x + 2x = 3x + + // wait for expiration (2x) + sleep(sessionTTL * 2) + + // the session should be still alive + sess, err = impl.GetSession(ctx, sID) + require.NoError(t, err) + require.Equal(t, originalCreatedAt, sess.CreatedAtUnixMilli) + require.NotEqual(t, originalTTL, sess.ExpiresAt) // changed + + // wait for expiration (2x) + sleep(sessionTTL * 2) + + // check again + sess, err = impl.GetSession(ctx, sID) + require.ErrorIs(t, err, storage.ErrSessionNotFound) + require.Nil(t, sess) + }) +} + +func testRequestCreateReadDelete( + t *testing.T, + new func(sessionTTL time.Duration, maxRequests uint32) storage.Storage, + sleep func(time.Duration), +) { + t.Helper() + + var ctx = context.Background() + + const someUrl = "https://example.com/foo/bar" + + t.Run("create, read, delete", func(t *testing.T) { + t.Parallel() + + var impl = new(time.Minute, 1) + defer func() { _ = toCloser(impl).Close() }() + + // create session + sID, newErr := impl.NewSession(ctx, storage.Session{ + Code: 201, + Headers: []storage.HttpHeader{{"foo", "bar"}, {"bar", "baz"}}, + Delay: time.Second, + }) + require.NoError(t, newErr) + require.NotEmpty(t, sID) + + const ( + clientAddr = "127.0.0.1" + method = "GET" + body = " \nfoo bar\n\t \nbaz" + ) + + var requestHeaders = []storage.HttpHeader{{"foo", "bar"}, {"bar", "baz"}} + + // create + rID, newReqErr := impl.NewRequest(ctx, sID, storage.Request{ + ClientAddr: clientAddr, + Method: method, + Body: []byte(body), + Headers: requestHeaders, + URL: someUrl, + }) + require.NoError(t, newReqErr) + require.NotEmpty(t, rID) + + // read + got, getErr := impl.GetRequest(ctx, sID, rID) + require.NoError(t, getErr) + require.Equal(t, clientAddr, got.ClientAddr) + require.Equal(t, method, got.Method) + require.Equal(t, []byte(body), got.Body) + require.Equal(t, requestHeaders, got.Headers) + require.Equal(t, someUrl, got.URL) + assert.NotZero(t, got.CreatedAtUnixMilli) + + { // read all + all, err := impl.GetAllRequests(ctx, sID) + require.NoError(t, err) + require.Len(t, all, 1) + require.Equal(t, all, map[string]storage.Request{rID: *got}) + } + + // delete + require.NoError(t, impl.DeleteRequest(ctx, sID, rID)) // success + require.ErrorIs(t, impl.DeleteRequest(ctx, sID, rID), storage.ErrNotFound) // already deleted + require.ErrorIs(t, impl.DeleteRequest(ctx, sID, rID), storage.ErrRequestNotFound) + + // read again + got, getErr = impl.GetRequest(ctx, sID, rID) + require.Nil(t, got) + require.ErrorIs(t, getErr, storage.ErrNotFound) + require.ErrorIs(t, getErr, storage.ErrRequestNotFound) + }) + + t.Run("new request - limit exceeded", func(t *testing.T) { + t.Parallel() + + var impl = new(time.Minute, 2) // limit is 2 + defer func() { _ = toCloser(impl).Close() }() + + // create session + sID, err := impl.NewSession(ctx, storage.Session{}) + require.NoError(t, err) + require.NotEmpty(t, sID) + + // create request #1 + rID1, err := impl.NewRequest(ctx, sID, storage.Request{}) + require.NoError(t, err) + require.NotEmpty(t, rID1) + + sleep(time.Millisecond) // the accuracy is one millisecond + + // create request #2 + rID2, err := impl.NewRequest(ctx, sID, storage.Request{}) + require.NoError(t, err) + require.NotEmpty(t, rID2) + + // now, the session has 2 requests and the limit is reached + + { // check made requests + requests, _ := impl.GetAllRequests(ctx, sID) + require.Len(t, requests, 2) + + req, _ := impl.GetRequest(ctx, sID, rID1) + require.NotNil(t, req) + + req, _ = impl.GetRequest(ctx, sID, rID2) + require.NotNil(t, req) + } + + sleep(time.Millisecond) + + // create request #3 + rID3, err := impl.NewRequest(ctx, sID, storage.Request{}) + require.NoError(t, err) + require.NotEmpty(t, rID3) + + // now, the request #1 should be deleted because the limit is reached (the storage should keep the requests + // with numbers 2 and 3) + + { // check made requests again + requests, _ := impl.GetAllRequests(ctx, sID) + require.Len(t, requests, 2) // still 2 + + req, reqErr := impl.GetRequest(ctx, sID, rID1) // not found + require.Nil(t, req) + require.Error(t, reqErr) + + req, _ = impl.GetRequest(ctx, sID, rID2) // ok + require.NotNil(t, req) + + req, _ = impl.GetRequest(ctx, sID, rID3) // ok + require.NotNil(t, req) + } + + // and now add one more request - after that, the request #2 should be deleted (the storage should keep the + // requests with numbers 3 and 4) + + sleep(time.Millisecond) + + // create request #4 + rID4, err := impl.NewRequest(ctx, sID, storage.Request{}) + require.NoError(t, err) + require.NotEmpty(t, rID4) + + { // check made requests again + requests, _ := impl.GetAllRequests(ctx, sID) + require.Len(t, requests, 2) // still 2 + + req, reqErr := impl.GetRequest(ctx, sID, rID1) // not found + require.Nil(t, req) + require.Error(t, reqErr) + + req, reqErr = impl.GetRequest(ctx, sID, rID2) // not found + require.Nil(t, req) + require.Error(t, reqErr) + + req, _ = impl.GetRequest(ctx, sID, rID3) // ok + require.NotNil(t, req) + + req, _ = impl.GetRequest(ctx, sID, rID4) // ok + require.NotNil(t, req) + } + + // and now delete all the requests + require.NoError(t, impl.DeleteAllRequests(ctx, sID)) + + _, err = impl.GetAllRequests(ctx, sID) + require.NoError(t, err) + + // and the session + require.NoError(t, impl.DeleteSession(ctx, sID)) + + _, err = impl.GetAllRequests(ctx, sID) + require.ErrorIs(t, err, storage.ErrSessionNotFound) + }) + + t.Run("delete all", func(t *testing.T) { + t.Parallel() + + var impl = new(time.Minute, 1) + defer func() { _ = toCloser(impl).Close() }() + + // create session + sID, err := impl.NewSession(ctx, storage.Session{}) + require.NoError(t, err) + require.NotEmpty(t, sID) + + // create request + rID, err := impl.NewRequest(ctx, sID, storage.Request{}) + require.NoError(t, err) + require.NotEmpty(t, rID) + + // delete all + require.NoError(t, impl.DeleteAllRequests(ctx, sID)) + + // check + all, err := impl.GetAllRequests(ctx, sID) + require.NoError(t, err) + require.Empty(t, all) + }) + + t.Run("delete all - no session", func(t *testing.T) { + t.Parallel() + + var impl = new(time.Minute, 1) + defer func() { _ = toCloser(impl).Close() }() + + err := impl.DeleteAllRequests(ctx, "foo") + require.ErrorIs(t, err, storage.ErrNotFound) + require.ErrorIs(t, err, storage.ErrSessionNotFound) + }) + + t.Run("get all - empty", func(t *testing.T) { + t.Parallel() + + var impl = new(time.Minute, 1) + defer func() { _ = toCloser(impl).Close() }() + + // create session + sID, err := impl.NewSession(ctx, storage.Session{}) + require.NoError(t, err) + require.NotEmpty(t, sID) + + all, err := impl.GetAllRequests(ctx, sID) + require.NoError(t, err) + require.Empty(t, all) + }) + + t.Run("get all - no session", func(t *testing.T) { + t.Parallel() + + var impl = new(time.Minute, 1) + defer func() { _ = toCloser(impl).Close() }() + + all, err := impl.GetAllRequests(ctx, "foo") + require.Nil(t, all) + require.ErrorIs(t, err, storage.ErrNotFound) + require.ErrorIs(t, err, storage.ErrSessionNotFound) + }) + + t.Run("new request - session not found", func(t *testing.T) { + t.Parallel() + + var impl = new(time.Minute, 1) + defer func() { _ = toCloser(impl).Close() }() + + _, err := impl.NewRequest(ctx, "foo", storage.Request{}) + require.ErrorIs(t, err, storage.ErrNotFound) + require.ErrorIs(t, err, storage.ErrSessionNotFound) + }) + + t.Run("get request - session not found", func(t *testing.T) { + t.Parallel() + + var impl = new(time.Minute, 1) + defer func() { _ = toCloser(impl).Close() }() + + got, err := impl.GetRequest(ctx, "foo", "bar") + require.Nil(t, got) + require.ErrorIs(t, err, storage.ErrNotFound) + require.ErrorIs(t, err, storage.ErrSessionNotFound) + }) + + t.Run("get request - request not found", func(t *testing.T) { + t.Parallel() + + var impl = new(time.Minute, 1) + defer func() { _ = toCloser(impl).Close() }() + + // create session + sID, newErr := impl.NewSession(ctx, storage.Session{}) + require.NoError(t, newErr) + require.NotEmpty(t, sID) + + got, err := impl.GetRequest(ctx, sID, "foo") + require.Nil(t, got) + require.ErrorIs(t, err, storage.ErrNotFound) + require.ErrorIs(t, err, storage.ErrRequestNotFound) + }) + + t.Run("delete request - session not found", func(t *testing.T) { + t.Parallel() + + var impl = new(time.Minute, 1) + defer func() { _ = toCloser(impl).Close() }() + + err := impl.DeleteRequest(ctx, "foo", "bar") + require.ErrorIs(t, err, storage.ErrNotFound) + require.ErrorIs(t, err, storage.ErrSessionNotFound) + }) + + t.Run("delete request - request not found", func(t *testing.T) { + t.Parallel() + + var impl = new(time.Minute, 1) + defer func() { _ = toCloser(impl).Close() }() + + // create session + sID, newErr := impl.NewSession(ctx, storage.Session{}) + require.NoError(t, newErr) + require.NotEmpty(t, sID) + + err := impl.DeleteRequest(ctx, sID, "foo") + require.ErrorIs(t, err, storage.ErrNotFound) + require.ErrorIs(t, err, storage.ErrRequestNotFound) + }) +} + +func testRaceProvocation( + t *testing.T, + new func(sessionTTL time.Duration, maxRequests uint32) storage.Storage, +) { + t.Helper() + + var ctx = context.Background() + + var impl = new(time.Minute, 1000) + defer func() { _ = toCloser(impl).Close() }() + + var wg sync.WaitGroup + + for range 100 { + wg.Add(1) + + go func() { + defer wg.Done() + + sID, err := impl.NewSession(ctx, storage.Session{}) + require.NoError(t, err) + + _, err = impl.GetSession(ctx, sID) + require.NoError(t, err) + + var rID string + + for range 50 { + rID, err = impl.NewRequest(ctx, sID, storage.Request{}) + require.NoError(t, err) + + _, err = impl.GetRequest(ctx, sID, rID) + require.NoError(t, err) + + all, aErr := impl.GetAllRequests(ctx, sID) + require.NoError(t, aErr) + require.NotEmpty(t, all) + } + + require.NoError(t, impl.AddSessionTTL(ctx, sID, time.Minute)) + + require.NoError(t, impl.DeleteRequest(ctx, sID, rID)) + + require.NoError(t, impl.DeleteAllRequests(ctx, sID)) + }() + } + + wg.Wait() +} diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go deleted file mode 100644 index 5985e45e..00000000 --- a/internal/storage/storage_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package storage_test - -import ( - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/webhook-tester/internal/storage" -) - -func TestNewUUID(t *testing.T) { - for range 100 { - s := storage.NewUUID() - _, err := uuid.Parse(s) - assert.Nil(t, err) - } -} - -func TestIsValidUUID(t *testing.T) { - assert.True(t, storage.IsValidUUID("00000000-0000-0000-0000-000000000000")) - assert.True(t, storage.IsValidUUID("9b6bbab9-c197-4dd3-bc3f-3cb6253820c7")) - - assert.False(t, storage.IsValidUUID("9b6bbab9-c197-4dd3-bc3f-3cb6253820ZZ")) - assert.False(t, storage.IsValidUUID("ZZ6bbab9-c197-4dd3-bc3f-3cb6253820c7")) - assert.False(t, storage.IsValidUUID("00-00-00-00-00")) -} diff --git a/internal/version/latest.go b/internal/version/latest.go new file mode 100644 index 00000000..69541771 --- /dev/null +++ b/internal/version/latest.go @@ -0,0 +1,87 @@ +package version + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +type httpClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// Latest returns the latest release tag of the "tarampampam/webhook-tester" repository. +// +// Optionally, you can pass a custom HTTP client to use for the request. If not provided, the default client with +// a 15-second timeout will be used. +// +// The 'v' prefix will be removed from the tag if it exists. +func Latest(ctx context.Context, useClient ...httpClient) (string, error) { //nolint:funlen + var doer httpClient + + if len(useClient) > 0 { + doer = useClient[0] + } else { + doer = &http.Client{ + Timeout: time.Second * 15, //nolint:mnd + Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse // disable redirects + }, + } + } + + const ownerAndRepo = "tarampampam/webhook-tester" + + // use the "magic" GitHub link to get the latest release tag (it returns a 302 redirect with the tag in + // the location header). this "hack" allows us to avoid the GitHub API rate limits + req, reqErr := http.NewRequestWithContext(ctx, + http.MethodGet, + fmt.Sprintf("https://github.com/%s/releases/latest", ownerAndRepo), + http.NoBody, + ) + if reqErr != nil { + return "", reqErr + } + + // send the request + resp, respErr := doer.Do(req) + if respErr != nil { + return "", respErr + } + + // body is not interesting for us + if resp.Body != nil { + _ = resp.Body.Close() + } + + // check the status code + if resp.StatusCode != http.StatusFound { + return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + // parse the location header + u, uErr := url.Parse(resp.Header.Get("Location")) + if uErr != nil { + return "", fmt.Errorf("parsing location header failed: %w", uErr) + } + + // split path by slashes: [owner repo releases tag v1.2.3] + var parts = strings.Split(strings.TrimLeft(u.Path, "/"), "/") + if len(parts) < 5 { //nolint:mnd + return "", fmt.Errorf("unexpected location path: %s", u.Path) + } + + // pick the 4th segment (tag) + var tag = parts[4] + + // if the tag starts with "v" - remove it + if len(tag) > 1 && ((tag[0] == 'v' || tag[0] == 'V') && (tag[1] >= '0' && tag[1] <= '9')) { + return tag[1:], nil + } + + return tag, nil +} diff --git a/internal/version/latest_test.go b/internal/version/latest_test.go new file mode 100644 index 00000000..af16e6fd --- /dev/null +++ b/internal/version/latest_test.go @@ -0,0 +1,74 @@ +package version_test + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gh.tarampamp.am/webhook-tester/v2/internal/version" +) + +type httpClientFunc func(*http.Request) (*http.Response, error) + +func (f httpClientFunc) Do(req *http.Request) (*http.Response, error) { return f(req) } + +func TestLatest(t *testing.T) { + t.Parallel() + + for name, tt := range map[string]struct { + giveStatusCode int + giveLocation string + + wantVersion string + wantErrorSubstr string + }{ + "success": { + giveStatusCode: http.StatusFound, + giveLocation: "https://github.com/tarampampam/webhook-tester/releases/tag/V1.2.0/foo/bar?baz=qux#quux", + wantVersion: "1.2.0", + }, + "success without v prefix": { + giveStatusCode: http.StatusFound, + giveLocation: "https://github.com/tarampampam/webhook-tester/releases/tag/1.2.0/foo/bar?baz=qux#quux", + wantVersion: "1.2.0", + }, + + "unexpected status code": { + giveStatusCode: http.StatusNotFound, + wantErrorSubstr: "unexpected status code: 404", + }, + "redirect location is malformed": { + giveStatusCode: http.StatusFound, + giveLocation: "qwe", + wantErrorSubstr: "unexpected location path: qwe", + }, + "too short location link": { + giveStatusCode: http.StatusFound, + giveLocation: "https://github.com/owner/repo/foo", + wantErrorSubstr: "unexpected location path: /owner/repo/foo", + }, + } { + t.Run(name, func(t *testing.T) { + var client httpClientFunc = func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: tt.giveStatusCode, + Header: http.Header{"Location": []string{tt.giveLocation}}, + }, nil + } + + latest, err := version.Latest(context.Background(), client) + + if tt.wantErrorSubstr == "" { + require.NoError(t, err) + assert.Equal(t, tt.wantVersion, latest) + } else { + require.Error(t, err) + assert.ErrorContains(t, err, tt.wantErrorSubstr) + assert.Empty(t, latest) + } + }) + } +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go index bed3e5b8..ade24665 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -5,6 +5,8 @@ import ( ) func TestVersion(t *testing.T) { + t.Parallel() + for give, want := range map[string]string{ // without changes "vvv": "vvv", diff --git a/test/hurl/api/session_create.hurl b/test/hurl/api/session_create.hurl deleted file mode 100644 index df110f72..00000000 --- a/test/hurl/api/session_create.hurl +++ /dev/null @@ -1,101 +0,0 @@ -POST http://{{ host }}:{{ port }}/api/session # create a session with all default values - -HTTP 200 - -[Captures] -session_uuid: jsonpath "$.uuid" - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$.created_at_unix" >= 1600000000 -jsonpath "$.response.code" == 200 # default value -jsonpath "$.response.content_base64" == "" # default value -jsonpath "$.response.content_type" == "text/plain" # default value -jsonpath "$.response.delay_sec" == 0 # default value -jsonpath "$.uuid" matches "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" - -# --- Cleanup - -DELETE http://{{ host }}:{{ port }}/api/session/{{ session_uuid }} - -HTTP 200 - -# --- Create a session with customized values - -POST http://{{ host }}:{{ port }}/api/session -Content-Type: application/json - -{ - "status_code": 201, - "content_type": "application1/json2", - "response_delay": 2, - "response_content_base64": "Zm9vIGJhcg==" -} - -HTTP 200 - -[Captures] -session_uuid: jsonpath "$.uuid" - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$.created_at_unix" >= 1600000000 -jsonpath "$.response.code" == 201 -jsonpath "$.response.content_base64" == "Zm9vIGJhcg==" -jsonpath "$.response.content_type" == "application1/json2" -jsonpath "$.response.delay_sec" == 2 -jsonpath "$.uuid" matches "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" - -# --- Cleanup - -DELETE http://{{ host }}:{{ port }}/api/session/{{ session_uuid }} - -HTTP 200 - -# --- Wrong status code (too small) - -POST http://{{ host }}:{{ port }}/api/session -Content-Type: application/json - -{ - "status_code": 99 -} - -HTTP 400 - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$.code" == 400 -jsonpath "$.message" contains "wrong status code" - -# --- Wrong status code (too large) - -POST http://{{ host }}:{{ port }}/api/session -Content-Type: application/json - -{ - "status_code": 531 -} - -HTTP 400 - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$.code" == 400 -jsonpath "$.message" contains "wrong status code" - -# --- Wrong status code (too large) - -POST http://{{ host }}:{{ port }}/api/session -Content-Type: application/json - -{ - "response_delay": 31 -} - -HTTP 400 - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$.code" == 400 -jsonpath "$.message" contains "response delay is too much" diff --git a/test/hurl/api/session_delete.hurl b/test/hurl/api/session_delete.hurl deleted file mode 100644 index 4b699dd2..00000000 --- a/test/hurl/api/session_delete.hurl +++ /dev/null @@ -1,27 +0,0 @@ -POST http://{{ host }}:{{ port }}/api/session # create a new session - -HTTP 200 - -[Captures] -session_uuid: jsonpath "$.uuid" - -# --- Should be OK - -DELETE http://{{ host }}:{{ port }}/api/session/{{ session_uuid }} - -HTTP 200 - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$.success" == true - -# --- Should fails - -DELETE http://{{ host }}:{{ port }}/api/session/{{ session_uuid }} # repeat - -HTTP 404 - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$.code" == 404 -jsonpath "$.message" contains "not found" diff --git a/test/hurl/api/session_request.hurl b/test/hurl/api/session_request.hurl deleted file mode 100644 index 48ed1790..00000000 --- a/test/hurl/api/session_request.hurl +++ /dev/null @@ -1,80 +0,0 @@ -POST http://{{ host }}:{{ port }}/api/session # create a session -Content-Type: application/json - -{ - "status_code": 529, - "content_type": "application2/json1", - "response_content_base64": "Zm9vIGJhcg==" -} - -HTTP 200 - -[Captures] -session_uuid: jsonpath "$.uuid" - -# --- Send a simple request - -PUT http://{{ host }}:{{ port }}/{{ session_uuid }}/foo/bar.js?bla=1&bla2=baz -Foo-Header: BarValue - -``` -bar baz -``` - -HTTP 529 - -[Asserts] -header "Content-Type" contains "application2/json1" -body == "foo bar" - -# --- Get all recorded requests - -GET http://{{ host }}:{{ port }}/api/session/{{ session_uuid }}/requests - -HTTP 200 - -[Captures] -request_uuid: jsonpath "$[0].uuid" - -# -- Get recorded request data - -GET http://{{ host }}:{{ port }}/api/session/{{ session_uuid }}/requests/{{ request_uuid }} - -HTTP 200 - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$.client_address" isString -jsonpath "$.content_base64" == "YmFyIGJhego=" -jsonpath "$.created_at_unix" > 1600000000 -jsonpath "$.headers[*].name" includes "Foo-Header" -jsonpath "$.headers[*].value" includes "BarValue" -jsonpath "$.method" == "PUT" -jsonpath "$.url" == "/{{ session_uuid }}/foo/bar.js?bla=1&bla2=baz" - -# --- Delete the requests - -DELETE http://{{ host }}:{{ port }}/api/session/{{ session_uuid }}/requests/{{ request_uuid }} - -HTTP 200 - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$.success" == true - -# --- The request should not exist anymore - -GET http://{{ host }}:{{ port }}/api/session/{{ session_uuid }}/requests/{{ request_uuid }} - -HTTP 404 - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$.code" == 404 -jsonpath "$.message" contains "not found" - -# --- Cleanup - -DELETE http://{{ host }}:{{ port }}/api/session/{{ session_uuid }} - -HTTP 200 diff --git a/test/hurl/api/session_requests.hurl b/test/hurl/api/session_requests.hurl deleted file mode 100644 index 63aa1bec..00000000 --- a/test/hurl/api/session_requests.hurl +++ /dev/null @@ -1,87 +0,0 @@ -POST http://{{ host }}:{{ port }}/api/session # create a session -Content-Type: application/json - -{ - "status_code": 201, - "content_type": "application1/json2", - "response_content_base64": "Zm9vIGJhcg==" -} - -HTTP 200 - -[Captures] -session_uuid: jsonpath "$.uuid" - -# --- Check the initial state - -GET http://{{ host }}:{{ port }}/api/session/{{ session_uuid }}/requests - -HTTP 200 - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$" count == 0 - -# --- Send a simple request - -POST http://{{ host }}:{{ port }}/{{ session_uuid }}/foobar -Foo-Header: BarValue - -``` -bar baz -``` - -HTTP 201 - -[Asserts] -header "Content-Type" contains "application1/json2" -body == "foo bar" - -# --- Check the recorded requests again - -GET http://{{ host }}:{{ port }}/api/session/{{ session_uuid }}/requests - -HTTP 200 - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$" count == 1 -jsonpath "$[0].client_address" isString -jsonpath "$[0].content_base64" == "YmFyIGJhego=" -jsonpath "$[0].created_at_unix" > 1600000000 -jsonpath "$[0].headers[*].name" includes "Foo-Header" -jsonpath "$[0].headers[*].value" includes "BarValue" -jsonpath "$[0].method" == "POST" -jsonpath "$[0].url" == "/{{ session_uuid }}/foobar" - -# --- Delete all requests - -DELETE http://{{ host }}:{{ port }}/api/session/{{ session_uuid }}/requests - -HTTP 200 - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$.success" == true - -# --- Recorded requests should become empty - -GET http://{{ host }}:{{ port }}/api/session/{{ session_uuid }}/requests - -HTTP 200 - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$" count == 0 - -# --- Cleanup - -DELETE http://{{ host }}:{{ port }}/api/session/{{ session_uuid }} - -HTTP 200 - -# -- After the session removal, the request should fail - -GET http://{{ host }}:{{ port }}/api/session/{{ session_uuid }}/requests - -HTTP 404 diff --git a/test/hurl/api/settings.hurl b/test/hurl/api/settings.hurl deleted file mode 100644 index 7e706236..00000000 --- a/test/hurl/api/settings.hurl +++ /dev/null @@ -1,9 +0,0 @@ -GET http://{{ host }}:{{ port }}/api/settings - -HTTP 200 - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$.limits.max_requests" isInteger -jsonpath "$.limits.max_webhook_body_size" isInteger -jsonpath "$.limits.session_lifetime_sec" isInteger diff --git a/test/hurl/api/version.hurl b/test/hurl/api/version.hurl deleted file mode 100644 index 1fe7cc9b..00000000 --- a/test/hurl/api/version.hurl +++ /dev/null @@ -1,7 +0,0 @@ -GET http://{{ host }}:{{ port }}/api/version - -HTTP 200 - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$.version" isString diff --git a/test/hurl/health/live.hurl b/test/hurl/health/live.hurl deleted file mode 100644 index a43eed7f..00000000 --- a/test/hurl/health/live.hurl +++ /dev/null @@ -1,15 +0,0 @@ -GET http://{{ host }}:{{ port }}/live - -HTTP 200 - -[Asserts] -bytes count == 0 - -# --- Head request - -HEAD http://{{ host }}:{{ port }}/live - -HTTP 200 - -[Asserts] -bytes count == 0 diff --git a/test/hurl/health/metrics.hurl b/test/hurl/health/metrics.hurl deleted file mode 100644 index efb913da..00000000 --- a/test/hurl/health/metrics.hurl +++ /dev/null @@ -1,17 +0,0 @@ -# disabled until https://github.com/Orange-OpenSource/hurl/issues/2540 is not fixed - -#GET http://{{ host }}:{{ port }}/metrics -# -#HTTP 200 -# -#[Asserts] -#header "Content-Type" contains "text/plain" -#bytes count >= 50 -#body contains "go_goroutines" - -# -# --- Head request should fails -# -#HEAD http://{{ host }}:{{ port }}/metrics -# -#HTTP 404 diff --git a/test/hurl/health/ready.hurl b/test/hurl/health/ready.hurl deleted file mode 100644 index f8481486..00000000 --- a/test/hurl/health/ready.hurl +++ /dev/null @@ -1,15 +0,0 @@ -GET http://{{ host }}:{{ port }}/ready - -HTTP 200 - -[Asserts] -bytes count == 0 - -# --- Head request - -HEAD http://{{ host }}:{{ port }}/ready - -HTTP 200 - -[Asserts] -bytes count == 0 diff --git a/test/hurl/readme.md b/test/hurl/readme.md deleted file mode 100644 index 5faf2e30..00000000 --- a/test/hurl/readme.md +++ /dev/null @@ -1,27 +0,0 @@ -# Hurl - -Hurl is a command line tool that runs **HTTP requests** defined in a simple **plain text format**. - -## How to use - -It can perform requests, capture values and evaluate queries on headers and body response. Hurl is very versatile: it can be used for both fetching data and testing HTTP sessions. - -```hurl -# Get home: -GET https://example.net - -HTTP/1.1 200 -[Captures] -csrf_token: xpath "string(//meta[@name='_csrf_token']/@content)" - -# Do login! -POST https://example.net/login?user=toto&password=1234 -X-CSRF-TOKEN: {{csrf_token}} - -HTTP/1.1 302 -``` - -### Links - -- [Official website](https://hurl.dev/) -- [GitHub](https://github.com/Orange-OpenSource/hurl) diff --git a/test/hurl/static/404.hurl b/test/hurl/static/404.hurl deleted file mode 100644 index 20304558..00000000 --- a/test/hurl/static/404.hurl +++ /dev/null @@ -1,27 +0,0 @@ -GET http://{{ host }}:{{ port }}/foobar404 - -HTTP 404 - -[Asserts] -header "Content-Type" contains "text/html" -xpath "string(/html/head/title)" contains "Not found" # Check title - -# --- Head request request should not return a body - -HEAD http://{{ host }}:{{ port }}/foobar404 -Accept: */* - -HTTP 404 - -[Asserts] -bytes count == 0 - -# --- Simple (not existent) file requested - -GET http://{{ host }}:{{ port }}/foo/bar/404.json - -HTTP 404 - -[Asserts] -header "Content-Type" contains "text/html" -xpath "string(/html/head/title)" contains "Not found" # Check title diff --git a/test/hurl/static/favicon.hurl b/test/hurl/static/favicon.hurl deleted file mode 100644 index 02456775..00000000 --- a/test/hurl/static/favicon.hurl +++ /dev/null @@ -1,18 +0,0 @@ -GET http://{{ host }}:{{ port }}/favicon.ico -Accept: */* - -HTTP 200 - -[Asserts] -header "Content-Type" contains "image" -bytes startsWith hex,00000100030030; - -# --- Head request request should not return a body - -HEAD http://{{ host }}:{{ port }}/favicon.ico -Accept: */* - -HTTP 200 - -[Asserts] -bytes count == 0 diff --git a/test/hurl/static/index.hurl b/test/hurl/static/index.hurl deleted file mode 100644 index c73c3934..00000000 --- a/test/hurl/static/index.hurl +++ /dev/null @@ -1,23 +0,0 @@ -GET http://{{ host }}:{{ port }}/ -User-Agent: Mozilla/5.0 -Accept: */* -Accept-Language: en-US,en;q=0.5 -Accept-Encoding: gzip, deflate, br -Connection: keep-alive - -HTTP 200 - -[Asserts] -header "Content-Type" contains "text/html" -xpath "string(/html/head/title)" contains "WebHook Tester" # Check title -xpath "//*[@id='app']" count == 1 # App mounting point exist - -# --- Head request request should not return a body - -HEAD http://{{ host }}:{{ port }}/ -Accept: */* - -HTTP 200 - -[Asserts] -bytes count == 0 diff --git a/test/hurl/static/robots.hurl b/test/hurl/static/robots.hurl deleted file mode 100644 index 7ebab3d7..00000000 --- a/test/hurl/static/robots.hurl +++ /dev/null @@ -1,17 +0,0 @@ -GET http://{{ host }}:{{ port }}/robots.txt - -HTTP 200 - -[Asserts] -header "Content-Type" contains "text/plain" -bytes count >= 13 - -# --- Head request request should not return a body - -HEAD http://{{ host }}:{{ port }}/robots.txt -Accept: */* - -HTTP 200 - -[Asserts] -bytes count == 0 diff --git a/test/hurl/static/sitemap.hurl b/test/hurl/static/sitemap.hurl deleted file mode 100644 index 7d4f7dda..00000000 --- a/test/hurl/static/sitemap.hurl +++ /dev/null @@ -1,16 +0,0 @@ -GET http://{{ host }}:{{ port }}/sitemap.xml - -HTTP 200 - -[Asserts] -header "Content-Type" contains "text/xml" - -# --- Head request request should not return a body - -HEAD http://{{ host }}:{{ port }}/sitemap.xml -Accept: */* - -HTTP 200 - -[Asserts] -bytes count == 0 diff --git a/test/hurl/webhook/no_session.hurl b/test/hurl/webhook/no_session.hurl deleted file mode 100644 index fbf0e2d9..00000000 --- a/test/hurl/webhook/no_session.hurl +++ /dev/null @@ -1,7 +0,0 @@ -GET http://{{ host }}:{{ port }}/c1eb8242-0cf8-47ec-927a-69eda4a989a8 - -HTTP 404 - -[Asserts] -header "Content-Type" contains "text/html" -body contains "WebHook: Not Found" diff --git a/test/hurl/webhook/simple.hurl b/test/hurl/webhook/simple.hurl deleted file mode 100644 index 65a7eced..00000000 --- a/test/hurl/webhook/simple.hurl +++ /dev/null @@ -1,148 +0,0 @@ -# --- Create a session first - -POST http://{{ host }}:{{ port }}/api/session -Content-Type: application/json - -{ - "status_code": 201, - "content_type": "text/plain", - "response_delay": 0, - "response_content_base64": "Zm9vIGJhcg==" -} - -HTTP 200 - -[Captures] -session_uuid: jsonpath "$.uuid" - -# --- Method GET - -GET http://{{ host }}:{{ port }}/{{ session_uuid }} -Foo-Header: BarValue - -HTTP 201 - -[Asserts] -header "Content-Type" contains "text/plain" -body == "foo bar" - -# --- Method HEAD - -HEAD http://{{ host }}:{{ port }}/{{ session_uuid }} -Foo-Header: BarValue - -HTTP 201 - -[Asserts] -header "Content-Type" contains "text/plain" -bytes count == 0 - -# --- Method POST - -POST http://{{ host }}:{{ port }}/{{ session_uuid }} -Foo-Header: BarValue - -``` -bar baz -``` - -HTTP 201 - -[Asserts] -header "Content-Type" contains "text/plain" -body == "foo bar" - -# --- Method PUT - -PUT http://{{ host }}:{{ port }}/{{ session_uuid }} -Foo-Header: BarValue - -``` -bar baz -``` - -HTTP 201 - -[Asserts] -header "Content-Type" contains "text/plain" -body == "foo bar" - -# --- Method PATCH - -PATCH http://{{ host }}:{{ port }}/{{ session_uuid }} -Foo-Header: BarValue - -``` -bar baz -``` - -HTTP 201 - -[Asserts] -header "Content-Type" contains "text/plain" -body == "foo bar" - -# --- Method DELETE - -DELETE http://{{ host }}:{{ port }}/{{ session_uuid }} -Foo-Header: BarValue - -``` -bar baz -``` - -HTTP 201 - -[Asserts] -header "Content-Type" contains "text/plain" -body == "foo bar" - -# --- Method OPTIONS - -OPTIONS http://{{ host }}:{{ port }}/{{ session_uuid }} -Foo-Header: BarValue - -``` -bar baz -``` - -HTTP 201 - -[Asserts] -header "Content-Type" contains "text/plain" -body == "foo bar" - -# --- Method TRACE - -TRACE http://{{ host }}:{{ port }}/{{ session_uuid }} -Foo-Header: BarValue - -``` -bar baz -``` - -HTTP 201 - -[Asserts] -header "Content-Type" contains "text/plain" -body == "foo bar" - -# --- Check the recorded requests - -GET http://{{ host }}:{{ port }}/api/session/{{ session_uuid }}/requests - -HTTP 200 - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$" count == 8 # GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE -jsonpath "$[*].content_base64" includes "YmFyIGJhego=" -jsonpath "$[*].headers[*].name" includes "Foo-Header" -jsonpath "$[*].headers[*].value" includes "BarValue" -jsonpath "$[0].url" == "/{{ session_uuid }}" - -# --- Cleanup - -DELETE http://{{ host }}:{{ port }}/api/session/{{ session_uuid }} - -HTTP 200 diff --git a/test/hurl/webhook/status_code.hurl b/test/hurl/webhook/status_code.hurl deleted file mode 100644 index 88567236..00000000 --- a/test/hurl/webhook/status_code.hurl +++ /dev/null @@ -1,167 +0,0 @@ -# --- Create a session first - -POST http://{{ host }}:{{ port }}/api/session -Content-Type: application/json - -{ - "status_code": 201, - "content_type": "text/plain", - "response_delay": 0, - "response_content_base64": "Zm9vIGJhcg==" -} - -HTTP 200 - -[Captures] -session_uuid: jsonpath "$.uuid" - -# --- Method GET - -GET http://{{ host }}:{{ port }}/{{ session_uuid }}/210 -Foo-Header: BarValue - -HTTP 210 - -[Asserts] -header "Content-Type" contains "text/plain" -body == "foo bar" - -# --- Method HEAD - -HEAD http://{{ host }}:{{ port }}/{{ session_uuid }}/211 -Foo-Header: BarValue - -HTTP 211 - -[Asserts] -header "Content-Type" contains "text/plain" -bytes count == 0 - -# --- Method POST - -POST http://{{ host }}:{{ port }}/{{ session_uuid }}/212 -Foo-Header: BarValue - -``` -bar baz -``` - -HTTP 212 - -[Asserts] -header "Content-Type" contains "text/plain" -body == "foo bar" - -# --- Method PUT - -PUT http://{{ host }}:{{ port }}/{{ session_uuid }}/213 -Foo-Header: BarValue - -``` -bar baz -``` - -HTTP 213 - -[Asserts] -header "Content-Type" contains "text/plain" -body == "foo bar" - -# --- Method PATCH - -PATCH http://{{ host }}:{{ port }}/{{ session_uuid }}/214 -Foo-Header: BarValue - -``` -bar baz -``` - -HTTP 214 - -[Asserts] -header "Content-Type" contains "text/plain" -body == "foo bar" - -# --- Method DELETE - -DELETE http://{{ host }}:{{ port }}/{{ session_uuid }}/215 -Foo-Header: BarValue - -``` -bar baz -``` - -HTTP 215 - -[Asserts] -header "Content-Type" contains "text/plain" -body == "foo bar" - -# --- Method OPTIONS - -OPTIONS http://{{ host }}:{{ port }}/{{ session_uuid }}/216 -Foo-Header: BarValue - -``` -bar baz -``` - -HTTP 216 - -[Asserts] -header "Content-Type" contains "text/plain" -body == "foo bar" - -# --- Method TRACE - -TRACE http://{{ host }}:{{ port }}/{{ session_uuid }}/217 -Foo-Header: BarValue - -``` -bar baz -``` - -HTTP 217 - -[Asserts] -header "Content-Type" contains "text/plain" -body == "foo bar" - -# --- The final slash is a mistake - -GET http://{{ host }}:{{ port }}/{{ session_uuid }}/210/ # <-- final slash - -HTTP 201 # <-- 201 instead of 210 - -[Asserts] -header "Content-Type" contains "text/plain" -body == "foo bar" - -# --- Anything after the code - too - -GET http://{{ host }}:{{ port }}/{{ session_uuid }}/210/foobar - -HTTP 201 # <-- 201 instead of 210 - -[Asserts] -header "Content-Type" contains "text/plain" -body == "foo bar" - -# --- Check the recorded requests - -GET http://{{ host }}:{{ port }}/api/session/{{ session_uuid }}/requests - -HTTP 200 - -[Asserts] -header "Content-Type" contains "application/json" -jsonpath "$" count == 10 # GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE; + GET + GET -jsonpath "$[*].content_base64" includes "YmFyIGJhego=" -jsonpath "$[*].headers[*].name" includes "Foo-Header" -jsonpath "$[*].headers[*].value" includes "BarValue" - -# --- Cleanup - -DELETE http://{{ host }}:{{ port }}/api/session/{{ session_uuid }} - -HTTP 200 diff --git a/test/hurl/websocket/session.hurl b/test/hurl/websocket/session.hurl deleted file mode 100644 index 4031f5fb..00000000 --- a/test/hurl/websocket/session.hurl +++ /dev/null @@ -1,25 +0,0 @@ -POST http://{{ host }}:{{ port }}/api/session # create a session with all default values - -HTTP 200 - -[Captures] -session_uuid: jsonpath "$.uuid" - -# --- - -GET http://{{ host }}:{{ port }}/ws/session/{{ session_uuid }} -Connection: Upgrade -Upgrade: websocket -Sec-WebSocket-Version: 13 - -HTTP 400 - -[Asserts] -header "Content-Type" contains "text/plain" -body contains "'Sec-WebSocket-Key' header is missing" - -# --- Cleanup - -DELETE http://{{ host }}:{{ port }}/api/session/{{ session_uuid }} - -HTTP 200 diff --git a/web/.dockerignore b/web/.dockerignore deleted file mode 100644 index a04e6166..00000000 --- a/web/.dockerignore +++ /dev/null @@ -1,3 +0,0 @@ -/node_modules -/dist -/src/api/schema.gen.ts diff --git a/web/.eslintrc.js b/web/.eslintrc.js deleted file mode 100644 index 5007cc14..00000000 --- a/web/.eslintrc.js +++ /dev/null @@ -1,17 +0,0 @@ -/* global module */ - -module.exports = { - root: true, - parser: "vue-eslint-parser", - parserOptions: { - parser: "@typescript-eslint/parser", - }, - extends: [ - "plugin:vue/strongly-recommended", - "eslint:recommended", - "@vue/typescript/recommended", - ], - plugins: ["@typescript-eslint"], - rules: {}, - ignorePatterns: ["schema.gen.ts"], -} diff --git a/web/api.generate.js b/web/api.generate.js new file mode 100644 index 00000000..4efbdf14 --- /dev/null +++ b/web/api.generate.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node + +import fs from 'node:fs' +import process from 'node:process' +import openapiTS, { astToString } from 'openapi-typescript' + +/** @param {string} message */ +const panic = (message) => { + console.error(message) + process.exit(1) +} + +/** + * @param {string} input Source OpenAPI file + * @param {string} output Output d.ts file + * @return {Promise} + */ +const apiGenerate = async (input, output) => { + const ast = await openapiTS(new URL(input, import.meta.url), { + additionalProperties: false, + arrayLength: true, + emptyObjectsUnknown: false, + enum: true, + immutable: true, + }) + + fs.writeFileSync(output, astToString(ast)) +} + +const output = process.argv[3] +const input = process.argv[2] + +if (!input || typeof input !== 'string') { + panic('Please provide an input file') +} else if (!output || typeof output !== 'string') { + panic('Please provide an output file') +} + +await Promise.all([apiGenerate(input, output)]).catch((error) => { + panic(error) +}) diff --git a/web/embed.go b/web/embed.go index 175b9ed1..dd264de7 100644 --- a/web/embed.go +++ b/web/embed.go @@ -1,18 +1,47 @@ -//go:build !watch - package web import ( "embed" + "errors" "io/fs" + "os" + "path" + "path/filepath" + "runtime" ) +// Generate mock distributive files, if needed. +//go:generate go run generate_dist_stub.go + //go:embed dist var content embed.FS -// Content returns the embedded web content. -func Content() fs.FS { - data, _ := fs.Sub(content, "dist") +// Dist returns frontend distributive files. If live is true, it returns files from the dist directory, otherwise +// from the embedded content. Live might be useful for development purposes. +func Dist(live bool) fs.FS { + const distDirName = "dist" + + if live { + // get the current file path (to resolve the dist directory path later) + _, filePath, _, ok := runtime.Caller(0) + if !ok { + return noFs("unable to get the current file path") + } + + return os.DirFS(path.Join(filepath.Dir(filePath), distDirName)) + } else { + data, err := fs.Sub(content, distDirName) + if err != nil { + return noFs("dist directory not found") + } - return data + return data + } } + +// noFs is a mock fs.FS implementation, which returns an error on Open. +type noFs string + +var _ fs.FS = (*noFs)(nil) // verify that noFs implements fs.FS + +func (fs noFs) Open(string) (fs.File, error) { return nil, errors.New("web/dist: " + string(fs)) } diff --git a/web/embed_test.go b/web/embed_test.go new file mode 100644 index 00000000..bfc84d7c --- /dev/null +++ b/web/embed_test.go @@ -0,0 +1,30 @@ +package web_test + +import ( + "io/fs" + "testing" + + "github.com/stretchr/testify/require" + + "gh.tarampamp.am/webhook-tester/v2/web" +) + +func TestDist(t *testing.T) { + t.Parallel() + + for _, fileSystem := range []fs.FS{ + web.Dist(true), + web.Dist(false), + } { + f, err := fileSystem.Open("index.html") + require.NoError(t, err) + require.NotNil(t, f) + + // file is not empty + bytes, err := f.Read(make([]byte, 2)) + require.NoError(t, err) + require.Equal(t, 2, bytes) + + require.NoError(t, f.Close()) + } +} diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 00000000..f0f4a4da --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,66 @@ +import { fixupConfigRules, fixupPluginRules } from '@eslint/compat' +import typescriptEslint from '@typescript-eslint/eslint-plugin' +import reactRefresh from 'eslint-plugin-react-refresh' +import react from 'eslint-plugin-react' +import globals from 'globals' +import tsParser from '@typescript-eslint/parser' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import js from '@eslint/js' +import { FlatCompat } from '@eslint/eslintrc' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}) + +export default [ + { + ignores: ['dist/*'], + }, + ...fixupConfigRules( + compat.extends( + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended' + ) + ), + { + plugins: { + '@typescript-eslint': fixupPluginRules(typescriptEslint), + 'react-refresh': reactRefresh, + react: fixupPluginRules(react), + }, + languageOptions: { + globals: { + ...globals.browser, + }, + parser: tsParser, + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { + project: true, + jsx: true, + warnOnUnsupportedTypeScriptVersion: false, // https://stackoverflow.com/a/78997913/2252921 + }, + }, + settings: { + react: { + version: 'detect', + }, + }, + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/jsx-key': [ + 'error', + { + checkFragmentShorthand: true, + }, + ], + }, + }, +] diff --git a/web/generate_dist_stub.go b/web/generate_dist_stub.go new file mode 100644 index 00000000..50815703 --- /dev/null +++ b/web/generate_dist_stub.go @@ -0,0 +1,37 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "errors" + "os" + "path" +) + +func main() { + const distDir = "./dist" + + if _, err := os.Stat(distDir); err != nil && errors.Is(err, os.ErrNotExist) { + if err = os.Mkdir(distDir, 0755); err != nil { + panic(err) + } + } + + var ( + indexPath = path.Join(distDir, "index.html") + robotsPath = path.Join(distDir, "robots.txt") + ) + + if _, err := os.Stat(indexPath); err != nil && errors.Is(err, os.ErrNotExist) { + if err = os.WriteFile(indexPath, []byte("\n"), 0644); err != nil { + panic(err) + } + } + + if _, err := os.Stat(robotsPath); err != nil && errors.Is(err, os.ErrNotExist) { + if err = os.WriteFile(robotsPath, []byte("User-agent: *\nDisallow: /\n"), 0644); err != nil { + panic(err) + } + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 00000000..087b82a0 --- /dev/null +++ b/web/index.html @@ -0,0 +1,115 @@ + + + + + + + + + + + + + WebHook Tester + + + + + +
+
+ + + Please wait... + + +
+
+ + + diff --git a/web/package-lock.json b/web/package-lock.json index 246f31ee..0fcf5a5b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -4,766 +4,2009 @@ "requires": true, "packages": { "": { - "dependencies": { - "@fortawesome/fontawesome-svg-core": "^6.6.0", - "@fortawesome/free-brands-svg-icons": "^6.6.0", - "@fortawesome/free-regular-svg-icons": "^6.6.0", - "@fortawesome/free-solid-svg-icons": "^6.6.0", - "@fortawesome/vue-fontawesome": "^3.0.8", - "@highlightjs/vue-plugin": "^2.1.0", - "@popperjs/core": "^2.11.8", - "bootstrap": "^5.3.2", - "bootswatch": "^5.3.2", - "clipboard": "^2.0.11", - "izitoast": "^1.4.0", - "js-base64": "^3.7.7", - "moment": "^2.30.1", - "openapi-typescript-fetch": "^2.0.0", - "reconnecting-websocket": "^4.4.0", - "vue": "^3.5.0", - "vue-router": "^4.4.3" + "name": "web", + "dependencies": { + "@mantine/code-highlight": "^7.13.4", + "@mantine/core": "^7.13.4", + "@mantine/hooks": "^7.13.4", + "@mantine/notifications": "^7.13.4", + "@tabler/icons-react": "^3.20.0", + "dayjs": "^1.11.13", + "openapi-fetch": "^0.12.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", + "semver": "^7.6.3" }, "devDependencies": { - "@types/bootstrap": "^5.2.10", - "@typescript-eslint/eslint-plugin": "^5.62.0", - "@typescript-eslint/parser": "^5.62.0", - "@vue/eslint-config-typescript": "^13.0.0", - "copy-webpack-plugin": "^12.0.2", - "css-loader": "^7.1.2", - "eslint": "^8.57.0", - "eslint-plugin-vue": "^9.28.0", - "filemanager-webpack-plugin": "^8.0.0", - "html-webpack-plugin": "^5.6.0", - "json-minimizer-webpack-plugin": "^5.0.0", - "mini-css-extract-plugin": "^2.9.1", - "openapi-typescript": "^5.4.1", - "sass": "^1.78.0", - "sass-loader": "^16.0.1", - "terser-webpack-plugin": "^5.3.10", - "ts-loader": "^9.5.1", - "type-fest": "^4.26.0", - "typescript": "^5.5.4", - "vue-eslint-parser": "^9.4.3", - "vue-loader": "^17.4.2", - "vue-style-loader": "^4.1.3", - "webpack": "^5.94.0", - "webpack-cli": "^5.1.4", - "webpack-merge": "^6.0.1" + "@eslint/compat": "^1.2.1", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.13.0", + "@types/node": "^22.7.9", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/semver": "^7.5.8", + "@typescript-eslint/eslint-plugin": "^8.11.0", + "@typescript-eslint/parser": "^8.11.0", + "@vitejs/plugin-react": "^4.3.3", + "eslint": "^9.13.0", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.11.0", + "openapi-typescript": "^7.4.1", + "postcss": "^8.4.47", + "postcss-preset-mantine": "^1.17.0", + "postcss-simple-vars": "^7.0.1", + "prettier": "^3.3.3", + "typescript": "^5.6.3", + "vite": "^5.4.10", + "vitest": "^2.1.3", + "vitest-fetch-mock": "^0.3.0" + }, + "engines": { + "node": ">=21" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { - "node": ">=16" + "node": ">=6.0.0" } }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "node_modules/@babel/code-frame": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.7.tgz", + "integrity": "sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==", "dev": true, + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.25.7", + "picocolors": "^1.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=6.9.0" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", - "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", + "node_modules/@babel/compat-data": { + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.8.tgz", + "integrity": "sha512-ZsysZyXY4Tlx+Q53XdnOFmqwfB9QDTHYxaZYajWRoBLuLEAwI2UIbtxOjWh/cFaa9IKUlcB+DDuoskLuKu56JA==", + "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "node_modules/@babel/core": { + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.8.tgz", + "integrity": "sha512-Oixnb+DzmRT30qu9d3tJSQkxuygWm32DFykT4bRoORPa9hZ/L4KhVB/XiRm6KG+roIEM7DBQlmg27kw2HZkdZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.25.7", + "@babel/generator": "^7.25.7", + "@babel/helper-compilation-targets": "^7.25.7", + "@babel/helper-module-transforms": "^7.25.7", + "@babel/helpers": "^7.25.7", + "@babel/parser": "^7.25.8", + "@babel/template": "^7.25.7", + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.8", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, "engines": { "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/parser": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", - "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", - "dependencies": { - "@babel/types": "^7.25.6" - }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", "bin": { - "parser": "bin/babel-parser.js" + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.7.tgz", + "integrity": "sha512-5Dqpl5fyV9pIAD62yK9P7fcA768uVPUyrQmqpqstHWgMma4feF1x/oFysBCVZLY5wJ2GkMUCdsNDnGZrPoR6rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.7", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" }, "engines": { - "node": ">=6.0.0" + "node": ">=6.9.0" } }, - "node_modules/@babel/types": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", - "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", + "node_modules/@babel/helper-compilation-targets": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.7.tgz", + "integrity": "sha512-DniTEax0sv6isaw6qSQSfV4gVRNtw2rte8HHM45t9ZR0xILaufBRNkpMifCRiAPyvL4ACD6v0gfCwCmtOQaV4A==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.24.8", - "@babel/helper-validator-identifier": "^7.24.7", - "to-fast-properties": "^2.0.0" + "@babel/compat-data": "^7.25.7", + "@babel/helper-validator-option": "^7.25.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@discoveryjs/json-ext": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", - "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.7.tgz", + "integrity": "sha512-o0xCgpNmRohmnoWKQ0Ij8IdddjyBFE4T2kagL/x6M3+4zUgc+4qTOUBoNe4XxDskt1HPKO007ZPiMgLDq2s7Kw==", "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.7" + }, "engines": { - "node": ">=10.0.0" + "node": ">=6.9.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "node_modules/@babel/helper-module-transforms": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.7.tgz", + "integrity": "sha512-k/6f8dKG3yDz/qCwSM+RKovjMix563SLxQFo0UhRNo239SP6n9u5/eLtKD6EAjwta2JHJ49CsD8pms2HdNiMMQ==", "dev": true, + "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "@babel/helper-module-imports": "^7.25.7", + "@babel/helper-simple-access": "^7.25.7", + "@babel/helper-validator-identifier": "^7.25.7", + "@babel/traverse": "^7.25.7" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=6.9.0" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "@babel/core": "^7.0.0" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "node_modules/@babel/helper-plugin-utils": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.7.tgz", + "integrity": "sha512-eaPZai0PiqCi09pPs3pAFfl/zYgGaE6IdXtYvmf0qlcDTd3WCtO7JWCcRd64e0EQrcYgiHibEZnOGsSY4QSgaw==", "dev": true, + "license": "MIT", "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=6.9.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "node_modules/@babel/helper-simple-access": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.7.tgz", + "integrity": "sha512-FPGAkJmyoChQeM+ruBGIDyrT2tKfZJO8NcxdC+CWNJi7N8/rZpSxK7yvBJ5O/nF1gfu5KzN7VKG3YVSLFfRSxQ==", "dev": true, + "license": "MIT", "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.7" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=6.9.0" } }, - "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "node_modules/@babel/helper-string-parser": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.7.tgz", + "integrity": "sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==", "dev": true, + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=6.9.0" } }, - "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz", + "integrity": "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==", "dev": true, + "license": "MIT", "engines": { - "node": ">=14" + "node": ">=6.9.0" } }, - "node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz", - "integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==", + "node_modules/@babel/helper-validator-option": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.7.tgz", + "integrity": "sha512-ytbPLsm+GjArDYXJ8Ydr1c/KJuutjF2besPNbIZnZ6MKUxi/uTA22t2ymmA4WFjZFpjiAMO0xuuJPqK2nvDVfQ==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6" + "node": ">=6.9.0" } }, - "node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", - "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", + "node_modules/@babel/helpers": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.7.tgz", + "integrity": "sha512-Sv6pASx7Esm38KQpF/U/OXLwPPrdGHNKoeblRxgZRLXnAtnkEe4ptJPDtAZM7fBLadbc1Q07kQpSiGQ0Jg6tRA==", + "dev": true, + "license": "MIT", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.6.0" + "@babel/template": "^7.25.7", + "@babel/types": "^7.25.7" }, "engines": { - "node": ">=6" + "node": ">=6.9.0" } }, - "node_modules/@fortawesome/free-brands-svg-icons": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.6.0.tgz", - "integrity": "sha512-1MPD8lMNW/earme4OQi1IFHtmHUwAKgghXlNwWi9GO7QkTfD+IIaYpIai4m2YJEzqfEji3jFHX1DZI5pbY/biQ==", + "node_modules/@babel/highlight": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.7.tgz", + "integrity": "sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==", + "dev": true, + "license": "MIT", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.6.0" + "@babel/helper-validator-identifier": "^7.25.7", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "engines": { - "node": ">=6" + "node": ">=6.9.0" } }, - "node_modules/@fortawesome/free-regular-svg-icons": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.6.0.tgz", - "integrity": "sha512-Yv9hDzL4aI73BEwSEh20clrY8q/uLxawaQ98lekBx6t9dQKDHcDzzV1p2YtBGTtolYtNqcWdniOnhzB+JPnQEQ==", + "node_modules/@babel/parser": { + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.8.tgz", + "integrity": "sha512-HcttkxzdPucv3nNFmfOOMfFf64KgdJVqm1KaCm25dPGMLElo9nsLvXeJECQg8UzPuBGLyTSA0ZzqCtDSzKTEoQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.6.0" + "@babel/types": "^7.25.8" + }, + "bin": { + "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=6" + "node": ">=6.0.0" } }, - "node_modules/@fortawesome/free-solid-svg-icons": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz", - "integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==", + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.7.tgz", + "integrity": "sha512-JD9MUnLbPL0WdVK8AWC7F7tTG2OS6u/AKKnsK+NdRhUiVdnzyR1S3kKQCaRLOiaULvUiqK6Z4JQE635VgtCFeg==", + "dev": true, + "license": "MIT", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.6.0" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { - "node": ">=6" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@fortawesome/vue-fontawesome": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.8.tgz", - "integrity": "sha512-yyHHAj4G8pQIDfaIsMvQpwKMboIZtcHTUvPqXjOHyldh1O1vZfH4W03VDPv5RvI9P6DLTzJQlmVgj9wCf7c2Fw==", + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.7.tgz", + "integrity": "sha512-S/JXG/KrbIY06iyJPKfxr0qRxnhNOdkNXYBl/rmwgDd72cQLH9tEGkDm/yJPGvcSIUoikzfjMios9i+xT/uv9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, "peerDependencies": { - "@fortawesome/fontawesome-svg-core": "~1 || ~6", - "vue": ">= 3.0.0 < 4" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@highlightjs/vue-plugin": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@highlightjs/vue-plugin/-/vue-plugin-2.1.0.tgz", - "integrity": "sha512-E+bmk4ncca+hBEYRV2a+1aIzIV0VSY/e5ArjpuSN9IO7wBJrzUE2u4ESCwrbQD7sAy+jWQjkV5qCCWgc+pu7CQ==", - "peerDependencies": { - "highlight.js": "^11.0.1", - "vue": "^3" + "node_modules/@babel/runtime": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.9.tgz", + "integrity": "sha512-4zpTHZ9Cm6L9L+uIqghQX8ZXg8HKFcjYO3qHoO8zTmRm6HQUJ8SSJ+KRvbMBZn0EGVlT4DRYeQ/6hjlyXBh+Kg==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "node_modules/@babel/template": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz", + "integrity": "sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==", "dev": true, + "license": "MIT", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" + "@babel/code-frame": "^7.25.7", + "@babel/parser": "^7.25.7", + "@babel/types": "^7.25.7" }, "engines": { - "node": ">=10.10.0" + "node": ">=6.9.0" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@babel/traverse": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.7.tgz", + "integrity": "sha512-jatJPT1Zjqvh/1FyJs6qAHL+Dzb7sTb+xr7Q+gM1b+1oBsMsQQ4FkVKb6dFlJvLlVssqkRzV05Jzervt9yhnzg==", "dev": true, - "engines": { - "node": ">=12.22" + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.25.7", + "@babel/generator": "^7.25.7", + "@babel/parser": "^7.25.7", + "@babel/template": "^7.25.7", + "@babel/types": "^7.25.7", + "debug": "^4.3.1", + "globals": "^11.1.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", - "dev": true + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "node_modules/@babel/types": { + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.8.tgz", + "integrity": "sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" + "@babel/helper-string-parser": "^7.25.7", + "@babel/helper-validator-identifier": "^7.25.7", + "to-fast-properties": "^2.0.0" }, "engines": { - "node": ">=6.0.0" + "node": ">=6.9.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=6.0.0" + "node": ">=12" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6.0.0" + "node": ">=12" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 8" + "node": ">=12" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 8" + "node": ">=12" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">= 8" + "node": ">=12" } }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@sindresorhus/merge-streams": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", - "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/@types/archiver": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-5.3.4.tgz", - "integrity": "sha512-Lj7fLBIMwYFgViVVZHEdExZC3lVYsl+QL0VmdNdIzGZH544jHveYWij6qdnBgJQDnR7pMKliN9z2cPZFEbhyPw==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "@types/readdir-glob": "*" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@types/bootstrap": { - "version": "5.2.10", - "resolved": "https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.2.10.tgz", - "integrity": "sha512-F2X+cd6551tep0MvVZ6nM8v7XgGN/twpdNDjqS1TUM7YFNEtQYWk+dKAnH+T1gr6QgCoGMPl487xw/9hXooa2g==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], "dev": true, - "dependencies": { - "@popperjs/core": "^2.9.2" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/@types/html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", - "dev": true + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/@types/node": { - "version": "20.11.28", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.28.tgz", - "integrity": "sha512-M/GPWVS2wLkSkNHVeLkrF2fD5Lx5UC4PxA0uZcKc6QqbIQUJyW1jVjueJYi1z8n0I5PxYrtpnPnWglE+y9A0KA==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], "dev": true, - "dependencies": { - "undici-types": "~5.26.4" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@types/readdir-glob": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", - "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], "dev": true, - "dependencies": { - "@types/node": "*" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@types/semver": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", - "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", - "dev": true + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", - "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/type-utils": "5.62.0", - "@typescript-eslint/utils": "5.62.0", - "debug": "^4.3.4", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "natural-compare-lite": "^1.4.0", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "eslint-visitor-keys": "^3.3.0" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, "peerDependencies": { - "@typescript-eslint/parser": "^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@typescript-eslint/parser": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", - "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", + "node_modules/@eslint-community/regexpp": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz", + "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==", "dev": true, - "dependencies": { - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", - "debug": "^4.3.4" - }, + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/compat": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.2.1.tgz", + "integrity": "sha512-JbHG2TWuCeNzh87fXo+/46Z1LEo9DBA9T188d0fZgGxAD+cNyS6sx9fdiyxjGPBMyQVRlCutTByZ6a5+YMkF7g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "eslint": "^9.10.0" }, "peerDependenciesMeta": { - "typescript": { + "eslint": { "optional": true } } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", - "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", + "node_modules/@eslint/config-array": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", + "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0" + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", - "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "5.62.0", - "@typescript-eslint/utils": "5.62.0", - "debug": "^4.3.4", - "tsutils": "^3.21.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "node": "*" } }, - "node_modules/@typescript-eslint/types": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", - "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", + "node_modules/@eslint/core": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", + "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", "dev": true, + "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", - "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", + "node_modules/@eslint/eslintrc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "url": "https://opencollective.com/eslint" } }, - "node_modules/@typescript-eslint/utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", - "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@types/json-schema": "^7.0.9", - "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", - "eslint-scope": "^5.1.1", - "semver": "^7.3.7" - }, + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", - "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, + "license": "ISC", "dependencies": { - "@typescript-eslint/types": "5.62.0", - "eslint-visitor-keys": "^3.3.0" + "brace-expansion": "^1.1.7" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": "*" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.0.tgz", - "integrity": "sha512-ja7cpqAOfw4tyFAxgBz70Z42miNDeaqTxExTsnXDLomRpqfyCgyvZvFp482fmsElpfvsoMJUsvzULhvxUTW6Iw==", - "dependencies": { - "@babel/parser": "^7.25.3", - "@vue/shared": "3.5.0", - "entities": "^4.5.0", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.0" + "node_modules/@eslint/js": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", + "integrity": "sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@vue/compiler-core/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.0.tgz", - "integrity": "sha512-xYjUybWZXl+1R/toDy815i4PbeehL2hThiSGkcpmIOCy2HoYyeeC/gAWK/Y/xsoK+GSw198/T5O31bYuQx5uvQ==", + "node_modules/@eslint/plugin-kit": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz", + "integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.11.tgz", + "integrity": "sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.25", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.25.tgz", + "integrity": "sha512-hZOmgN0NTOzOuZxI1oIrDu3Gcl8WViIkvPMpB4xdd4QD6xAMtwgwr3VPoiyH/bLtRcS1cDnhxLSD1NsMJmwh/A==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.0.tgz", + "integrity": "sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.5", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.5.tgz", + "integrity": "sha512-KSPA4umqSG4LHYRodq31VDwKAvaTF4xmVlzM8Aeh4PlU1JQ3IG0wiA8C25d3RQ9nJyM3mBHyI53K06VVL/oFFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.0", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mantine/code-highlight": { + "version": "7.13.4", + "resolved": "https://registry.npmjs.org/@mantine/code-highlight/-/code-highlight-7.13.4.tgz", + "integrity": "sha512-aed9h5Rxu7WMFNirrrZp6efAwAn6PioxMGSB1zYfcQNtWl10jDvzvYYbGWHT9LSGLxDdkD6QAtVU/+NiNSrkJQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "highlight.js": "^11.9.0" + }, + "peerDependencies": { + "@mantine/core": "7.13.4", + "@mantine/hooks": "7.13.4", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, + "node_modules/@mantine/core": { + "version": "7.13.4", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.13.4.tgz", + "integrity": "sha512-9I6+SqTq90pnI3WPmOQzQ1PL7IkhQg/5ft8Awhgut8tvk1VaKruDm/K5ysUG3ncHrP+QTI2UHYjNlUrux6HKlw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.9", + "clsx": "^2.1.1", + "react-number-format": "^5.3.1", + "react-remove-scroll": "^2.5.7", + "react-textarea-autosize": "8.5.3", + "type-fest": "^4.12.0" + }, + "peerDependencies": { + "@mantine/hooks": "7.13.4", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, + "node_modules/@mantine/hooks": { + "version": "7.13.4", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.13.4.tgz", + "integrity": "sha512-B2QCegQyWlLdenVNaLNK8H9cTAjLW9JKJ3xWg+ShhpjZDHT2hjZz4L0Nt071Z7mPvyAaOwKGM0FyqTcTjdECfg==", + "license": "MIT", + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/@mantine/notifications": { + "version": "7.13.4", + "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-7.13.4.tgz", + "integrity": "sha512-CKd3tDGDAegkJYJIMHtF0St4hBpBVAujdmtsEin7UYeVq5N0YYe7j2T1Xu7Ry6dfObkuxeig6csxiJyBrZ2bew==", + "license": "MIT", + "dependencies": { + "@mantine/store": "7.13.4", + "react-transition-group": "4.4.5" + }, + "peerDependencies": { + "@mantine/core": "7.13.4", + "@mantine/hooks": "7.13.4", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, + "node_modules/@mantine/store": { + "version": "7.13.4", + "resolved": "https://registry.npmjs.org/@mantine/store/-/store-7.13.4.tgz", + "integrity": "sha512-DUlnXizE7aCjbVg2J3XLLKsOzt2c2qfQl2Xmx9l/BPE4FFZZKUqGDkYaTDbTAmnN3FVZ9xXycL7bAlq9udO8mA==", + "license": "MIT", + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.4.1.tgz", + "integrity": "sha512-HNjmfLQEVRZmHRET336f20H/8kOozUGwk7yajvsonjNxbj2wBTK1WsQuHkD5yYh9RxFGL2EyDHryOihOwUoKDA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.4.1", + "@parcel/watcher-darwin-arm64": "2.4.1", + "@parcel/watcher-darwin-x64": "2.4.1", + "@parcel/watcher-freebsd-x64": "2.4.1", + "@parcel/watcher-linux-arm-glibc": "2.4.1", + "@parcel/watcher-linux-arm64-glibc": "2.4.1", + "@parcel/watcher-linux-arm64-musl": "2.4.1", + "@parcel/watcher-linux-x64-glibc": "2.4.1", + "@parcel/watcher-linux-x64-musl": "2.4.1", + "@parcel/watcher-win32-arm64": "2.4.1", + "@parcel/watcher-win32-ia32": "2.4.1", + "@parcel/watcher-win32-x64": "2.4.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.4.1.tgz", + "integrity": "sha512-LOi/WTbbh3aTn2RYddrO8pnapixAziFl6SMxHM69r3tvdSm94JtCenaKgk1GRg5FJ5wpMCpHeW+7yqPlvZv7kg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.4.1.tgz", + "integrity": "sha512-ln41eihm5YXIY043vBrrHfn94SIBlqOWmoROhsMVTSXGh0QahKGy77tfEywQ7v3NywyxBBkGIfrWRHm0hsKtzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.4.1.tgz", + "integrity": "sha512-yrw81BRLjjtHyDu7J61oPuSoeYWR3lDElcPGJyOvIXmor6DEo7/G2u1o7I38cwlcoBHQFULqF6nesIX3tsEXMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.4.1.tgz", + "integrity": "sha512-TJa3Pex/gX3CWIx/Co8k+ykNdDCLx+TuZj3f3h7eOjgpdKM+Mnix37RYsYU4LHhiYJz3DK5nFCCra81p6g050w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.4.1.tgz", + "integrity": "sha512-4rVYDlsMEYfa537BRXxJ5UF4ddNwnr2/1O4MHM5PjI9cvV2qymvhwZSFgXqbS8YoTk5i/JR0L0JDs69BUn45YA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.4.1.tgz", + "integrity": "sha512-BJ7mH985OADVLpbrzCLgrJ3TOpiZggE9FMblfO65PlOCdG++xJpKUJ0Aol74ZUIYfb8WsRlUdgrZxKkz3zXWYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.4.1.tgz", + "integrity": "sha512-p4Xb7JGq3MLgAfYhslU2SjoV9G0kI0Xry0kuxeG/41UfpjHGOhv7UoUDAz/jb1u2elbhazy4rRBL8PegPJFBhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.4.1.tgz", + "integrity": "sha512-s9O3fByZ/2pyYDPoLM6zt92yu6P4E39a03zvO0qCHOTjxmt3GHRMLuRZEWhWLASTMSrrnVNWdVI/+pUElJBBBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.4.1.tgz", + "integrity": "sha512-L2nZTYR1myLNST0O632g0Dx9LyMNHrn6TOt76sYxWLdff3cB22/GZX2UPtJnaqQPdCRoszoY5rcOj4oMTtp5fQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.4.1.tgz", + "integrity": "sha512-Uq2BPp5GWhrq/lcuItCHoqxjULU1QYEcyjSO5jqqOK8RNFDBQnenMMx4gAl3v8GiWa59E9+uDM7yZ6LxwUIfRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.4.1.tgz", + "integrity": "sha512-maNRit5QQV2kgHFSYwftmPBxiuK5u4DXjbXx7q6eKjq5dsLXZ4FJiVvlcw35QXzk0KrUecJmuVFbj4uV9oYrcw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.4.1.tgz", + "integrity": "sha512-+DvS92F9ezicfswqrvIRM2njcYJbd5mb9CUgtrHCHmvn7pPPa+nMDRu1o1bYYz/l5IB2NVGNJWiH7h1E58IF2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.12.1.tgz", + "integrity": "sha512-RW3rSirfsPdr0uvATijRDU3f55SuZV3m7/ppdTDvGw4IB0cmeZRkFmqTrchxMqWP50Gfg1tpHnjdxUCNo0E2qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.25.6", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.25.6.tgz", + "integrity": "sha512-6MolUvqYNepxgXts9xRONvX6I1yq63B/hct1zyRrLgWM2QjmFhhS2yCZxELwWZfGO1OmzqutDaqsoFqB+LYJGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "^8.11.2", + "@redocly/config": "^0.12.1", + "colorette": "^1.2.0", + "https-proxy-agent": "^7.0.4", + "js-levenshtein": "^1.1.6", + "js-yaml": "^4.1.0", + "lodash.isequal": "^4.5.0", + "minimatch": "^5.0.1", + "node-fetch": "^2.6.1", + "pluralize": "^8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=14.19.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@remix-run/router": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz", + "integrity": "sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", + "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz", + "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz", + "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz", + "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz", + "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz", + "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz", + "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz", + "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz", + "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz", + "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz", + "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz", + "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz", + "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz", + "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz", + "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz", + "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tabler/icons": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.20.0.tgz", + "integrity": "sha512-nXSeUzsCOxX/Of+kdUVQfxL9bG+ck8XCWNf9dGSpE+nhVexRwk/4HiDQDxFDysfT7vfgSut6GXnrZsU5M5dSlA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.20.0.tgz", + "integrity": "sha512-a47oaL48bb5Cx/WUVfg/NZrsWwFExrcDQO8thUZ7S6h/OQYFu7sm4E5pZsmUtGCjikB3lRzjtmMD+C4s7mr9yw==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.20.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.7.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.9.tgz", + "integrity": "sha512-jrTfRC7FM6nChvU7X2KqcrgquofrWLFDeYC1hKfwNWomVvrn7JIksqf344WN2X/y8xrgqBd2dJATZV4GbatBfg==", + "dev": true, + "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.0", - "@vue/shared": "3.5.0" + "undici-types": "~6.19.2" } }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.0.tgz", - "integrity": "sha512-B9DgLtrqok2GLuaFjLlSL15ZG3ZDBiitUH1ecex9guh/ZcA5MCdwuVE6nsfQxktuZY/QY0awJ35/ripIviCQTQ==", - "dependencies": { - "@babel/parser": "^7.25.3", - "@vue/compiler-core": "3.5.0", - "@vue/compiler-dom": "3.5.0", - "@vue/compiler-ssr": "3.5.0", - "@vue/shared": "3.5.0", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.11", - "postcss": "^8.4.44", - "source-map-js": "^1.2.0" - } + "node_modules/@types/prop-types": { + "version": "15.7.13", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", + "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", + "devOptional": true, + "license": "MIT" }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.0.tgz", - "integrity": "sha512-E263QZmA1dqRd7c3u/sWTLRMpQOT0aZ8av/L9SoD/v/BVMZaWFHPUUBswS+bzrfvG2suJF8vSLKx6k6ba5SUdA==", + "node_modules/@types/react": { + "version": "18.3.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", + "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", + "devOptional": true, + "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.0", - "@vue/shared": "3.5.0" + "@types/prop-types": "*", + "csstype": "^3.0.2" } }, - "node_modules/@vue/devtools-api": { - "version": "6.6.3", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.3.tgz", - "integrity": "sha512-0MiMsFma/HqA6g3KLKn+AGpL1kgKhFWszC9U29NfpWK5LE7bjeXxySWJrOJ77hBz+TBrBQ7o4QJqbPbqbs8rJw==" - }, - "node_modules/@vue/eslint-config-typescript": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-13.0.0.tgz", - "integrity": "sha512-MHh9SncG/sfqjVqjcuFLOLD6Ed4dRAis4HNt0dXASeAuLqIAx4YMB1/m2o4pUKK1vCt8fUvYG8KKX2Ot3BVZTg==", + "node_modules/@types/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "^7.1.1", - "@typescript-eslint/parser": "^7.1.1", - "vue-eslint-parser": "^9.3.1" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "peerDependencies": { - "eslint": "^8.56.0", - "eslint-plugin-vue": "^9.0.0", - "typescript": ">=4.7.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "@types/react": "*" } }, - "node_modules/@vue/eslint-config-typescript/node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.3.1.tgz", - "integrity": "sha512-STEDMVQGww5lhCuNXVSQfbfuNII5E08QWkvAw5Qwf+bj2WT+JkG1uc+5/vXA3AOYMDHVOSpL+9rcbEUiHIm2dw==", + "node_modules/@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", + "integrity": "sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "7.3.1", - "@typescript-eslint/type-utils": "7.3.1", - "@typescript-eslint/utils": "7.3.1", - "@typescript-eslint/visitor-keys": "7.3.1", - "debug": "^4.3.4", + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/type-utils": "8.11.0", + "@typescript-eslint/utils": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "graphemer": "^1.4.0", - "ignore": "^5.2.4", + "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.56.0" + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -771,27 +2014,28 @@ } } }, - "node_modules/@vue/eslint-config-typescript/node_modules/@typescript-eslint/parser": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.3.1.tgz", - "integrity": "sha512-Rq49+pq7viTRCH48XAbTA+wdLRrB/3sRq4Lpk0oGDm0VmnjBrAOVXH/Laalmwsv2VpekiEfVFwJYVk6/e8uvQw==", + "node_modules/@typescript-eslint/parser": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.11.0.tgz", + "integrity": "sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "7.3.1", - "@typescript-eslint/types": "7.3.1", - "@typescript-eslint/typescript-estree": "7.3.1", - "@typescript-eslint/visitor-keys": "7.3.1", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "debug": "^4.3.4" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": "^8.57.0 || ^9.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -799,80 +2043,81 @@ } } }, - "node_modules/@vue/eslint-config-typescript/node_modules/@typescript-eslint/scope-manager": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.3.1.tgz", - "integrity": "sha512-fVS6fPxldsKY2nFvyT7IP78UO1/I2huG+AYu5AMjCT9wtl6JFiDnsv4uad4jQ0GTFzcUV5HShVeN96/17bTBag==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.11.0.tgz", + "integrity": "sha512-Uholz7tWhXmA4r6epo+vaeV7yjdKy5QFCERMjs1kMVsLRKIrSdM6o21W2He9ftp5PP6aWOVpD5zvrvuHZC0bMQ==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.3.1", - "@typescript-eslint/visitor-keys": "7.3.1" + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@vue/eslint-config-typescript/node_modules/@typescript-eslint/type-utils": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.3.1.tgz", - "integrity": "sha512-iFhaysxFsMDQlzJn+vr3OrxN8NmdQkHks4WaqD4QBnt5hsq234wcYdyQ9uquzJJIDAj5W4wQne3yEsYA6OmXGw==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.11.0.tgz", + "integrity": "sha512-ItiMfJS6pQU0NIKAaybBKkuVzo6IdnAhPFZA/2Mba/uBjuPQPet/8+zh5GtLHwmuFRShZx+8lhIs7/QeDHflOg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "7.3.1", - "@typescript-eslint/utils": "7.3.1", + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/utils": "8.11.0", "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" + "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependencies": { - "eslint": "^8.56.0" - }, "peerDependenciesMeta": { "typescript": { "optional": true } } }, - "node_modules/@vue/eslint-config-typescript/node_modules/@typescript-eslint/types": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.3.1.tgz", - "integrity": "sha512-2tUf3uWggBDl4S4183nivWQ2HqceOZh1U4hhu4p1tPiIJoRRXrab7Y+Y0p+dozYwZVvLPRI6r5wKe9kToF9FIw==", + "node_modules/@typescript-eslint/types": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.11.0.tgz", + "integrity": "sha512-tn6sNMHf6EBAYMvmPUaKaVeYvhUsrE6x+bXQTxjQRp360h1giATU0WvgeEys1spbvb5R+VpNOZ+XJmjD8wOUHw==", "dev": true, + "license": "MIT", "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@vue/eslint-config-typescript/node_modules/@typescript-eslint/typescript-estree": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.3.1.tgz", - "integrity": "sha512-tLpuqM46LVkduWP7JO7yVoWshpJuJzxDOPYIVWUUZbW+4dBpgGeUdl/fQkhuV0A8eGnphYw3pp8d2EnvPOfxmQ==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.11.0.tgz", + "integrity": "sha512-yHC3s1z1RCHoCz5t06gf7jH24rr3vns08XXhfEqzYpd6Hll3z/3g23JRi0jM8A47UFKNc3u/y5KIMx8Ynbjohg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "7.3.1", - "@typescript-eslint/visitor-keys": "7.3.1", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "debug": "^4.3.4", - "globby": "^11.1.0", + "fast-glob": "^3.3.2", "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -884,1351 +2129,1617 @@ } } }, - "node_modules/@vue/eslint-config-typescript/node_modules/@typescript-eslint/utils": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.3.1.tgz", - "integrity": "sha512-jIERm/6bYQ9HkynYlNZvXpzmXWZGhMbrOvq3jJzOSOlKXsVjrrolzWBjDW6/TvT5Q3WqaN4EkmcfdQwi9tDjBQ==", + "node_modules/@typescript-eslint/utils": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.11.0.tgz", + "integrity": "sha512-CYiX6WZcbXNJV7UNB4PLDIBtSdRmRI/nb0FMyqHPTQD1rMjA0foPLaPUV39C/MxkTd/QKSeX+Gb34PPsDVC35g==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "7.3.1", - "@typescript-eslint/types": "7.3.1", - "@typescript-eslint/typescript-estree": "7.3.1", - "semver": "^7.5.4" + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": "^8.57.0 || ^9.0.0" } }, - "node_modules/@vue/eslint-config-typescript/node_modules/@typescript-eslint/visitor-keys": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.3.1.tgz", - "integrity": "sha512-9RMXwQF8knsZvfv9tdi+4D/j7dMG28X/wMJ8Jj6eOHyHWwDW4ngQJcqEczSsqIKKjFiLFr40Mnr7a5ulDD3vmw==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.11.0.tgz", + "integrity": "sha512-EaewX6lxSjRJnc+99+dqzTeoDZUfyrA52d2/HRrkI830kgovWsmIiTfmr0NZorzqic7ga+1bS60lRBUgR3n/Bw==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.3.1", - "eslint-visitor-keys": "^3.4.1" + "@typescript-eslint/types": "8.11.0", + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@vue/eslint-config-typescript/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "node_modules/@vitejs/plugin-react": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.3.tgz", + "integrity": "sha512-NooDe9GpHGqNns1i8XDERg0Vsg5SSYRhRxxyTGogUdkdNt47jal+fbuYi+Yfq6pzRCKXyoPcWisfxE6RIM3GKA==", "dev": true, + "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "@babel/core": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0" } }, - "node_modules/@vue/eslint-config-typescript/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "node_modules/@vitest/expect": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.3.tgz", + "integrity": "sha512-SNBoPubeCJhZ48agjXruCI57DvxcsivVDdWz+SSsmjTT4QN/DfHk3zB/xKsJqMs26bLZ/pNRLnCf0j679i0uWQ==", "dev": true, + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" + "@vitest/spy": "2.1.3", + "@vitest/utils": "2.1.3", + "chai": "^5.1.1", + "tinyrainbow": "^1.2.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vue/reactivity": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.0.tgz", - "integrity": "sha512-Ew3F5riP3B3ZDGjD3ZKb9uZylTTPSqt8hAf4sGbvbjrjDjrFb3Jm15Tk1/w7WwTE5GbQ2Qhwxx1moc9hr8A/OQ==", + "node_modules/@vitest/mocker": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.3.tgz", + "integrity": "sha512-eSpdY/eJDuOvuTA3ASzCjdithHa+GIF1L4PqtEELl6Qa3XafdMLBpBlZCIUCX2J+Q6sNmjmxtosAG62fK4BlqQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@vue/shared": "3.5.0" + "@vitest/spy": "2.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/spy": "2.1.3", + "msw": "^2.3.5", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@vue/runtime-core": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.0.tgz", - "integrity": "sha512-mQyW0F9FaNRdt8ghkAs+BMG3iQ7LGgWKOpkzUzR5AI5swPNydHGL5hvVTqFaeMzwecF1g0c86H4yFQsSxJhH1w==", + "node_modules/@vitest/pretty-format": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.3.tgz", + "integrity": "sha512-XH1XdtoLZCpqV59KRbPrIhFCOO0hErxrQCMcvnQete3Vibb9UeIOX02uFPfVn3Z9ZXsq78etlfyhnkmIZSzIwQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.0", - "@vue/shared": "3.5.0" + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.0.tgz", - "integrity": "sha512-NQQXjpdXgyYVJ2M56FJ+lSJgZiecgQ2HhxhnQBN95FymXegRNY/N2htI7vOTwpP75pfxhIeYOJ8mE8sW8KAW6A==", + "node_modules/@vitest/runner": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.3.tgz", + "integrity": "sha512-JGzpWqmFJ4fq5ZKHtVO3Xuy1iF2rHGV4d/pdzgkYHm1+gOzNZtqjvyiaDGJytRyMU54qkxpNzCx+PErzJ1/JqQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.0", - "@vue/runtime-core": "3.5.0", - "@vue/shared": "3.5.0", - "csstype": "^3.1.3" + "@vitest/utils": "2.1.3", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vue/server-renderer": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.0.tgz", - "integrity": "sha512-HyDIFUg+l7L4PKrEnJlCYWHUOlm6NxZhmSxIefZ5MTYjkIPfDfkwhX7hqxAQHfgIAE1uLMLQZwuNR/ozI0NhZg==", + "node_modules/@vitest/snapshot": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.3.tgz", + "integrity": "sha512-qWC2mWc7VAXmjAkEKxrScWHWFyCQx/cmiZtuGqMi+WwqQJ2iURsVY4ZfAK6dVo6K2smKRU6l3BPwqEBvhnpQGg==", + "dev": true, + "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.0", - "@vue/shared": "3.5.0" + "@vitest/pretty-format": "2.1.3", + "magic-string": "^0.30.11", + "pathe": "^1.1.2" }, - "peerDependencies": { - "vue": "3.5.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vue/shared": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.0.tgz", - "integrity": "sha512-m9IgiteBpCkFaMNwCOBkFksA7z8QiKc30ooRuoXWUFRDu0mGyNPlFHmbncF0/Kra1RlX8QrmBbRaIxVvikaR0Q==" - }, - "node_modules/@webassemblyjs/ast": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", - "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "node_modules/@vitest/spy": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.3.tgz", + "integrity": "sha512-Nb2UzbcUswzeSP7JksMDaqsI43Sj5+Kry6ry6jQJT4b5gAK+NS9NED6mDb8FlMRCX8m5guaHCDZmqYMMWRy5nQ==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/helper-numbers": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + "tinyspy": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", - "dev": true - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", - "dev": true - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", - "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", - "dev": true - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", - "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "node_modules/@vitest/utils": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.3.tgz", + "integrity": "sha512-xpiVfDSg1RrYT0tX6czgerkpcKFmFOF/gCr30+Mve5V2kewCy4Prn1/NDMSRwaSmT7PRaOF83wu+bEtsY1wrvA==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.11.6", - "@webassemblyjs/helper-api-error": "1.11.6", - "@xtuc/long": "4.2.2" + "@vitest/pretty-format": "2.1.3", + "loupe": "^3.1.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", - "dev": true - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", - "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "node_modules/acorn": { + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.12.1" + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" } }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", - "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "dependencies": { - "@xtuc/ieee754": "^1.2.0" + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", - "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", "dev": true, + "license": "MIT", "dependencies": { - "@xtuc/long": "4.2.2" + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" } }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", - "dev": true - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", - "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-opt": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1", - "@webassemblyjs/wast-printer": "1.12.1" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", - "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" + "license": "MIT", + "engines": { + "node": ">=6" } }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", - "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1" + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" } }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", - "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-api-error": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", - "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@xtuc/long": "4.2.2" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@webpack-cli/configtest": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", - "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, "engines": { - "node": ">=14.15.0" + "node": ">= 0.4" }, - "peerDependencies": { - "webpack": "5.x.x", - "webpack-cli": "5.x.x" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@webpack-cli/info": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", - "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", + "node_modules/array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, "engines": { - "node": ">=14.15.0" + "node": ">= 0.4" }, - "peerDependencies": { - "webpack": "5.x.x", - "webpack-cli": "5.x.x" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@webpack-cli/serve": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", - "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", + "node_modules/array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", "dev": true, - "engines": { - "node": ">=14.15.0" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" }, - "peerDependencies": { - "webpack": "5.x.x", - "webpack-cli": "5.x.x" + "engines": { + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "webpack-dev-server": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true - }, - "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, - "bin": { - "acorn": "bin/acorn" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" }, "engines": { - "node": ">=0.4.0" + "node": ">= 0.4" } }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", "dev": true, - "peerDependencies": { - "acorn": "^8" + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "license": "MIT", + "engines": { + "node": ">=12" } }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, + "license": "MIT", "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" + "possible-typed-array-names": "^1.0.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "engines": { + "node": ">=8" } }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "node_modules/browserslist": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.0.tgz", + "integrity": "sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", "dependencies": { - "ajv": "^8.0.0" + "caniuse-lite": "^1.0.30001663", + "electron-to-chromium": "^1.5.28", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" }, - "peerDependencies": { - "ajv": "^8.0.0" + "bin": { + "browserslist": "cli.js" }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", "dev": true, + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, - "peerDependencies": { - "ajv": "^6.9.1" + "license": "MIT", + "engines": { + "node": ">=6" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 6" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/caniuse-lite": { + "version": "1.0.30001668", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001668.tgz", + "integrity": "sha512-nWLrdxqCdblixUO+27JtGJJE/txpJlyUy5YN1u53wLZkP0emYCo5zgS6QYft7VUYR42LGgi/S5hdLZTrnyIddw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", + "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", "dev": true, + "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=12" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, + "license": "MIT", "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" }, "engines": { - "node": ">= 8" + "node": ">=4" } }, - "node_modules/archiver": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", - "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", "dev": true, - "dependencies": { - "archiver-utils": "^2.1.0", - "async": "^3.2.4", - "buffer-crc32": "^0.2.1", - "readable-stream": "^3.6.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^2.2.0", - "zip-stream": "^4.1.0" - }, + "license": "MIT" + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 16" } }, - "node_modules/archiver-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", - "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", "dev": true, + "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "glob": "^7.1.4", - "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^2.0.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 6" + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/archiver-utils/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" } }, - "node_modules/archiver-utils/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "node_modules/archiver-utils/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, + "license": "MIT", "dependencies": { - "safe-buffer": "~5.1.0" + "color-name": "1.1.3" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", "dev": true, - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "node_modules/async": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", - "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", - "dev": true + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } }, - "node_modules/big.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", - "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, "engines": { - "node": "*" + "node": ">=4" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", "dev": true, + "license": "MIT", "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/boolbase": { + "node_modules/data-view-byte-offset": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true - }, - "node_modules/bootstrap": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", - "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/twbs" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/bootstrap" - } - ], - "peerDependencies": { - "@popperjs/core": "^2.11.8" + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/bootswatch": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/bootswatch/-/bootswatch-5.3.3.tgz", - "integrity": "sha512-cJLhobnZsVCelU7zdH/L7wpcXAyUoTX4/5l2dWQ0JXgaVK80BdTQNU/ImWwoyIGBeyms4iQDAdNtOfPQZf0Atg==" + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dev": true, + "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=6" } }, - "node_modules/browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" - }, - "bin": { - "browserslist": "cli.js" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "license": "MIT", "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, "engines": { - "node": "*" + "node": ">=0.10" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, "engines": { - "node": ">=6" + "node": ">=0.10.0" } }, - "node_modules/camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "dev": true, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", "dependencies": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001597", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001597.tgz", - "integrity": "sha512-7LjJvmQU6Sj7bL0j5b5WY/3n7utXUJvAe1lxhsHDbLmwX9mdL86Yjtr+5SRCyf8qME4M7pU2hswj0FpyBVCv9w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "node_modules/electron-to-chromium": { + "version": "1.5.36", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.36.tgz", + "integrity": "sha512-HYTX8tKge/VNp6FGO+f/uVDmUkq+cEfcxYhKf15Akc4M5yxt5YmorwlAitKWjWhWQnKcDRBAQKXkhqqXMqcrjw==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-abstract": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", + "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.3", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.13", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.15" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", "dev": true, + "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "get-intrinsic": "^1.2.4" }, "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "node": ">= 0.4" } }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, + "license": "MIT", "engines": { - "node": ">= 6" + "node": ">= 0.4" } }, - "node_modules/chrome-trace-event": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", - "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "node_modules/es-iterator-helpers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.1.0.tgz", + "integrity": "sha512-/SurEfycdyssORP/E+bj4sEu1CWw4EmLDsHynHwSXQ7utgbrMRWW195pTrCjFgFCddf/UkYm3oqKPRq5i8bJbw==", "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "globalthis": "^1.0.4", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.7", + "iterator.prototype": "^1.1.3", + "safe-array-concat": "^1.1.2" + }, "engines": { - "node": ">=6.0" + "node": ">= 0.4" } }, - "node_modules/clean-css": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", - "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", "dev": true, + "license": "MIT", "dependencies": { - "source-map": "~0.6.0" + "es-errors": "^1.3.0" }, "engines": { - "node": ">= 10.0" + "node": ">= 0.4" } }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "node_modules/es-set-tostringtag": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" + }, "engines": { - "node": ">=6" + "node": ">= 0.4" } }, - "node_modules/clipboard": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz", - "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==", + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "license": "MIT", "dependencies": { - "good-listener": "^1.2.2", - "select": "^1.1.2", - "tiny-emitter": "^2.0.0" + "hasown": "^2.0.0" } }, - "node_modules/clone-deep": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", "dev": true, + "license": "MIT", "dependencies": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" }, "engines": { - "node": ">=6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, - "dependencies": { - "color-name": "~1.1.4" + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=7.0.0" + "node": ">=12" + }, + "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" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true - }, - "node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, + "license": "MIT", "engines": { - "node": ">= 12" + "node": ">=6" } }, - "node_modules/compress-commons": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", - "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, - "dependencies": { - "buffer-crc32": "^0.2.13", - "crc32-stream": "^4.0.2", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" - }, + "license": "MIT", "engines": { - "node": ">= 10" + "node": ">=0.8.0" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/copy-webpack-plugin": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz", - "integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==", + "node_modules/eslint": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.13.0.tgz", + "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", "dev": true, + "license": "MIT", "dependencies": { - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.1", - "globby": "^14.0.0", - "normalize-path": "^3.0.0", - "schema-utils": "^4.2.0", - "serialize-javascript": "^6.0.2" + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.11.0", + "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.7.0", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.13.0", + "@eslint/plugin-kit": "^0.2.0", + "@humanfs/node": "^0.16.5", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.3.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.1.0", + "eslint-visitor-keys": "^4.1.0", + "espree": "^10.2.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" }, "engines": { - "node": ">= 18.12.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://eslint.org/donate" }, "peerDependencies": { - "webpack": "^5.1.0" + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/copy-webpack-plugin/node_modules/globby": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.1.tgz", - "integrity": "sha512-jOMLD2Z7MAhyG8aJpNOpmziMOP4rPLcc95oQPKXBazW82z+CEgPFBQvEpRUa1KeIMUJo4Wsm+q6uzO/Q/4BksQ==", + "node_modules/eslint-plugin-react": { + "version": "7.37.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.2.tgz", + "integrity": "sha512-EsTAnj9fLVr/GZleBLFbj/sSuXeWmp1eXIN60ceYnZveqEaUCyW4X+Vh4WTdUhCkW4xutXYqTXCUSyqD4rB75w==", "dev": true, + "license": "MIT", "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.2", - "ignore": "^5.2.4", - "path-type": "^5.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.1.0" + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.2", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.1.0", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.8", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.0", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.11", + "string.prototype.repeat": "^1.0.0" }, "engines": { - "node": ">=18" + "node": ">=4" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, - "node_modules/copy-webpack-plugin/node_modules/path-type": { + "node_modules/eslint-plugin-react-hooks": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", - "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0.tgz", + "integrity": "sha512-hIOwI+5hYGpJEc4uPRmz2ulCjAGD/N13Lukkh8cLV0i2IRk/bdZDYjgLVHj+U9Z704kLIdIO6iueGvxNur0sgw==", "dev": true, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=10" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.14.tgz", + "integrity": "sha512-aXvzCTK7ZBv1e7fahFuR3Z/fyQQSIQ711yPgYRj+Oj64tyTgO4iQIDmYXDBqvSWQ/FA4OSCsXOStlF+noU0/NA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=7" + } + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, - "node_modules/copy-webpack-plugin/node_modules/slash": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", - "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "node_modules/eslint-scope": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz", + "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==", "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, "engines": { - "node": ">=14.16" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "bin": { - "crc32": "bin/crc32.njs" + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" }, "engines": { - "node": ">=0.8" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/crc32-stream": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", - "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { - "crc-32": "^1.2.0", - "readable-stream": "^3.4.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">= 10" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "node_modules/eslint/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "color-name": "~1.1.4" }, "engines": { - "node": ">= 8" + "node": ">=7.0.0" } }, - "node_modules/css-loader": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", - "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", + "node_modules/eslint/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, - "dependencies": { - "icss-utils": "^5.1.0", - "postcss": "^8.4.33", - "postcss-modules-extract-imports": "^3.1.0", - "postcss-modules-local-by-default": "^4.0.5", - "postcss-modules-scope": "^3.2.0", - "postcss-modules-values": "^4.0.0", - "postcss-value-parser": "^4.2.0", - "semver": "^7.5.4" - }, + "license": "MIT" + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 18.12.0" + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "webpack": "^5.27.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/css-select": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", - "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", "dev": true, - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.0.1", - "domhandler": "^4.3.1", - "domutils": "^2.8.0", - "nth-check": "^2.0.1" + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/fb55" + "url": "https://opencollective.com/eslint" } }, - "node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "node_modules/eslint/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" + "node": ">=8" } }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "bin": { - "cssesc": "bin/cssesc" + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=4" + "node": "*" } }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=8" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/del": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz", - "integrity": "sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==", + "node_modules/espree": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", + "integrity": "sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "globby": "^11.0.1", - "graceful-fs": "^4.2.4", - "is-glob": "^4.0.1", - "is-path-cwd": "^2.2.0", - "is-path-inside": "^3.0.2", - "p-map": "^4.0.0", - "rimraf": "^3.0.2", - "slash": "^3.0.0" + "acorn": "^8.12.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.1.0" }, "engines": { - "node": ">=10" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, - "node_modules/delegate": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", - "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==" + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "path-type": "^4.0.0" + "estraverse": "^5.1.0" }, "engines": { - "node": ">=8" + "node": ">=0.10" } }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "esutils": "^2.0.2" + "estraverse": "^5.2.0" }, "engines": { - "node": ">=6.0.0" + "node": ">=4.0" } }, - "node_modules/dom-converter": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", - "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "dependencies": { - "utila": "~0.4" + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" } }, - "node_modules/dom-serializer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", - "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, + "license": "MIT", "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.0", - "entities": "^2.0.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + "@types/estree": "^1.0.0" } }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ] + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } }, - "node_modules/domhandler": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", - "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "dev": true, + "license": "MIT", "dependencies": { - "domelementtype": "^2.2.0" + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" }, "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" + "node": ">=8.6.0" } }, - "node_modules/domutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, + "license": "ISC", "dependencies": { - "dom-serializer": "^1.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0" + "is-glob": "^4.0.1" }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" + "engines": { + "node": ">= 6" } }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.4.708", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.708.tgz", - "integrity": "sha512-iWgEEvREL4GTXXHKohhh33+6Y8XkPI5eHihDmm8zUk5Zo7HICEW+wI/j5kJ2tbuNUCXJ/sNXa03ajW635DiJXA==", - "dev": true + "license": "MIT" }, - "node_modules/emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "engines": { - "node": ">= 4" - } + "license": "MIT" }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", "dev": true, + "license": "ISC", "dependencies": { - "once": "^1.4.0" + "reusify": "^1.0.4" } }, - "node_modules/enhanced-resolve": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", - "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, + "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "flat-cache": "^4.0.0" }, "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", - "dev": true, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "node": ">=16.0.0" } }, - "node_modules/envinfo": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.11.1.tgz", - "integrity": "sha512-8PiZgZNIB4q/Lw4AhOvAfB/ityHAd2bli3lESSWmWSzSsl5dKpy5N1d1Rfkd2teq/g9xN90lc6o98DOjMeYHpg==", + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, - "bin": { - "envinfo": "dist/cli.js" + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" }, "engines": { - "node": ">=4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", - "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==", - "dev": true - }, - "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", - "dev": true, - "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, "engines": { "node": ">=10" }, @@ -2236,936 +3747,823 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" + "flatted": "^3.2.9", + "keyv": "^4.5.4" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-vue": { - "version": "9.28.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.28.0.tgz", - "integrity": "sha512-ShrihdjIhOTxs+MfWun6oJWuk+g/LAhN+CiuOl/jjkG3l0F2AuK5NMTaWqyvBgkFtpYmyks6P4603mLmhNJW8g==", + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "globals": "^13.24.0", - "natural-compare": "^1.4.0", - "nth-check": "^2.1.1", - "postcss-selector-parser": "^6.0.15", - "semver": "^7.6.3", - "vue-eslint-parser": "^9.4.3", - "xml-name-validator": "^4.0.0" + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" }, "engines": { - "node": "^14.17.0 || >=16.0.0" + "node": ">= 0.4" }, - "peerDependencies": { - "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=6.9.0" } }, - "node_modules/eslint/node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", "dev": true, + "license": "MIT", "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">= 0.4" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", "engines": { - "node": ">=4.0" + "node": ">=6" } }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "node_modules/get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", "dev": true, + "license": "MIT", "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">= 0.4" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, + "license": "ISC", "dependencies": { - "estraverse": "^5.1.0" + "is-glob": "^4.0.3" }, "engines": { - "node": ">=0.10" + "node": ">=10.13.0" } }, - "node_modules/esquery/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/globals": { + "version": "15.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.11.0.tgz", + "integrity": "sha512-yeyNSjdbyVaWurlwCpcA6XNBrHTMIeDdj0/hnvX/OLJ9ekOXYbLsLinH/MucQyGvNnXhidTdNhTtJaffL2sMfw==", "dev": true, + "license": "MIT", "engines": { - "node": ">=4.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, + "license": "MIT", "dependencies": { - "estraverse": "^5.2.0" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { - "node": ">=4.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esrecurse/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", "dev": true, - "engines": { - "node": ">=4.0" + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + "license": "MIT" }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", "dev": true, - "engines": { - "node": ">=0.10.0" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, + "license": "MIT", "engines": { - "node": ">=0.8.x" + "node": ">=4" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, + "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" + "es-define-property": "^1.0.0" }, - "engines": { - "node": ">=8.6.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, + "license": "MIT", "engines": { - "node": ">= 6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fastest-levenshtein": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", - "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", "dev": true, + "license": "MIT", "engines": { - "node": ">= 4.9.1" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, + "license": "MIT", "dependencies": { - "reusify": "^1.0.4" + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, + "license": "MIT", "dependencies": { - "flat-cache": "^3.0.4" + "function-bind": "^1.1.2" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">= 0.4" } }, - "node_modules/filemanager-webpack-plugin": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/filemanager-webpack-plugin/-/filemanager-webpack-plugin-8.0.0.tgz", - "integrity": "sha512-TYwu62wgq2O2c3K80Sfj8vEys/tP5wdgYoySHgUwWoc2hPbQY3Mq3ahcAW634JvHCTcSV7IAfRxMI3wTXRt2Vw==", + "node_modules/highlight.js": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.10.0.tgz", + "integrity": "sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", "dev": true, + "license": "MIT", "dependencies": { - "@types/archiver": "^5.3.1", - "archiver": "^5.3.1", - "del": "^6.1.1", - "fast-glob": "^3.2.12", - "fs-extra": "^10.1.0", - "is-glob": "^4.0.3", - "normalize-path": "^3.0.0", - "schema-utils": "^4.0.0" + "agent-base": "^7.0.2", + "debug": "4" }, "engines": { - "node": "^14.13.1 || >=16.0.0" - }, - "peerDependencies": { - "webpack": "^5.0.0" + "node": ">= 14" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 4" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/immutable": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", "dev": true, + "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, "engines": { - "node": ">=10" + "node": ">=6" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, - "bin": { - "flat": "cli.js" + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/index-to-position": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-0.1.2.tgz", + "integrity": "sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "node_modules/internal-slot": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", "dev": true, + "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "es-errors": "^1.3.0", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">= 0.4" } }, - "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true - }, - "node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" + "loose-envify": "^1.0.0" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/is-array-buffer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "node_modules/is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", "dev": true, + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "has-tostringtag": "^1.0.0" }, "engines": { - "node": "*" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", "dev": true, + "license": "MIT", "dependencies": { - "is-glob": "^4.0.3" + "has-bigints": "^1.0.1" }, - "engines": { - "node": ">=10.13.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", "dev": true, + "license": "MIT", "dependencies": { - "type-fest": "^0.20.2" + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globals/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globalyzer": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", - "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", - "dev": true - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", "dev": true, + "license": "MIT", "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" + "hasown": "^2.0.2" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globrex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", - "dev": true - }, - "node_modules/good-listener": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", - "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==", - "dependencies": { - "delegate": "^3.1.2" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", "dev": true, + "license": "MIT", + "dependencies": { + "is-typed-array": "^1.1.13" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hash-sum": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-2.0.0.tgz", - "integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==", - "dev": true - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", "dev": true, + "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "has-tostringtag": "^1.0.0" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, - "bin": { - "he": "bin/he" - } - }, - "node_modules/highlight.js": { - "version": "11.9.0", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.9.0.tgz", - "integrity": "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==", - "peer": true, + "license": "MIT", "engines": { - "node": ">=12.0.0" + "node": ">=0.10.0" } }, - "node_modules/html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "node_modules/is-finalizationregistry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", + "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", "dev": true, + "license": "MIT", "dependencies": { - "camel-case": "^4.1.2", - "clean-css": "^5.2.2", - "commander": "^8.3.0", - "he": "^1.2.0", - "param-case": "^3.0.4", - "relateurl": "^0.2.7", - "terser": "^5.10.0" - }, - "bin": { - "html-minifier-terser": "cli.js" + "call-bind": "^1.0.2" }, - "engines": { - "node": ">=12" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/html-webpack-plugin": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.0.tgz", - "integrity": "sha512-iwaY4wzbe48AfKLZ/Cc8k0L+FKG6oSNRaZ8x5A/T/IVDGyXcbHncM9TdDa93wn0FsSm82FhTKW7f3vS61thXAw==", + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", "dev": true, + "license": "MIT", "dependencies": { - "@types/html-minifier-terser": "^6.0.0", - "html-minifier-terser": "^6.0.2", - "lodash": "^4.17.21", - "pretty-error": "^4.0.0", - "tapable": "^2.0.0" + "has-tostringtag": "^1.0.0" }, "engines": { - "node": ">=10.13.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/html-webpack-plugin" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "webpack": "^5.20.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/htmlparser2": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", - "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], + "license": "MIT", "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.0.0", - "domutils": "^2.5.2", - "entities": "^2.0.0" + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" } }, - "node_modules/icss-utils": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", - "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "dev": true, + "license": "MIT", "engines": { - "node": "^10 || ^12 || >= 14" + "node": ">= 0.4" }, - "peerDependencies": { - "postcss": "^8.1.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, + "license": "MIT", "engines": { - "node": ">= 4" + "node": ">=0.12.0" } }, - "node_modules/immutable": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz", - "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==", - "dev": true - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", "dev": true, + "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "has-tostringtag": "^1.0.0" }, "engines": { - "node": ">=6" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", "dev": true, + "license": "MIT", "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/interpret": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", - "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, + "license": "MIT", "engines": { - "node": ">=10.13.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "node_modules/is-shared-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", "dev": true, + "license": "MIT", "dependencies": { - "binary-extensions": "^2.0.0" + "call-bind": "^1.0.7" }, "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", "dev": true, + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", "dev": true, + "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "has-symbols": "^1.0.2" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.14" + }, "engines": { - "node": ">=0.12.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-path-cwd": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", - "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "dev": true, + "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", "dev": true, - "engines": { - "node": ">=8" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "node_modules/is-weakset": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz", + "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==", "dev": true, + "license": "MIT", "dependencies": { - "isobject": "^3.0.1" + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/izitoast": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/izitoast/-/izitoast-1.4.0.tgz", - "integrity": "sha512-Oc1X2wiQtPp39i5VpIjf3GJf5sfCtHKXZ5szx7RareyEeFLUlcEW0FSfBni28+Ul6KNKZRKzhVuWzSP4Xngh0w==" + "license": "ISC" }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "node_modules/iterator.prototype": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.3.tgz", + "integrity": "sha512-FW5iMbeQ6rBGm/oKgzq2aW4KvAGpxPzYES8N4g4xNXUKpL1mclMvOe+76AcLDTvD+Ze+sOpVhgdAQEKF4L9iGQ==", "dev": true, + "license": "MIT", "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "define-properties": "^1.2.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "reflect.getprototypeof": "^1.0.4", + "set-function-name": "^2.0.1" }, "engines": { - "node": ">= 10.13.0" + "node": ">= 0.4" } }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, + "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "node": ">=0.10.0" } }, - "node_modules/js-base64": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz", - "integrity": "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==" + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -3173,71 +4571,67 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "node_modules/json-minimizer-webpack-plugin": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/json-minimizer-webpack-plugin/-/json-minimizer-webpack-plugin-5.0.0.tgz", - "integrity": "sha512-GT/SZolN2p405EMGjMTBvAVi2+y035p1tSOuLpWbp5QTMl080OHx4DEGXfUH6vbnGw5Z/QKfBe+KpP9Dj0qLmA==", + "node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", "dev": true, - "dependencies": { - "schema-utils": "^4.2.0" + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" }, "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" + "node": ">=6" } }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, - "dependencies": { - "minimist": "^1.2.0" - }, + "license": "MIT", "bin": { "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" } }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, + "license": "MIT", "dependencies": { - "universalify": "^2.0.0" + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "engines": { + "node": ">=4.0" } }, "node_modules/keyv": { @@ -3245,66 +4639,17 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, + "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", - "dev": true, - "dependencies": { - "readable-stream": "^2.0.5" - }, - "engines": { - "node": ">= 0.6.3" - } - }, - "node_modules/lazystream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/lazystream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "node_modules/lazystream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -3313,34 +4658,12 @@ "node": ">= 0.8.0" } }, - "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", - "dev": true, - "engines": { - "node": ">=6.11.5" - } - }, - "node_modules/loader-utils": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz", - "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==", - "dev": true, - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^1.0.1" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, + "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -3351,303 +4674,334 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "dev": true - }, - "node_modules/lodash.difference": { + "node_modules/lodash.isequal": { "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", - "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", - "dev": true - }, - "node_modules/lodash.flatten": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", - "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", - "dev": true - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "dev": true, + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } }, - "node_modules/lodash.union": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", - "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", - "dev": true + "node_modules/loupe": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "dev": true, + "license": "MIT" }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, + "license": "ISC", "dependencies": { - "tslib": "^2.0.3" + "yallist": "^3.0.2" } }, "node_modules/magic-string": { - "version": "0.30.11", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", - "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", + "version": "0.30.12", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", + "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==", + "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 8" } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { "node": ">=8.6" } }, - "node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, - "bin": { - "mime": "cli.js" + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=10.0.0" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "engines": { - "node": ">= 0.6" - } + "license": "MIT" }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "dev": true, - "dependencies": { - "mime-db": "1.52.0" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">= 0.6" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/mini-css-extract-plugin": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.1.tgz", - "integrity": "sha512-+Vyi+GCCOHnrJ2VPS+6aPoXN2k2jgUzDRhTFLjjTBn23qyXJXkjUWQgTL+mXpF5/A8ixLdCc6kWsoeOjKGejKQ==", + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dev": true, + "license": "MIT", "dependencies": { - "schema-utils": "^4.0.0", - "tapable": "^2.2.1" + "whatwg-url": "^5.0.0" }, "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "node": "4.x || >=6.0.0" }, "peerDependencies": { - "webpack": "^5.0.0" + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } } }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/node-releases": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", "engines": { - "node": "*" + "node": ">=0.10.0" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "node_modules/object-inspect": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", "engines": { - "node": "*" + "node": ">= 0.4" } }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node_modules/natural-compare-lite": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", - "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", - "dev": true - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true - }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "node_modules/object.entries": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", + "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", "dev": true, + "license": "MIT", "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", - "dev": true - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "node_modules/object.values": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", + "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", "dev": true, + "license": "MIT", "dependencies": { - "boolbase": "^1.0.0" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, + "node_modules/openapi-fetch": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.12.2.tgz", + "integrity": "sha512-ctMQ4LkkSWfIDUMuf1SYuPMsQ7ePcWAkYaMPW1lCDdk4WlV3Vulq1zoyGrwnFVvrBs5t7OOqNF+EKa8SAaovEA==", + "license": "MIT", "dependencies": { - "wrappy": "1" + "openapi-typescript-helpers": "^0.0.13" } }, "node_modules/openapi-typescript": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-5.4.2.tgz", - "integrity": "sha512-tHeRv39Yh7brqJpbUntdjtUaXrTHmC4saoyTLU/0J2I8LEFQYDXRLgnmWTMiMOB2GXugJiqHa5n9sAyd6BRqiA==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.4.1.tgz", + "integrity": "sha512-HrRoWveViADezHCNgQqZmPKmQ74q7nuH/yg9ursFucZaYQNUqsX38fE/V2sKBHVM+pws4tAHpuh/ext2UJ/AoQ==", "dev": true, + "license": "MIT", "dependencies": { - "js-yaml": "^4.1.0", - "mime": "^3.0.0", - "prettier": "^2.6.2", - "tiny-glob": "^0.2.9", - "undici": "^5.4.0", - "yargs-parser": "^21.0.1" + "@redocly/openapi-core": "^1.25.3", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.1.0", + "supports-color": "^9.4.0", + "yargs-parser": "^21.1.1" }, "bin": { "openapi-typescript": "bin/cli.js" }, - "engines": { - "node": ">= 14.0.0" + "peerDependencies": { + "typescript": "^5.x" } }, - "node_modules/openapi-typescript-fetch": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/openapi-typescript-fetch/-/openapi-typescript-fetch-2.0.0.tgz", - "integrity": "sha512-9YkzVKIx9RVIET0lFjJOuf15VjI9AUsoNByBk5WYM66xVlAKDNy8anj08Ci3zZA+HgTwdDamYz5FCVYt2VoHkA==", + "node_modules/openapi-typescript-helpers": { + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.13.tgz", + "integrity": "sha512-z44WK2e7ygW3aUtAtiurfEACohf/Qt9g6BsejmIYgEoY4REHeRzgFJmO3ium0libsuzPc145I+8lE9aiiZrQvQ==", + "license": "MIT" + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.4.0.tgz", + "integrity": "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 12.0.0", - "npm": ">= 7.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, + "license": "MIT", "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { "node": ">= 0.8.0" @@ -3658,6 +5012,7 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, + "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -3673,6 +5028,7 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, + "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -3683,45 +5039,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/param-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", - "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", - "dev": true, - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, + "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -3729,14 +5052,22 @@ "node": ">=6" } }, - "node_modules/pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "node_modules/parse-json": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.1.0.tgz", + "integrity": "sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA==", "dev": true, + "license": "MIT", "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" + "@babel/code-frame": "^7.22.13", + "index-to-position": "^0.1.2", + "type-fest": "^4.7.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/path-exists": { @@ -3744,24 +5075,17 @@ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3770,27 +5094,39 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "dev": true, + "license": "MIT" }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 14.16" } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "dev": true, + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.6" }, @@ -3798,74 +5134,31 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, + "license": "MIT", "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4" } }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", "dev": true, - "dependencies": { - "p-limit": "^2.2.0" - }, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.4" } }, "node_modules/postcss": { - "version": "8.4.44", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.44.tgz", - "integrity": "sha512-Aweb9unOEpQ3ezu4Q00DPvvM2ZTUitJdNKeP/+uQgr1IBIqu574IaZoURId7BKtWMREwzKa9OgzPzezWGPWFQw==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -3880,79 +5173,105 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-modules-extract-imports": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", - "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, "engines": { - "node": "^10 || ^12 || >= 14" + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" }, "peerDependencies": { - "postcss": "^8.1.0" + "postcss": "^8.4.21" } }, - "node_modules/postcss-modules-local-by-default": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.5.tgz", - "integrity": "sha512-6MieY7sIfTK0hYfafw1OMEG+2bg8Q1ocHCpoWLqOKj3JXlKu4G7btkmM/B7lFubYkYWmRSPLZi5chid63ZaZYw==", + "node_modules/postcss-mixins": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/postcss-mixins/-/postcss-mixins-9.0.4.tgz", + "integrity": "sha512-XVq5jwQJDRu5M1XGkdpgASqLk37OqkH4JCFDXl/Dn7janOJjCTEKL+36cnRVy7bMtoBzALfO7bV7nTIsFnUWLA==", "dev": true, + "license": "MIT", "dependencies": { - "icss-utils": "^5.0.0", - "postcss-selector-parser": "^6.0.2", - "postcss-value-parser": "^4.1.0" + "fast-glob": "^3.2.11", + "postcss-js": "^4.0.0", + "postcss-simple-vars": "^7.0.0", + "sugarss": "^4.0.1" }, "engines": { - "node": "^10 || ^12 || >= 14" + "node": ">=14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" }, "peerDependencies": { - "postcss": "^8.1.0" + "postcss": "^8.2.14" } }, - "node_modules/postcss-modules-scope": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.0.tgz", - "integrity": "sha512-oq+g1ssrsZOsx9M96c5w8laRmvEu9C3adDSjI8oTcbfkrTE8hx/zfyobUoWIxaKPO8bt6S62kxpw5GqypEw1QQ==", + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.4" + "postcss-selector-parser": "^6.1.1" }, "engines": { - "node": "^10 || ^12 || >= 14" + "node": ">=12.0" }, "peerDependencies": { - "postcss": "^8.1.0" + "postcss": "^8.2.14" } }, - "node_modules/postcss-modules-values": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", - "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "node_modules/postcss-preset-mantine": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/postcss-preset-mantine/-/postcss-preset-mantine-1.17.0.tgz", + "integrity": "sha512-ji1PMDBUf2Vsx/HE5faMSs1+ff6qE6YRulTr4Ja+6HD3gop8rSMTCYdpN7KrdsEg079kfBKkO/PaKhG9uR0zwQ==", "dev": true, + "license": "MIT", "dependencies": { - "icss-utils": "^5.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" + "postcss-mixins": "^9.0.4", + "postcss-nested": "^6.0.1" }, "peerDependencies": { - "postcss": "^8.1.0" + "postcss": ">=8.0.0" } }, "node_modules/postcss-selector-parser": { - "version": "6.0.16", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", - "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, + "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3961,57 +5280,66 @@ "node": ">=4" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true + "node_modules/postcss-simple-vars": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-simple-vars/-/postcss-simple-vars-7.0.1.tgz", + "integrity": "sha512-5GLLXaS8qmzHMOjVxqkk1TZPf1jMqesiI7qLhnlyERalG0sMbHIbJqrcnrpmZdKCLglHnRHoEBB61RtGTsj++A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.1" + } }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8.0" } }, "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true, + "license": "MIT", "bin": { - "prettier": "bin-prettier.js" + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=10.13.0" + "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/pretty-error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", - "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", - "dev": true, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", "dependencies": { - "lodash": "^4.17.20", - "renderkid": "^3.0.0" + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -4034,110 +5362,256 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", "dependencies": { - "safe-buffer": "^5.1.0" + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-number-format": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.2.tgz", + "integrity": "sha512-cg//jVdS49PYDgmcYoBnMMHl4XNTMuV723ZnHD2aXYtWWWqbVF3hjQ8iB+UZEuXapLbeA8P8H+1o6ZB1lcw3vg==", + "license": "MIT", + "peerDependencies": { + "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz", + "integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==", + "license": "MIT", "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "react-remove-scroll-bar": "^2.3.6", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" }, "engines": { - "node": ">= 6" + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/readdir-glob": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", - "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", - "dev": true, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz", + "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==", + "license": "MIT", "dependencies": { - "minimatch": "^5.1.0" + "react-style-singleton": "^2.2.1", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, + "node_modules/react-router": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz", + "integrity": "sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==", + "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "@remix-run/router": "1.20.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" } }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, + "node_modules/react-router-dom": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.27.0.tgz", + "integrity": "sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==", + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.1" + "@remix-run/router": "1.20.0", + "react-router": "6.27.0" }, "engines": { - "node": ">=10" + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, + "node_modules/react-style-singleton": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", + "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", + "license": "MIT", "dependencies": { - "picomatch": "^2.2.1" + "get-nonce": "^1.0.0", + "invariant": "^2.2.4", + "tslib": "^2.0.0" }, "engines": { - "node": ">=8.10.0" + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/rechoir": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", - "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", - "dev": true, + "node_modules/react-textarea-autosize": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.3.tgz", + "integrity": "sha512-XT1024o2pqCuZSuBt9FwHlaDeNtVrtCXu0Rnz88t1jUGheCLa3PhjE1GH8Ctm2axEtvdCl5SUHYschyQ0L5QHQ==", + "license": "MIT", "dependencies": { - "resolve": "^1.20.0" + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" }, "engines": { - "node": ">= 10.13.0" + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, - "node_modules/reconnecting-websocket": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz", - "integrity": "sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==" + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } }, - "node_modules/relateurl": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", - "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "node_modules/reflect.getprototypeof": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", + "integrity": "sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==", "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.1", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "globalthis": "^1.0.3", + "which-builtin-type": "^1.1.3" + }, "engines": { - "node": ">= 0.10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/renderkid": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", - "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", + "integrity": "sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==", "dev": true, + "license": "MIT", "dependencies": { - "css-select": "^4.1.3", - "dom-converter": "^0.2.0", - "htmlparser2": "^6.1.0", - "lodash": "^4.17.21", - "strip-ansi": "^6.0.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/require-from-string": { @@ -4145,15 +5619,17 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", "dev": true, + "license": "MIT", "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -4162,29 +5638,8 @@ "bin": { "resolve": "bin/resolve" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-cwd/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/resolve-from": { @@ -4192,6 +5647,7 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -4201,24 +5657,46 @@ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "dev": true, + "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" } }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "node_modules/rollup": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", + "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==", "dev": true, + "license": "MIT", "dependencies": { - "glob": "^7.1.3" + "@types/estree": "1.0.6" }, "bin": { - "rimraf": "bin.js" + "rollup": "dist/bin/rollup" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.24.0", + "@rollup/rollup-android-arm64": "4.24.0", + "@rollup/rollup-darwin-arm64": "4.24.0", + "@rollup/rollup-darwin-x64": "4.24.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.24.0", + "@rollup/rollup-linux-arm-musleabihf": "4.24.0", + "@rollup/rollup-linux-arm64-gnu": "4.24.0", + "@rollup/rollup-linux-arm64-musl": "4.24.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0", + "@rollup/rollup-linux-riscv64-gnu": "4.24.0", + "@rollup/rollup-linux-s390x-gnu": "4.24.0", + "@rollup/rollup-linux-x64-gnu": "4.24.0", + "@rollup/rollup-linux-x64-musl": "4.24.0", + "@rollup/rollup-win32-arm64-msvc": "4.24.0", + "@rollup/rollup-win32-ia32-msvc": "4.24.0", + "@rollup/rollup-win32-x64-msvc": "4.24.0", + "fsevents": "~2.3.2" } }, "node_modules/run-parallel": { @@ -4240,150 +5718,83 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/sass": { - "version": "1.78.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.78.0.tgz", - "integrity": "sha512-AaIqGSrjo5lA2Yg7RvFZrlXDBCp3nV4XP73GrLGvdRWWwk+8H3l0SDvq/5bA4eF+0RFPLuWUk3E+P1U/YqnpsQ==", - "dev": true, - "dependencies": { - "chokidar": ">=3.0.0 <4.0.0", - "immutable": "^4.0.0", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-loader": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.1.tgz", - "integrity": "sha512-xACl1ToTsKnL9Ce5yYpRxrLj9QUDCnwZNhzpC7tKiFyA8zXsd3Ap+HGVnbCgkdQcm43E+i6oKAWBsvGA6ZoiMw==", + "node_modules/safe-array-concat": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", + "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", "dev": true, + "license": "MIT", "dependencies": { - "neo-async": "^2.6.2" + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" }, "engines": { - "node": ">= 18.12.0" + "node": ">=0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", - "sass": "^1.3.0", - "sass-embedded": "*", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "node-sass": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "webpack": { - "optional": true - } + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/schema-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", - "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "node_modules/safe-regex-test": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", "dev": true, + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-regex": "^1.1.4" }, "engines": { - "node": ">= 12.13.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/schema-utils/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "node_modules/sass": { + "version": "1.79.5", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.79.5.tgz", + "integrity": "sha512-W1h5kp6bdhqFh2tk3DsI771MoEJjvrSY/2ihJRJS4pjIyfJCw0nTsxqhnrUzaLMOJjFchj8rOvraI/YUVjtx5g==", "dev": true, + "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "@parcel/watcher": "^2.4.1", + "chokidar": "^4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" } }, - "node_modules/schema-utils/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" + "loose-envify": "^1.1.0" } }, - "node_modules/schema-utils/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "node_modules/select": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", - "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==" - }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -4391,25 +5802,38 @@ "node": ">=10" } }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, + "license": "MIT", "dependencies": { - "randombytes": "^2.1.0" + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/shallow-clone": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, + "license": "MIT", "dependencies": { - "kind-of": "^6.0.2" + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" }, "engines": { - "node": ">=8" + "node": ">= 0.4" } }, "node_modules/shebang-command": { @@ -4417,6 +5841,7 @@ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, + "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -4429,96 +5854,111 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, - "engines": { - "node": ">=0.10.0" - } + "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } + "license": "MIT" }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "node_modules/std-env": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", + "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", "dev": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } + "license": "MIT" }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/string.prototype.matchall": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz", + "integrity": "sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.7", + "regexp.prototype.flags": "^1.5.2", + "set-function-name": "^2.0.2", + "side-channel": "^1.0.6" }, "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, + "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "node_modules/string.prototype.trim": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", + "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-object-atoms": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -4526,132 +5966,158 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "node_modules/string.prototype.trimend": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", + "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", "dev": true, - "engines": { - "node": ">=6" + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dev": true, + "license": "MIT", "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/terser": { - "version": "5.29.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.29.2.tgz", - "integrity": "sha512-ZiGkhUBIM+7LwkNjXYJq8svgkd+QK3UUr0wJqY4MieaezBSAIPgbSPZyIx0idM6XWK5CMzSWa8MJIzmRcB8Caw==", + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, - "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" - }, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/terser-webpack-plugin": { - "version": "5.3.10", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", - "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "node_modules/sugarss": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/sugarss/-/sugarss-4.0.1.tgz", + "integrity": "sha512-WCjS5NfuVJjkQzK10s8WOBY+hhDxxNt/N6ZaGwxFZ+wN3/lKKFSaaKUNecULcTTvE4urLcKaZFQD8vO0mOZujw==", "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.20", - "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.1", - "terser": "^5.26.0" - }, + "license": "MIT", "engines": { - "node": ">= 10.13.0" + "node": ">=12.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://opencollective.com/postcss/" }, "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } + "postcss": "^8.3.3" } }, - "node_modules/terser-webpack-plugin/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" + "has-flag": "^3.0.0" }, "engines": { - "node": ">= 10.13.0" + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true + "dev": true, + "license": "MIT" }, - "node_modules/tiny-emitter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", - "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" }, - "node_modules/tiny-glob": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", - "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", + "node_modules/tinyexec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", + "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", "dev": true, - "dependencies": { - "globalyzer": "0.1.0", - "globrex": "^0.1.2" + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.1.tgz", + "integrity": "sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" } }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -4661,6 +6127,7 @@ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -4668,79 +6135,38 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", - "dev": true, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "typescript": ">=4.2.0" - } - }, - "node_modules/ts-loader": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.1.tgz", - "integrity": "sha512-rNH3sK9kGZcH9dYzC7CewQm4NtxJTjSEVRJ2DyBZR7f8/wcta+iV44UPCXc5+nzDzivKtlzV6c9P4e+oFhDLYg==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "enhanced-resolve": "^5.0.0", - "micromatch": "^4.0.0", - "semver": "^7.3.4", - "source-map": "^0.7.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "typescript": "*", - "webpack": "^5.0.0" - } - }, - "node_modules/ts-loader/node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true - }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "dependencies": { - "tslib": "^1.8.1" - }, + "dev": true, + "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=16" }, "peerDependencies": { - "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + "typescript": ">=4.2.0" } }, - "node_modules/tsutils/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true + "node_modules/tslib": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", + "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" }, @@ -4749,10 +6175,10 @@ } }, "node_modules/type-fest": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.0.tgz", - "integrity": "sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==", - "dev": true, + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.1.tgz", + "integrity": "sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==", + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" }, @@ -4760,62 +6186,124 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "devOptional": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "node_modules/typed-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" }, "engines": { - "node": ">=14.17" + "node": ">= 0.4" } }, - "node_modules/undici": { - "version": "5.28.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", - "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "node_modules/typed-array-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", "dev": true, + "license": "MIT", "dependencies": { - "@fastify/busboy": "^2.0.0" + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" }, "engines": { - "node": ">=14.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "node_modules/typed-array-byte-offset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/unicorn-magic": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "node_modules/typed-array-length": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", + "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, "engines": { - "node": ">=18" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, "engines": { - "node": ">= 10.0.0" + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", "dev": true, "funding": [ { @@ -4831,9 +6319,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.2.0", + "picocolors": "^1.1.0" }, "bin": { "update-browserslist-db": "cli.js" @@ -4847,352 +6336,435 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true - }, - "node_modules/utila": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", - "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", - "dev": true + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" }, - "node_modules/vue": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.0.tgz", - "integrity": "sha512-1t70favYoFijwfWJ7g81aTd32obGaAnKYE9FNyMgnEzn3F4YncRi/kqAHHKloG0VXTD8vBYMhbgLKCA+Sk6QDw==", + "node_modules/use-callback-ref": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz", + "integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==", + "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.0", - "@vue/compiler-sfc": "3.5.0", - "@vue/runtime-dom": "3.5.0", - "@vue/server-renderer": "3.5.0", - "@vue/shared": "3.5.0" + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" }, "peerDependencies": { - "typescript": "*" + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" }, "peerDependenciesMeta": { - "typescript": { + "@types/react": { "optional": true } } }, - "node_modules/vue-eslint-parser": { - "version": "9.4.3", - "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", - "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==", - "dev": true, - "dependencies": { - "debug": "^4.3.4", - "eslint-scope": "^7.1.1", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.3.1", - "esquery": "^1.4.0", - "lodash": "^4.17.21", - "semver": "^7.3.6" - }, - "engines": { - "node": "^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, + "node_modules/use-composed-ref": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.3.0.tgz", + "integrity": "sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==", + "license": "MIT", "peerDependencies": { - "eslint": ">=6.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, - "node_modules/vue-eslint-parser/node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node_modules/use-isomorphic-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", + "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/vue-eslint-parser/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/vue-loader": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-17.4.2.tgz", - "integrity": "sha512-yTKOA4R/VN4jqjw4y5HrynFL8AK0Z3/Jt7eOJXEitsm0GMRHDBjCfCiuTiLP7OESvsZYo2pATCWhDqxC5ZrM6w==", - "dev": true, + "node_modules/use-latest": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.2.1.tgz", + "integrity": "sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==", + "license": "MIT", "dependencies": { - "chalk": "^4.1.0", - "hash-sum": "^2.0.0", - "watchpack": "^2.4.0" + "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { - "webpack": "^4.1.0 || ^5.0.0-0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" }, "peerDependenciesMeta": { - "@vue/compiler-sfc": { - "optional": true - }, - "vue": { + "@types/react": { "optional": true } } }, - "node_modules/vue-router": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.4.3.tgz", - "integrity": "sha512-sv6wmNKx2j3aqJQDMxLFzs/u/mjA9Z5LCgy6BE0f7yFWMjrPLnS/sPNn8ARY/FXw6byV18EFutn5lTO6+UsV5A==", + "node_modules/use-sidecar": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", + "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", + "license": "MIT", "dependencies": { - "@vue/devtools-api": "^6.6.3" + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" }, - "funding": { - "url": "https://github.com/sponsors/posva" + "engines": { + "node": ">=10" }, "peerDependencies": { - "vue": "^3.2.0" - } - }, - "node_modules/vue-style-loader": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.3.tgz", - "integrity": "sha512-sFuh0xfbtpRlKfm39ss/ikqs9AbKCoXZBpHeVZ8Tx650o0k0q/YCM7FRvigtxpACezfq6af+a7JeqVTWvncqDg==", - "dev": true, - "dependencies": { - "hash-sum": "^1.0.2", - "loader-utils": "^1.0.2" + "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/vue-style-loader/node_modules/hash-sum": { + "node_modules/util-deprecate": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-1.0.2.tgz", - "integrity": "sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA==", - "dev": true + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" }, - "node_modules/watchpack": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", - "integrity": "sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==", + "node_modules/vite": { + "version": "5.4.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", + "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", "dev": true, + "license": "MIT", "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack": { - "version": "5.94.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", - "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", - "dev": true, - "dependencies": { - "@types/estree": "^1.0.5", - "@webassemblyjs/ast": "^1.12.1", - "@webassemblyjs/wasm-edit": "^1.12.1", - "@webassemblyjs/wasm-parser": "^1.12.1", - "acorn": "^8.7.1", - "acorn-import-attributes": "^1.9.5", - "browserslist": "^4.21.10", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { - "webpack": "bin/webpack.js" + "vite": "bin/vite.js" }, "engines": { - "node": ">=10.13.0" + "node": "^18.0.0 || >=20.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "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" }, "peerDependenciesMeta": { - "webpack-cli": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { "optional": true } } }, - "node_modules/webpack-cli": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", - "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", + "node_modules/vite-node": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.3.tgz", + "integrity": "sha512-I1JadzO+xYX887S39Do+paRePCKoiDrWRRjp9kkG5he0t7RXNvPAJPCQSJqbGN4uCrFFeS3Kj3sLqY8NMYBEdA==", "dev": true, + "license": "MIT", "dependencies": { - "@discoveryjs/json-ext": "^0.5.0", - "@webpack-cli/configtest": "^2.1.1", - "@webpack-cli/info": "^2.0.2", - "@webpack-cli/serve": "^2.0.5", - "colorette": "^2.0.14", - "commander": "^10.0.1", - "cross-spawn": "^7.0.3", - "envinfo": "^7.7.3", - "fastest-levenshtein": "^1.0.12", - "import-local": "^3.0.2", - "interpret": "^3.1.1", - "rechoir": "^0.8.0", - "webpack-merge": "^5.7.3" + "cac": "^6.7.14", + "debug": "^4.3.6", + "pathe": "^1.1.2", + "vite": "^5.0.0" }, "bin": { - "webpack-cli": "bin/cli.js" + "vite-node": "vite-node.mjs" }, "engines": { - "node": ">=14.15.0" + "node": "^18.0.0 || >=20.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.3.tgz", + "integrity": "sha512-Zrxbg/WiIvUP2uEzelDNTXmEMJXuzJ1kCpbDvaKByFA9MNeO95V+7r/3ti0qzJzrxdyuUw5VduN7k+D3VmVOSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.3", + "@vitest/mocker": "2.1.3", + "@vitest/pretty-format": "^2.1.3", + "@vitest/runner": "2.1.3", + "@vitest/snapshot": "2.1.3", + "@vitest/spy": "2.1.3", + "@vitest/utils": "2.1.3", + "chai": "^5.1.1", + "debug": "^4.3.6", + "magic-string": "^0.30.11", + "pathe": "^1.1.2", + "std-env": "^3.7.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.0", + "tinypool": "^1.0.0", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.3", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "webpack": "5.x.x" + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.3", + "@vitest/ui": "2.1.3", + "happy-dom": "*", + "jsdom": "*" }, "peerDependenciesMeta": { - "@webpack-cli/generators": { + "@edge-runtime/vm": { "optional": true }, - "webpack-bundle-analyzer": { + "@types/node": { "optional": true }, - "webpack-dev-server": { + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { "optional": true } } }, - "node_modules/webpack-cli/node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "node_modules/vitest-fetch-mock": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/vitest-fetch-mock/-/vitest-fetch-mock-0.3.0.tgz", + "integrity": "sha512-g6upWcL8/32fXL43/5f4VHcocuwQIi9Fj5othcK9gPO8XqSEGtnIZdenr2IaipDr61ReRFt+vaOEgo8jiUUX5w==", "dev": true, + "license": "MIT", + "dependencies": { + "cross-fetch": "^4.0.0" + }, "engines": { - "node": ">=14" + "node": ">=14.14.0" + }, + "peerDependencies": { + "vitest": ">=2.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" } }, - "node_modules/webpack-cli/node_modules/webpack-merge": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", - "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "license": "ISC", "dependencies": { - "clone-deep": "^4.0.1", - "flat": "^5.0.2", - "wildcard": "^2.0.0" + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" }, "engines": { - "node": ">=10.0.0" + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/webpack-merge": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", - "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "node_modules/which-builtin-type": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.4.tgz", + "integrity": "sha512-bppkmBSsHFmIMSl8BO9TbsyzsvGjVoppt8xUiGzwiu/bhDCGxnpOKCxgqj6GuyHE0mINMDecBFPlOm2hzY084w==", "dev": true, + "license": "MIT", "dependencies": { - "clone-deep": "^4.0.1", - "flat": "^5.0.2", - "wildcard": "^2.0.1" + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.0.5", + "is-finalizationregistry": "^1.0.2", + "is-generator-function": "^1.0.10", + "is-regex": "^1.1.4", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.15" }, "engines": { - "node": ">=18.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, "engines": { - "node": ">=10.13.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "node_modules/which-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", "dev": true, + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">= 10.13.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, + "license": "MIT", "dependencies": { - "isexe": "^2.0.0" + "siginfo": "^2.0.0", + "stackback": "0.0.2" }, "bin": { - "node-which": "bin/node-which" + "why-is-node-running": "cli.js" }, "engines": { - "node": ">= 8" + "node": ">=8" } }, - "node_modules/wildcard": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", - "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", - "dev": true - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=0.10.0" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, + "license": "ISC", "engines": { "node": ">=12" } @@ -5202,47 +6774,13 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zip-stream": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", - "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", - "dev": true, - "dependencies": { - "archiver-utils": "^3.0.4", - "compress-commons": "^4.1.2", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/zip-stream/node_modules/archiver-utils": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", - "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", - "dev": true, - "dependencies": { - "glob": "^7.2.3", - "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } } } } diff --git a/web/package.json b/web/package.json index 9f5fc19f..0ae6b9cc 100644 --- a/web/package.json +++ b/web/package.json @@ -1,57 +1,58 @@ { + "name": "web", + "private": true, + "type": "module", "scripts": { - "generate": "openapi-typescript ./../api/openapi.yml --immutable-types --output ./src/api/schema.gen.ts", - "watch": "webpack --config webpack/webpack.dev.js --watch", - "build": "webpack --config webpack/webpack.prod.js", - "lint": "eslint ./src --ext .ts,.vue" + "generate": "node --no-deprecation api.generate.js ./../api/openapi.yml ./src/api/schema.gen.ts", + "fmt": "prettier --write ./*.{js,ts} ./src && npm run lint:es -- --fix", + "lint": "npm run lint:ts && npm run lint:es", + "lint:ts": "tsc --noEmit", + "lint:es": "eslint ./src/**/*.{ts,tsx}", + "test": "vitest --run", + "serve": "vite --strictPort", + "watch": "vite build --watch", + "build": "tsc --noEmit && vite build" }, "dependencies": { - "@fortawesome/fontawesome-svg-core": "^6.6.0", - "@fortawesome/free-brands-svg-icons": "^6.6.0", - "@fortawesome/free-regular-svg-icons": "^6.6.0", - "@fortawesome/free-solid-svg-icons": "^6.6.0", - "@fortawesome/vue-fontawesome": "^3.0.8", - "@highlightjs/vue-plugin": "^2.1.0", - "@popperjs/core": "^2.11.8", - "bootstrap": "^5.3.2", - "bootswatch": "^5.3.2", - "clipboard": "^2.0.11", - "izitoast": "^1.4.0", - "js-base64": "^3.7.7", - "moment": "^2.30.1", - "openapi-typescript-fetch": "^2.0.0", - "reconnecting-websocket": "^4.4.0", - "vue": "^3.5.0", - "vue-router": "^4.4.3" + "@mantine/code-highlight": "^7.13.4", + "@mantine/core": "^7.13.4", + "@mantine/hooks": "^7.13.4", + "@mantine/notifications": "^7.13.4", + "@tabler/icons-react": "^3.20.0", + "dayjs": "^1.11.13", + "openapi-fetch": "^0.12.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", + "semver": "^7.6.3" }, "devDependencies": { - "@types/bootstrap": "^5.2.10", - "@typescript-eslint/eslint-plugin": "^5.62.0", - "@typescript-eslint/parser": "^5.62.0", - "@vue/eslint-config-typescript": "^13.0.0", - "copy-webpack-plugin": "^12.0.2", - "css-loader": "^7.1.2", - "eslint": "^8.57.0", - "eslint-plugin-vue": "^9.28.0", - "filemanager-webpack-plugin": "^8.0.0", - "html-webpack-plugin": "^5.6.0", - "json-minimizer-webpack-plugin": "^5.0.0", - "mini-css-extract-plugin": "^2.9.1", - "openapi-typescript": "^5.4.1", - "sass": "^1.78.0", - "sass-loader": "^16.0.1", - "terser-webpack-plugin": "^5.3.10", - "ts-loader": "^9.5.1", - "type-fest": "^4.26.0", - "typescript": "^5.5.4", - "vue-eslint-parser": "^9.4.3", - "vue-loader": "^17.4.2", - "vue-style-loader": "^4.1.3", - "webpack": "^5.94.0", - "webpack-cli": "^5.1.4", - "webpack-merge": "^6.0.1" + "@eslint/compat": "^1.2.1", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.13.0", + "@types/node": "^22.7.9", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/semver": "^7.5.8", + "@typescript-eslint/eslint-plugin": "^8.11.0", + "@typescript-eslint/parser": "^8.11.0", + "@vitejs/plugin-react": "^4.3.3", + "eslint": "^9.13.0", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.11.0", + "openapi-typescript": "^7.4.1", + "postcss": "^8.4.47", + "postcss-preset-mantine": "^1.17.0", + "postcss-simple-vars": "^7.0.1", + "prettier": "^3.3.3", + "typescript": "^5.6.3", + "vite": "^5.4.10", + "vitest": "^2.1.3", + "vitest-fetch-mock": "^0.3.0" }, "engines": { - "node": ">=16" + "node": ">=21" } } diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 00000000..5ada75c1 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,14 @@ +export default { + plugins: { + 'postcss-preset-mantine': {}, + 'postcss-simple-vars': { + variables: { + 'mantine-breakpoint-xs': '36em', + 'mantine-breakpoint-sm': '48em', + 'mantine-breakpoint-md': '62em', + 'mantine-breakpoint-lg': '75em', + 'mantine-breakpoint-xl': '88em', + }, + }, + }, +} diff --git a/web/prettier.config.js b/web/prettier.config.js new file mode 100644 index 00000000..39124016 --- /dev/null +++ b/web/prettier.config.js @@ -0,0 +1,13 @@ +/** + * @see https://prettier.io/docs/en/configuration.html + * @type {import("prettier").Config} + */ +const config = { + semi: false, + tabWidth: 2, + singleQuote: true, + printWidth: 120, + trailingComma: 'es5', +} + +export default config diff --git a/web/public/apple-touch-icon.png b/web/public/apple-touch-icon.png new file mode 100644 index 00000000..25df19b6 Binary files /dev/null and b/web/public/apple-touch-icon.png differ diff --git a/web/public/assets/404.html b/web/public/assets/404.html deleted file mode 100644 index 6dd71ee6..00000000 --- a/web/public/assets/404.html +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - Error 404 - Not found - - - - - -
Error 404: Not found
- - - - diff --git a/web/public/assets/apple-touch-icon.png b/web/public/assets/apple-touch-icon.png deleted file mode 100644 index acd16b5d..00000000 Binary files a/web/public/assets/apple-touch-icon.png and /dev/null differ diff --git a/web/public/assets/browserconfig.xml b/web/public/assets/browserconfig.xml deleted file mode 100644 index d4580b1c..00000000 --- a/web/public/assets/browserconfig.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/web/public/assets/favicon.ico b/web/public/assets/favicon.ico deleted file mode 100644 index 3da40689..00000000 Binary files a/web/public/assets/favicon.ico and /dev/null differ diff --git a/web/public/assets/robots.txt b/web/public/assets/robots.txt deleted file mode 100644 index 80998f55..00000000 --- a/web/public/assets/robots.txt +++ /dev/null @@ -1,4 +0,0 @@ -User-agent: * - -# Deny all robots: -Disallow: diff --git a/web/public/assets/sitemap.xml b/web/public/assets/sitemap.xml deleted file mode 100644 index 7962ef42..00000000 --- a/web/public/assets/sitemap.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/web/public/assets/webhook-icon.png b/web/public/assets/webhook-icon.png deleted file mode 100644 index eb711e86..00000000 Binary files a/web/public/assets/webhook-icon.png and /dev/null differ diff --git a/web/public/assets/webmanifest.json b/web/public/assets/webmanifest.json deleted file mode 100644 index 7083bfad..00000000 --- a/web/public/assets/webmanifest.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "WebHook Tester", - "short_name": "WebHook Tester", - "description": "Allows you to easily test webhooks and other types of HTTP requests", - "start_url": "/", - "icons": [ - { - "src": "apple-touch-icon.png", - "sizes": "180x180", - "type": "image/png" - } - ], - "theme_color": "#222222", - "background_color": "#222222", - "display": "standalone" -} diff --git a/web/public/favicon-48x48.png b/web/public/favicon-48x48.png new file mode 100644 index 00000000..a0603cf1 Binary files /dev/null and b/web/public/favicon-48x48.png differ diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 00000000..e0806b75 Binary files /dev/null and b/web/public/favicon.ico differ diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 00000000..3eed1665 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/web/public/index.html b/web/public/index.html deleted file mode 100644 index acd7ae52..00000000 --- a/web/public/index.html +++ /dev/null @@ -1,136 +0,0 @@ - - - - - - - - - - - - - - - - WebHook Tester - - - - - - - -
-
-
-
-
-
-
- -
- - - diff --git a/web/public/robots.txt b/web/public/robots.txt new file mode 100644 index 00000000..1f53798b --- /dev/null +++ b/web/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/web/public/site.webmanifest b/web/public/site.webmanifest new file mode 100644 index 00000000..92ae9bd4 --- /dev/null +++ b/web/public/site.webmanifest @@ -0,0 +1,34 @@ +{ + "name": "WebHook Tester", + "short_name": "WebHook Tester", + "description": "Allows you to easily test webhooks and other types of HTTP requests", + "id": "app", + "start_url": "/", + "scope": "/", + "lang": "en", + "icons": [ + { + "src": "/favicon-48x48.png", + "sizes": "48x48", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable", + "purpose": "any" + } + ], + "orientation": "natural", + "theme_color": "#dcdcdc", + "background_color": "#18181c", + "display": "standalone" +} diff --git a/web/public/web-app-manifest-192x192.png b/web/public/web-app-manifest-192x192.png new file mode 100644 index 00000000..2b2f83d7 Binary files /dev/null and b/web/public/web-app-manifest-192x192.png differ diff --git a/web/public/web-app-manifest-512x512.png b/web/public/web-app-manifest-512x512.png new file mode 100644 index 00000000..4c49df93 Binary files /dev/null and b/web/public/web-app-manifest-512x512.png differ diff --git a/web/public/web-app-manifest-screenshot-640x320.png~ b/web/public/web-app-manifest-screenshot-640x320.png~ new file mode 100644 index 00000000..42ec57b0 Binary files /dev/null and b/web/public/web-app-manifest-screenshot-640x320.png~ differ diff --git a/web/src/api/api.ts b/web/src/api/api.ts deleted file mode 100644 index 6c56b89f..00000000 --- a/web/src/api/api.ts +++ /dev/null @@ -1,149 +0,0 @@ -import {Fetcher} from 'openapi-typescript-fetch' -import {paths, components} from './schema.gen' -import {Base64} from 'js-base64' - -const fetcher = Fetcher.for() -const textEncoder = new TextEncoder() -const textDecoder = new TextDecoder('utf-8') - -export function getAppVersion(): Promise { - return new Promise((resolve, reject) => { - fetcher.path('/api/version').method('get').create() - .call(fetcher, {}) - .then((resp) => resolve(resp.data.version)) - .catch(reject) - }) -} - -interface APISettingsResponse { - limits: { - maxRequests: number - maxWebhookBodySize: number - sessionLifetimeSec: number - } -} - -export function getAppSettings(): Promise { - return new Promise((resolve, reject) => { - fetcher.path('/api/settings').method('get').create() - .call(fetcher, {}) - .then((resp) => resolve({ - limits: { - maxRequests: resp.data.limits.max_requests, - maxWebhookBodySize: resp.data.limits.max_webhook_body_size, - sessionLifetimeSec: resp.data.limits.session_lifetime_sec, - } - })) - .catch(reject) - }) -} - -interface APINewSessionRequest { - statusCode?: number - contentType?: string - responseDelay?: number - responseContent?: Uint8Array -} - -interface APINewSessionResponse { - UUID: string - response: { - content: Uint8Array - code: number - contentType: string - delaySec: number - } - createdAt: Date -} - -export function startNewSession(request: APINewSessionRequest): Promise { - return new Promise((resolve, reject) => { - fetcher.path('/api/session').method('post').create() - .call(fetcher, { - content_type: request.contentType, - response_content_base64: Base64.encode(textDecoder.decode(request.responseContent)), - response_delay: request.responseDelay, - status_code: request.statusCode, - }) - .then((resp) => resolve({ - UUID: resp.data.uuid, - response: { - content: textEncoder.encode(Base64.decode(resp.data.response.content_base64)), - code: resp.data.response.code, - contentType: resp.data.response.content_type, - delaySec: resp.data.response.delay_sec, - }, - createdAt: new Date(resp.data.created_at_unix * 1000), - })) - .catch(reject) - }) -} - -export function deleteSession(sessionUUID: string): Promise { - return new Promise((resolve, reject) => { - fetcher.path('/api/session/{session_uuid}').method('delete').create() - .call(fetcher, {session_uuid: sessionUUID}) - .then((resp) => resolve(resp.data.success)) - .catch(reject) - }) -} - -export type HTTPMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'TRACE' - -export interface RecordedRequest { - UUID: string - clientAddress: string - method: HTTPMethod - content: Uint8Array - headers: { name: string, value: string }[] - url: string // relative (`/foo/bar`, NOT `http://example.com/foo/bar`) - createdAt: Date -} - -function convertRecordedRequest(r: components['schemas']['SessionRequest']): RecordedRequest { - return { - UUID: r.uuid, - clientAddress: r.client_address, - method: r.method, - content: textEncoder.encode(Base64.decode(r.content_base64)), - headers: r.headers.map(h => ({name: h.name, value: h.value})), - url: r.url, - createdAt: new Date(r.created_at_unix * 1000), - } -} - -export function getSessionRequest(sessionUUID: string, requestUUID: string): Promise { - return new Promise((resolve, reject) => { - fetcher.path('/api/session/{session_uuid}/requests/{request_uuid}').method('get').create() - .call(fetcher, {session_uuid: sessionUUID, request_uuid: requestUUID}) - .then((resp) => resolve(convertRecordedRequest(resp.data))) - .catch(reject) - }) -} - -export function getAllSessionRequests(sessionUUID: string): Promise { - return new Promise((resolve, reject) => { - fetcher.path('/api/session/{session_uuid}/requests').method('get').create() - .call(fetcher, {session_uuid: sessionUUID}) - .then((resp) => resolve(resp.data.map((r) => convertRecordedRequest(r)))) - .catch(reject) - }) -} - -export function deleteSessionRequest(sessionUUID: string, requestUUID: string): Promise { - return new Promise((resolve, reject) => { - fetcher.path('/api/session/{session_uuid}/requests/{request_uuid}').method('delete').create() - .call(fetcher, {session_uuid: sessionUUID, request_uuid: requestUUID}) - .then((resp) => resolve(resp.data.success)) - .catch(reject) - }) -} - -export function deleteAllSessionRequests(sessionUUID: string): Promise { - return new Promise((resolve, reject) => { - fetcher.path('/api/session/{session_uuid}/requests').method('delete').create() - .call(fetcher, {session_uuid: sessionUUID}) - .then((resp) => resolve(resp.data.success)) - .catch(reject) - }) -} diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 00000000..9ee5dae3 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,389 @@ +import createClient, { type Client as OpenapiClient, type ClientOptions } from 'openapi-fetch' +import { coerce as semverCoerce, parse as semverParse, type SemVer } from 'semver' +import { base64ToUint8Array, uint8ArrayToBase64 } from '~/shared' +import { APIErrorUnknown } from './errors' +import { throwIfNotJSON, throwIfNotValidResponse } from './middleware' +import { components, paths } from './schema.gen' + +type AppSettings = Readonly<{ + limits: Readonly<{ + maxRequests: number + maxRequestBodySize: number // In bytes + sessionTTL: number // In seconds + }> +}> + +type SessionOptions = Readonly<{ + uuid: string + response: Readonly<{ + statusCode: number + headers: ReadonlyArray<{ name: string; value: string }> + delay: number + body: Readonly + }> + createdAt: Readonly +}> + +type CapturedRequest = Readonly<{ + uuid: string + clientAddress: string + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE' | string + requestPayload: Uint8Array + headers: ReadonlyArray<{ name: string; value: string }> + url: Readonly + capturedAt: Readonly +}> + +type CapturedRequestShort = Omit + +export class Client { + private readonly baseUrl: URL + private readonly api: OpenapiClient + private cache: Partial<{ + currentVersion: Readonly + latestVersion: Readonly + settings: AppSettings + }> = {} + + constructor(opt?: ClientOptions) { + this.baseUrl = new URL( + opt?.baseUrl ? opt.baseUrl.replace(/\/+$/, '') : window.location.protocol + '//' + window.location.host + ) + + this.api = createClient(opt) + this.api.use(throwIfNotJSON, throwIfNotValidResponse) + } + + /** + * Returns the version of the app. + * + * @throws {APIError} + */ + async currentVersion(force: boolean = false): Promise> { + if (this.cache.currentVersion && !force) { + return this.cache.currentVersion + } + + const { data, response } = await this.api.GET('/api/version') + + if (data) { + const version = semverParse(semverCoerce(data.version.replace('@', '-'))) + + if (!version) { + throw new APIErrorUnknown({ message: `Failed to parse the current version value: ${data.version}`, response }) + } + + this.cache.currentVersion = Object.freeze(version) + + return this.cache.currentVersion + } + + throw new APIErrorUnknown({ message: response.statusText, response }) // will never happen due to the middleware + } + + /** + * Returns the latest available version of the app. + * + * @throws {APIError} + */ + async latestVersion(force: boolean = false): Promise> { + if (this.cache.latestVersion && !force) { + return this.cache.latestVersion + } + + const { data, response } = await this.api.GET('/api/version/latest') + + if (data) { + const version = semverParse(semverCoerce(data.version)) + + if (!version) { + throw new APIErrorUnknown({ message: `Failed to parse the latest version value: ${data.version}`, response }) + } + + this.cache.latestVersion = Object.freeze(version) + + return this.cache.latestVersion + } + + throw new APIErrorUnknown({ message: response.statusText, response }) + } + + /** + * Returns the app settings. + * + * @throws {APIError} + */ + async getSettings(force: boolean = false): Promise { + if (this.cache.settings && !force) { + return this.cache.settings + } + + const { data, response } = await this.api.GET('/api/settings') + + if (data) { + this.cache.settings = Object.freeze({ + limits: Object.freeze({ + maxRequests: data.limits.max_requests, + maxRequestBodySize: data.limits.max_request_body_size, + sessionTTL: data.limits.session_ttl, // in seconds + }), + }) + + return this.cache.settings + } + + throw new APIErrorUnknown({ message: response.statusText, response }) + } + + /** + * Creates a new session with the specified response settings. + * + * @throws {APIError} + */ + async newSession({ + statusCode = 200, + headers = {}, + delay = 0, + responseBody = new Uint8Array(), + }: { + statusCode?: number + headers?: Record + delay?: number + responseBody?: Uint8Array + }): Promise { + const { data, response } = await this.api.POST('/api/session', { + body: { + status_code: Math.min(Math.max(100, statusCode), 530), // clamp to the valid range + headers: Object.entries(headers) + .map(([name, value]) => ({ name, value })) // convert to array of objects + .filter((h) => h.value), // remove empty values + delay: Math.min(Math.max(0, delay), 30), // clamp to the valid range + response_body_base64: uint8ArrayToBase64(responseBody), + }, + }) + + if (data) { + return Object.freeze({ + uuid: data.uuid, + response: Object.freeze({ + statusCode: data.response.status_code, + headers: data.response.headers.map(({ name, value }) => Object.freeze({ name, value })), + delay: data.response.delay, + body: base64ToUint8Array(data.response.response_body_base64), + }), + createdAt: Object.freeze(new Date(data.created_at_unix_milli)), + }) + } + + throw new APIErrorUnknown({ message: response.statusText, response }) + } + + /** + * Returns the session by its ID. + * + * @throws {APIError} + */ + async getSession(sID: string): Promise { + const { data, response } = await this.api.GET(`/api/session/{session_uuid}`, { + params: { path: { session_uuid: sID } }, + }) + + if (data) { + return Object.freeze({ + uuid: data.uuid, + response: Object.freeze({ + statusCode: data.response.status_code, + headers: data.response.headers.map(({ name, value }) => Object.freeze({ name, value })), + delay: data.response.delay, + body: base64ToUint8Array(data.response.response_body_base64), + }), + createdAt: Object.freeze(new Date(data.created_at_unix_milli)), + }) + } + + throw new APIErrorUnknown({ message: response.statusText, response }) + } + + /** + * Deletes the session by its ID. + * + * @throws {APIError} + */ + async deleteSession(sID: string): Promise { + const { data, response } = await this.api.DELETE('/api/session/{session_uuid}', { + params: { path: { session_uuid: sID } }, + }) + + if (data) { + return data.success + } + + throw new APIErrorUnknown({ message: response.statusText, response }) + } + + /** + * Returns the list of captured requests for the session by its ID. + * + * @throws {APIError} + */ + async getSessionRequests(sID: string): Promise> { + const { data, response } = await this.api.GET('/api/session/{session_uuid}/requests', { + params: { path: { session_uuid: sID } }, + }) + + if (data) { + return Object.freeze( + data + // convert the list of requests to the immutable objects with the correct types + .map((req) => + Object.freeze({ + uuid: req.uuid, + clientAddress: req.client_address, + method: req.method, + requestPayload: base64ToUint8Array(req.request_payload_base64), + headers: Object.freeze(req.headers.map(({ name, value }) => Object.freeze({ name, value }))), + url: Object.freeze(new URL(req.url)), + capturedAt: Object.freeze(new Date(req.captured_at_unix_milli)), + }) + ) + // sort the list by capturedAt date, to have the latest requests first + .sort((a, b) => b.capturedAt.getTime() - a.capturedAt.getTime()) + ) + } + + throw new APIErrorUnknown({ message: response.statusText, response }) + } + + /** + * Deletes all captured requests for the session by its ID. + * + * @throws {APIError} + */ + async deleteAllSessionRequests(sID: string): Promise { + const { data, response } = await this.api.DELETE('/api/session/{session_uuid}/requests', { + params: { path: { session_uuid: sID } }, + }) + + if (data) { + return data.success + } + + throw new APIErrorUnknown({ message: response.statusText, response }) + } + + /** + * Subscribes to the captured requests for the session by its ID. + * + * The promise resolves with a closer function that can be called to close the WebSocket connection. + */ + async subscribeToSessionRequests( + sID: string, + { + onConnected, + onUpdate, + onError, + }: { + onConnected?: () => void // called when the WebSocket connection is established + onUpdate: (request: CapturedRequestShort) => void // called when the update is received + onError?: (err: Error) => void // called when an error occurs on alive connection + } + ): Promise void> { + const protocol = this.baseUrl.protocol === 'https:' ? 'wss:' : 'ws:' + const path: keyof paths = '/api/session/{session_uuid}/requests/subscribe' + + return new Promise((resolve: (closer: () => void) => void, reject: (err: Error) => void) => { + let connected: boolean = false + + try { + const ws = new WebSocket(`${protocol}//${this.baseUrl.host}${path.replace('{session_uuid}', sID)}`) + + ws.onopen = (): void => { + connected = true + onConnected?.() + resolve((): void => ws.close()) + } + + ws.onerror = (event: Event): void => { + // convert Event to Error + const err = new Error(event instanceof ErrorEvent ? String(event.error) : 'WebSocket error') + + if (connected) { + onError?.(err) + } + + reject(err) // will be ignored if the promise is already resolved + } + + ws.onmessage = (event): void => { + if (event.data) { + const req = JSON.parse(event.data) as components['schemas']['CapturedRequestShort'] + + onUpdate( + Object.freeze({ + uuid: req.uuid, + clientAddress: req.client_address, + method: req.method, + headers: Object.freeze(req.headers), + url: Object.freeze(new URL(req.url)), + capturedAt: Object.freeze(new Date(req.captured_at_unix_milli)), + }) + ) + } + } + } catch (e) { + // convert any exception to Error + const err = e instanceof Error ? e : new Error(String(e)) + + if (connected) { + onError?.(err) + } + + reject(err) + } + }) + } + + /** + * Returns the captured request by its ID. + * + * @throws {APIError} + */ + async getSessionRequest(sID: string, rID: string): Promise { + const { data, response } = await this.api.GET('/api/session/{session_uuid}/requests/{request_uuid}', { + params: { path: { session_uuid: sID, request_uuid: rID } }, + }) + + if (data) { + return Object.freeze({ + uuid: data.uuid, + clientAddress: data.client_address, + method: data.method, + requestPayload: base64ToUint8Array(data.request_payload_base64), + headers: Object.freeze(data.headers), + url: Object.freeze(new URL(data.url)), + capturedAt: Object.freeze(new Date(data.captured_at_unix_milli)), + }) + } + + throw new APIErrorUnknown({ message: response.statusText, response }) + } + + /** + * Deletes the captured request by its ID. + * + * @throws {APIError} + */ + async deleteSessionRequest(sID: string, rID: string): Promise { + const { data, response } = await this.api.DELETE('/api/session/{session_uuid}/requests/{request_uuid}', { + params: { path: { session_uuid: sID, request_uuid: rID } }, + }) + + if (data) { + return data.success + } + + throw new APIErrorUnknown({ message: response.statusText, response }) + } +} + +export default new Client() // singleton instance diff --git a/web/src/api/errors.test.ts b/web/src/api/errors.test.ts new file mode 100644 index 00000000..34c7117a --- /dev/null +++ b/web/src/api/errors.test.ts @@ -0,0 +1,8 @@ +import { test, expect } from 'vitest' +import { APIErrorCommon, APIErrorNotFound, APIErrorUnknown } from './errors' + +test('errors', () => { + expect(new APIErrorNotFound().description.toLowerCase()).contains('not found') + expect(new APIErrorCommon().description.toLowerCase()).contains('server') + expect(new APIErrorUnknown().description.toLowerCase()).contains("don't know") +}) diff --git a/web/src/api/errors.ts b/web/src/api/errors.ts new file mode 100644 index 00000000..460820f8 --- /dev/null +++ b/web/src/api/errors.ts @@ -0,0 +1,30 @@ +interface APIError extends Error { + readonly response?: Response + readonly description: string +} + +abstract class BaseAPIError extends Error implements APIError { + public readonly response?: Response + public abstract readonly description: string + + constructor({ message, response }: { message?: string; response?: Response } = {}) { + super(message) + + this.response = response + } +} + +class APIErrorNotFound extends BaseAPIError { + public readonly description = 'Not found' +} + +class APIErrorCommon extends BaseAPIError { + public readonly description = "Something went wrong on the server side, but we can't identify it as a specific error" +} + +class APIErrorUnknown extends BaseAPIError { + public readonly description = + "Something went wrong, and we don't know what (usually on the client or JS libraries side)" +} + +export { type APIError, APIErrorNotFound, APIErrorCommon, APIErrorUnknown } diff --git a/web/src/api/index.ts b/web/src/api/index.ts new file mode 100644 index 00000000..4060e118 --- /dev/null +++ b/web/src/api/index.ts @@ -0,0 +1,2 @@ +export { default as apiClient, type Client } from './client' +export { type APIError, APIErrorNotFound, APIErrorCommon, APIErrorUnknown } from './errors' diff --git a/web/src/api/middleware.test.ts b/web/src/api/middleware.test.ts new file mode 100644 index 00000000..2447c579 --- /dev/null +++ b/web/src/api/middleware.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest' +import { throwIfNotJSON, throwIfNotValidResponse } from './middleware' +import createFetchMock from 'vitest-fetch-mock' +import type { Middleware } from 'openapi-fetch' +import createClient from 'openapi-fetch' +import { APIErrorCommon, APIErrorNotFound } from './errors' + +const fetchMocker = createFetchMock(vi) + +beforeAll(() => fetchMocker.enableMocks()) +afterEach(() => fetchMocker.resetMocks()) + +interface paths { + '/self': { + get: { + responses: { + 200: { + headers: Record + content: { + 'application/json': string + } + } + } + } + } +} + +const newClient = (...mv: Middleware[]) => { + const client = createClient({ baseUrl: 'http://localhost' }) + + client.use(...mv) // attach the middleware + + return client +} + +describe('throwIfNotJSON', () => { + test('pass', async () => { + const client = newClient(throwIfNotJSON) + + fetchMocker.mockResponseOnce(() => ({ + status: 200, + body: '"ok"', + headers: { 'Content-Type': 'application/json' }, // the header is correct + })) + + const { data, error } = await client.GET('/self') + + expect(data).equals('ok') + expect(error).toBeUndefined() + }) + + test('throws', async () => { + const client = newClient(throwIfNotJSON) + + fetchMocker.mockResponseOnce(() => ({ + status: 200, + body: '"ok"', + headers: { 'Content-Type': 'text/html' }, // the header is incorrect + })) + + try { + await client.GET('/self') + + expect(true).toBe(false) // fail the test if the error is not thrown + } catch (e: TypeError | unknown) { + expect(e).toBeInstanceOf(APIErrorCommon) + expect((e as TypeError).message).toBe('Response is not JSON (the header "Content-Type" does not include "json")') + } + }) +}) + +describe('throwIfNotValidResponse', () => { + test('pass', async () => { + const client = newClient(throwIfNotValidResponse) + + fetchMocker.mockResponseOnce(() => ({ + status: 200, + body: '"ok"', + headers: { 'Content-Type': 'text/html' }, // the header doesn't matter + })) + + const { data, error } = await client.GET('/self') + + expect(data).equals('ok') + expect(error).toBeUndefined() + }) + + test('throws ({ message: "..." })', async () => { + const client = newClient(throwIfNotValidResponse) + + fetchMocker.mockResponseOnce(() => ({ + status: 404, + body: `{"message": "some value"}`, + headers: { 'Content-Type': 'application/json' }, // the header is correct + })) + + try { + await client.GET('/self') + + expect(true).toBe(false) + } catch (e: TypeError | unknown) { + expect(e).toBeInstanceOf(APIErrorNotFound) + expect((e as TypeError).message).toBe('some value') + } + }) + + test('throws ({ error: "..." })', async () => { + const client = newClient(throwIfNotValidResponse) + + fetchMocker.mockResponseOnce(() => ({ + status: 404, + body: `{"error": "some value"}`, + headers: { 'Content-Type': 'application/json' }, // the header is correct + })) + + try { + await client.GET('/self') + + expect(true).toBe(false) + } catch (e: TypeError | unknown) { + expect(e).toBeInstanceOf(APIErrorNotFound) + expect((e as TypeError).message).toBe('some value') + } + }) + + test('throws ({ errors: ["...", ...] })', async () => { + const client = newClient(throwIfNotValidResponse) + + fetchMocker.mockResponseOnce(() => ({ + status: 404, + body: `{"errors": ["some", "value"]}`, + headers: { 'Content-Type': 'application/json' }, // the header is correct + })) + + try { + await client.GET('/self') + + expect(true).toBe(false) + } catch (e: TypeError | unknown) { + expect(e).toBeInstanceOf(APIErrorNotFound) + expect((e as TypeError).message).toBe('some, value') + } + }) + + test('throws ({ errors: { "...": "..." } })', async () => { + const client = newClient(throwIfNotValidResponse) + + fetchMocker.mockResponseOnce(() => ({ + status: 500, + body: `{"errors": {"a": "some", "b": "value"}}`, + headers: { 'Content-Type': 'application/json' }, // the header is correct + })) + + try { + await client.GET('/self') + + expect(true).toBe(false) + } catch (e: TypeError | unknown) { + expect(e).toBeInstanceOf(APIErrorCommon) + expect((e as TypeError).message).toBe('some, value') + } + }) + + test('throws', async () => { + const client = newClient(throwIfNotValidResponse) + + fetchMocker.mockResponseOnce(() => ({ + status: 500, + body: `{"error]`, // since the content type, the error message will be `res.statusText` + headers: { 'Content-Type': 'foo/bar' }, // the header is incorrect + })) + + try { + await client.GET('/self') + + expect(true).toBe(false) + } catch (e: TypeError | unknown) { + expect(e).toBeInstanceOf(APIErrorCommon) + expect((e as TypeError).message).toBe('Internal Server Error') + } + }) + + test('throws json (Failed to parse the response body as JSON)', async () => { + const client = newClient(throwIfNotValidResponse) + + fetchMocker.mockResponseOnce(() => ({ + status: 500, + body: `{"error]`, + headers: { 'Content-Type': 'application/json' }, // the header is correct + })) + + try { + await client.GET('/self') + + expect(true).toBe(false) + } catch (e: TypeError | unknown) { + expect(e).toBeInstanceOf(APIErrorCommon) + expect((e as TypeError).message).toBe('Internal Server Error (failed to parse the response body as JSON)') + } + }) +}) diff --git a/web/src/api/middleware.ts b/web/src/api/middleware.ts new file mode 100644 index 00000000..26adc692 --- /dev/null +++ b/web/src/api/middleware.ts @@ -0,0 +1,67 @@ +import type { Middleware } from 'openapi-fetch' +import { APIErrorCommon, APIErrorNotFound } from './errors' + +/** This middleware throws an error if the response is not JSON. */ +export const throwIfNotJSON: Middleware = { + async onResponse({ response }): Promise { + // check the response header "Content-Type" to be sure it's JSON + if (response.headers.get('content-type')?.toLowerCase().includes('json')) { + return undefined + } + + throw new APIErrorCommon({ + message: 'Response is not JSON (the header "Content-Type" does not include "json")', + response: response, + }) + }, +} + +/** This middleware throws a well-formatted error if the response is not OK. */ +export const throwIfNotValidResponse: Middleware = { + async onResponse({ response }): Promise { + // skip if the response is OK + if (response.ok) { + return undefined + } + + let message: string = response.statusText + + // try to parse the response body as JSON and extract the error message + if (response.headers.get('content-type')?.toLowerCase().includes('json')) { + try { + const data = await response.clone().json() + + switch (true) { + case data.message && typeof data.message === 'string': // { message: "..." } + message = data.message + break + + case data.error && typeof data.error === 'string': // { error: "..." } + message = data.error + break + + case data.errors && Array.isArray(data.errors) && data.errors.length > 0: // { errors: ["...", ...] } + message = data.errors.filter((e: unknown) => typeof e === 'string').join(', ') + break + + case data.errors && typeof data.errors === 'object': // { errors: { "...": "..." } } + message = Object.values(data.errors as Record) + .filter((e) => typeof e === 'string') + .join(', ') + break + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_) { + message += ' (failed to parse the response body as JSON)' + } + } + + // handle some common HTTP status codes + switch (response.status) { + case 404: + throw new APIErrorNotFound({ message, response: response }) + } + + throw new APIErrorCommon({ message, response: response }) + }, +} diff --git a/web/src/assets/logo-text.svg b/web/src/assets/logo-text.svg new file mode 100644 index 00000000..8f690048 --- /dev/null +++ b/web/src/assets/logo-text.svg @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + diff --git a/web/src/assets/logo.svg b/web/src/assets/logo.svg new file mode 100644 index 00000000..3f8aa76b --- /dev/null +++ b/web/src/assets/logo.svg @@ -0,0 +1,21 @@ + + + + + diff --git a/web/src/assets/panda.svg b/web/src/assets/panda.svg new file mode 100644 index 00000000..0f0db179 --- /dev/null +++ b/web/src/assets/panda.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/globals.d.ts b/web/src/globals.d.ts new file mode 100644 index 00000000..38303b9e --- /dev/null +++ b/web/src/globals.d.ts @@ -0,0 +1,5 @@ +/** + * @description declared in `vite.config.ts` + */ +declare const __GITHUB_PROJECT_LINK__: Readonly +declare const __LATEST_RELEASE_LINK__: Readonly diff --git a/web/src/index.ts b/web/src/index.ts deleted file mode 100644 index d825fb1f..00000000 --- a/web/src/index.ts +++ /dev/null @@ -1,90 +0,0 @@ -import {createApp} from 'vue' -import {createRouter, createWebHashHistory} from 'vue-router' -import {library} from '@fortawesome/fontawesome-svg-core' -import { - faAngleLeft, - faAngleRight, - faAnglesLeft, - faAnglesRight, - faArrowUpRightFromSquare, - faAtom, - faDownload, - faFont, - faPersonRunning, - faPlus, - faQuestion -} from '@fortawesome/free-solid-svg-icons' -import { - faGithub, - faGolang, - faJava, - faJs, - faNodeJs, - faPhp, - faPython -} from '@fortawesome/free-brands-svg-icons' -import {faCopy} from '@fortawesome/free-regular-svg-icons' -import ClipboardJS from 'clipboard' -import iziToast from 'izitoast' -import mainApp from './views/main-app.vue' -import hljs from 'highlight.js/lib/core' -import hlJavascript from 'highlight.js/lib/languages/javascript' -import hlGo from 'highlight.js/lib/languages/go' -import hlPython from 'highlight.js/lib/languages/python' -import hlJava from 'highlight.js/lib/languages/java' -import hlPhp from 'highlight.js/lib/languages/php' -import hljsVuePlugin from '@highlightjs/vue-plugin' - -library.add( // https://fontawesome.com/icons - faQuestion, - faGithub, - faCopy, - faPlus, - faArrowUpRightFromSquare, - faAngleLeft, - faAngleRight, - faAnglesLeft, - faAnglesRight, - faFont, - faAtom, - faDownload, - faPersonRunning, - faPhp, - faGolang, - faPython, - faJava, - faNodeJs, - faJs, -) - -hljs.registerLanguage('javascript', hlJavascript) -hljs.registerLanguage('go', hlGo) -hljs.registerLanguage('python', hlPython) -hljs.registerLanguage('java', hlJava) -hljs.registerLanguage('php', hlPhp) - -new ClipboardJS('[data-clipboard]') // - .on('error', () => { - iziToast.error({title: 'Copying error!'}) - }) - .on('success', (e) => { - iziToast.success({title: 'Copied!', message: e.text, timeout: 4000}) - - e.clearSelection() - }) - -const router = createRouter({ - history: createWebHashHistory(), // https://router.vuejs.org/guide/essentials/history-mode.html#hash-mode - routes: [ - {path: '/', name: 'index', component: {}}, - {path: '/:sessionUUID?/:requestUUID?', name: 'request', component: {}}, - ], -}) - -createApp(mainApp) - .use(router) - .use(hljsVuePlugin) - .use(() => { - document.getElementById('main-loader')?.remove() // hide main loading spinner - }) - .mount('#app') diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 00000000..f60ffd6a --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,39 @@ +import React, { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { createBrowserRouter, RouterProvider } from 'react-router-dom' +import { MantineProvider } from '@mantine/core' +import { Notifications } from '@mantine/notifications' +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import { routes } from './routing' +import { BrowserNotificationsProvider, SessionsProvider, UISettingsProvider } from './shared' +import '@mantine/core/styles.css' +import '@mantine/code-highlight/styles.css' +import '@mantine/notifications/styles.css' +import '~/theme/app.css' + +dayjs.extend(relativeTime) // https://day.js.org/docs/en/plugin/relative-time + +/** App component */ +const App = (): React.JSX.Element => { + return ( + + + + + + + + + + + ) +} + +const root = document.getElementById('root') as HTMLElement + +createRoot(root).render( + + + +) diff --git a/web/src/routing/index.ts b/web/src/routing/index.ts new file mode 100644 index 00000000..51f3e5e1 --- /dev/null +++ b/web/src/routing/index.ts @@ -0,0 +1 @@ +export { routes, pathTo, RouteIDs } from './routing' diff --git a/web/src/routing/routing.tsx b/web/src/routing/routing.tsx new file mode 100644 index 00000000..720d436f --- /dev/null +++ b/web/src/routing/routing.tsx @@ -0,0 +1,70 @@ +import { createPath, Navigate, type RouteObject } from 'react-router-dom' +import { apiClient } from '~/api' +import { DefaultLayout } from '~/screens' +import { NotFoundScreen } from '~/screens/not-found' +import { SessionAndRequestScreen } from '~/screens/session' +import { HomeScreen } from '~/screens/home' + +export enum RouteIDs { + Home = 'home', + SessionAndRequest = 'session-and-request', +} + +export const routes: RouteObject[] = [ + { + path: '/', + element: , + errorElement: , + children: [ + { + index: true, + element: , + id: RouteIDs.Home, + }, + { + // redirect to the home screen if the path is just `/s/` + path: 's/', + element: , + }, + { + // please note that `sID` and `rID` accessed via `useParams` hook, and changing this will break the app + path: 's/:sID/:rID?', + id: RouteIDs.SessionAndRequest, + element: , + }, + ], + }, +] + +type RouteParams = T extends RouteIDs.SessionAndRequest + ? [string /* sID */, string? /* rID (optional) */] + : [] // no params + +/** + * Converts a route ID to a path to use in a link. + * + * @example + * ```tsx + * Go to home + * ``` + */ +export function pathTo( + path: RouteIDs, + ...params: T extends RouteIDs ? RouteParams : never +): string { + switch (path) { + case RouteIDs.Home: + return createPath({ pathname: '/' }) + case RouteIDs.SessionAndRequest: { + const [sID, rID] = [params[0] ?? 'no-session', params[1]] + + if (!rID) { + return createPath({ pathname: `/s/${encodeURIComponent(sID)}` }) + } + + return createPath({ pathname: `/s/${encodeURIComponent(sID)}/${encodeURIComponent(rID)}` }) + } + default: + throw new Error(`Unknown route: ${path}`) // will never happen because of the type guard + } +} diff --git a/web/src/screens/components/header-help-modal.tsx b/web/src/screens/components/header-help-modal.tsx new file mode 100644 index 00000000..632ad379 --- /dev/null +++ b/web/src/screens/components/header-help-modal.tsx @@ -0,0 +1,70 @@ +import { CodeHighlight } from '@mantine/code-highlight' +import { Divider, Modal, Text, Title } from '@mantine/core' +import React from 'react' + +export default function HeaderHelpModal({ + opened, + onClose, + webHookUrl = null, + sessionTTLSec = 0, + maxBodySizeBytes = 0, + maxRequestsPerSession = 0, +}: { + opened: boolean + onClose: () => void + webHookUrl: URL | null + sessionTTLSec: number | null + maxBodySizeBytes: number | null + maxRequestsPerSession: number | null +}): React.JSX.Element { + return ( + What is Webhook Tester?} + centered + > + + Webhook Tester lets you easily test webhooks and other HTTP requests. Here's your unique URL: + + + + + Any requests sent to this URL are instantly logged here — no need to refresh! + + + + To specify a status code in the response, append it to the URL, like so: + + + + This way, the URL will respond with a 404 status. + + + Feel free to bookmark this page to revisit the request details at any time. + {!!sessionTTLSec && + sessionTTLSec > 0 && + ` Requests and tokens for this URL expire after ${sessionTTLSec / 60 / 60 / 24} days of inactivity.`} + {!!maxBodySizeBytes && + maxBodySizeBytes > 0 && + ` The maximum size for incoming requests is ${bytesToKilobytes(maxBodySizeBytes)} KiB.`} + {!!maxRequestsPerSession && + maxRequestsPerSession > 0 && + ` The maximum number of requests per session is ${maxRequestsPerSession}.`} + + + ) +} + +const bytesToKilobytes = (bytes: number): number => { + if (isFinite(bytes)) { + return Number((bytes / 1024).toFixed(1)) + } + + return 0 +} diff --git a/web/src/screens/components/header-new-session-modal.tsx b/web/src/screens/components/header-new-session-modal.tsx new file mode 100644 index 00000000..afc6bb45 --- /dev/null +++ b/web/src/screens/components/header-new-session-modal.tsx @@ -0,0 +1,195 @@ +import React, { useCallback, useState } from 'react' +import { Button, Checkbox, Group, Modal, NumberInput, Space, Text, Textarea, Title } from '@mantine/core' +import { IconCodeAsterisk, IconHeading, IconHourglassHigh, IconVersions } from '@tabler/icons-react' +import { useStorage, UsedStorageKeys } from '~/shared' + +const limits = { + statusCode: { min: 100, max: 530 }, + responseHeaders: { maxCount: 10, minNameLen: 1, maxNameLen: 40, maxValueLen: 2048 }, + delay: { min: 0, max: 30 }, +} + +export type NewSessionOptions = { + statusCode: number + headers: Array<{ name: string; value: string }> + delay: number + responseBody: string + destroyCurrentSession: boolean +} + +export default function HeaderNewSessionModal({ + opened, + loading = false, + onClose, + onCreate, + maxRequestBodySize = 10240, +}: { + opened: boolean + loading?: boolean + onClose: () => void + onCreate: (_: NewSessionOptions) => void + maxRequestBodySize: number | null +}): React.JSX.Element { + const [statusCode, setStatusCode] = useStorage(200, UsedStorageKeys.NewSessionStatusCode, 'session') + const [headersList, setHeadersList] = useStorage( + 'Content-Type: application/json\nServer: WebhookTester', + UsedStorageKeys.NewSessionHeadersList, + 'session' + ) + const [delay, setDelay] = useStorage(0, UsedStorageKeys.NewSessionSessionDelay, 'session') + const [responseBody, setResponseBody] = useStorage( + '{"captured": true}', + UsedStorageKeys.NewSessionResponseBody, + 'session' + ) + const [destroyCurrentSession, setDestroyCurrentSession] = useStorage( + true, + UsedStorageKeys.NewSessionDestroyCurrentSession, + 'session' + ) + + const [wrongStatusCode, setWrongStatusCode] = useState(false) + const [wrongDelay, setWrongDelay] = useState(false) + const [wrongResponseBody, setWrongResponseBody] = useState(false) + + /** Handle the creation of a new session */ + const handleCreate = useCallback(() => { + // validate all the fields + if (statusCode < limits.statusCode.min || statusCode > limits.statusCode.max) { + setWrongStatusCode(true) + + return + } else { + setWrongStatusCode(false) + } + + const headers = headersList + .split('\n') // split by each line + .map((line) => { + const [name, ...valueParts] = line.split(': ') + const value = valueParts.join(': ') // join in case of additional colons in value + + return { name: name.trim(), value: value.trim() } + }) + .filter((header) => header.name && header.value) // remove empty headers + .filter((header) => header.name.length >= limits.responseHeaders.minNameLen) // filter by min name length + .filter((header) => header.name.length <= limits.responseHeaders.maxNameLen) // filter by max name length + .filter((header) => header.value.length <= limits.responseHeaders.maxValueLen) // filter by max value length + .slice(0, limits.responseHeaders.maxCount) + + if (delay < limits.delay.min || delay > limits.delay.max) { + setWrongDelay(true) + + return + } else { + setWrongDelay(false) + } + + if (!!maxRequestBodySize && maxRequestBodySize > 0 && responseBody.length > maxRequestBodySize) { + setWrongResponseBody(true) + + return + } else { + setWrongResponseBody(false) + } + + onCreate({ statusCode, headers, delay, responseBody, destroyCurrentSession }) + }, [delay, destroyCurrentSession, headersList, maxRequestBodySize, onCreate, responseBody, statusCode]) + + return ( + Configure URL} + centered + > + + You have the ability to customize how your URL will respond by changing the status code, headers, response delay + and the content. + + + } + min={limits.statusCode.min} + max={limits.statusCode.max} + error={wrongStatusCode} + disabled={loading} + value={statusCode} + onChange={(v: string | number): void => setStatusCode(typeof v === 'string' ? parseInt(v, 10) : v)} + /> +