commit 8afa94767e1f5ea348ab4ba4d002beff1237daf3 Author: Xi Xu Date: Sun Jul 27 02:09:25 2025 +0800 Initial repository setup and core files added Add configuration, documentation, code of conduct, contributing guide, license, and README files. Set up .gitignore, coverage config, GitHub issue templates, pull request template, and initial source code, scripts, and tests for DeepLX optimized for Cloudflare Workers. diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..93338b9 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,30 @@ +# Coverage configuration for DeepLX test suite + +[run] +source = src/ +omit = + */node_modules/* + */tests/* + */coverage/* + */.git/* + */dist/* + */build/* + +[report] +exclude_lines = + pragma: no cover + def __repr__ + if self.debug: + if settings.DEBUG + raise AssertionError + raise NotImplementedError + if 0: + if __name__ == .__main__.: + class .*\bProtocol\): + @(abc\.)?abstractmethod + +[html] +directory = coverage/html + +[xml] +output = coverage/coverage.xml \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..74f5794 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://xi-xu.me/#sponsorships diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..ca30c8b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,193 @@ +name: 🐛 Bug Report +description: Report a bug or unexpected behavior +title: "[BUG] " +labels: ["bug", "needs-triage"] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + + Before submitting, please: + - Search existing issues to avoid duplicates + - Check if the issue exists in the latest version + - Test with minimal reproduction steps + + - type: checkboxes + id: checklist + attributes: + label: Pre-submission Checklist + description: Please confirm the following before submitting + options: + - label: I have searched existing issues + required: true + - label: I am using the latest version of DeepLX + required: true + - label: I have checked the documentation + required: true + + - type: textarea + id: bug-description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is + placeholder: Describe the bug... + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected Behavior + description: What did you expect to happen? + placeholder: Describe what you expected... + validations: + required: true + + - type: textarea + id: actual-behavior + attributes: + label: Actual Behavior + description: What actually happened? + placeholder: Describe what actually happened... + validations: + required: true + + - type: textarea + id: reproduction-steps + attributes: + label: Steps to Reproduce + description: Detailed steps to reproduce the issue + placeholder: | + 1. Configure DeepLX with... + 2. Send a request to... + 3. Observe that... + value: | + 1. + 2. + 3. + validations: + required: true + + - type: textarea + id: minimal-example + attributes: + label: Minimal Reproduction Example + description: Provide a minimal code example that reproduces the issue + placeholder: | + // Minimal example + const response = await fetch('/translate', { + method: 'POST', + body: JSON.stringify({ + text: 'Hello world', + target_lang: 'ES' + }) + }); + render: typescript + + - type: dropdown + id: environment + attributes: + label: Environment + description: Where are you running DeepLX? + options: + - Cloudflare Workers + - Local development + - Docker + - Other (specify in additional context) + validations: + required: true + + - type: input + id: version + attributes: + label: DeepLX Version + description: What version of DeepLX are you using? + placeholder: e.g., 1.0.0 + validations: + required: true + + - type: input + id: node-version + attributes: + label: Node.js Version + description: What version of Node.js are you using? + placeholder: e.g., 18.17.0 + + - type: input + id: wrangler-version + attributes: + label: Wrangler Version + description: What version of Wrangler are you using? (if applicable) + placeholder: e.g., 4.25.0 + + - type: textarea + id: error-logs + attributes: + label: Error Logs + description: Include any relevant error messages or logs + placeholder: Paste error logs here... + render: shell + + - type: textarea + id: network-info + attributes: + label: Network Information + description: Include request/response details if relevant + placeholder: | + Request: + POST /translate + Content-Type: application/json + + Response: + Status: 500 + Body: {"error": "..."} + render: http + + - type: dropdown + id: frequency + attributes: + label: Bug Frequency + description: How often does this bug occur? + options: + - Always (100%) + - Often (75-99%) + - Sometimes (25-74%) + - Rarely (1-24%) + - Unable to reproduce + validations: + required: true + + - type: dropdown + id: severity + attributes: + label: Severity + description: How severe is this bug? + options: + - Critical (system unusable) + - High (major functionality affected) + - Medium (some functionality affected) + - Low (minor issue) + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Any other information that might be helpful + placeholder: | + - Related issues or PRs + - Workarounds you've tried + - Impact on your use case + - Configuration details + + - type: checkboxes + id: contribution + attributes: + label: Contribution + description: Are you willing to help? + options: + - label: I am willing to submit a pull request to fix this bug + - label: I am available for testing the fix diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..18bbf36 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: false +contact_links: + - name: 💬 GitHub Discussions + url: https://github.com/xixu-me/DeepLX/discussions + about: Ask questions, share ideas, and discuss with the community + - name: 🔒 Security Policy + url: https://github.com/xixu-me/DeepLX/security/policy + about: Report security vulnerabilities privately + - name: 📖 Documentation + url: https://github.com/xixu-me/DeepLX#readme + about: Read the repository documentation and guides + - name: 🚀 Cloudflare Workers Docs + url: https://developers.cloudflare.com/workers/ + about: Learn more about Cloudflare Workers platform diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 0000000..9017df9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,136 @@ +name: 📚 Documentation Issue +description: Report an issue with documentation or request documentation improvements +title: "[DOCS] " +labels: ["documentation", "needs-triage"] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thanks for helping improve our documentation! + + Good documentation is crucial for repository adoption and developer experience. + + - type: checkboxes + id: checklist + attributes: + label: Pre-submission Checklist + description: Please confirm the following before submitting + options: + - label: I have searched existing issues + required: true + - label: I have checked the latest documentation + required: true + + - type: dropdown + id: doc-type + attributes: + label: Documentation Type + description: What type of documentation issue is this? + options: + - Missing documentation + - Incorrect information + - Outdated information + - Unclear explanation + - Typo or grammar error + - Code example issue + - API documentation + - Setup/installation guide + - Configuration documentation + - Best practices guide + - Other (specify below) + validations: + required: true + + - type: input + id: doc-location + attributes: + label: Documentation Location + description: Where is the documentation issue located? + placeholder: e.g., README.md, docs/api.md, inline comments, etc. + + - type: textarea + id: issue-description + attributes: + label: Issue Description + description: Describe the documentation issue + placeholder: | + - What is missing, incorrect, or unclear? + - What specific section or topic? + - How does this affect users? + validations: + required: true + + - type: textarea + id: current-content + attributes: + label: Current Content + description: Quote the current documentation (if applicable) + placeholder: Copy the current text that needs to be changed... + render: markdown + + - type: textarea + id: suggested-content + attributes: + label: Suggested Improvement + description: Provide your suggested changes or additions + placeholder: What should the documentation say instead? + render: markdown + + - type: textarea + id: code-examples + attributes: + label: Code Examples + description: Provide code examples if applicable + placeholder: | + // Example usage + const response = await translate({ + text: "Hello", + target_lang: "ES" + }); + render: typescript + + - type: dropdown + id: audience + attributes: + label: Target Audience + description: Who would benefit from this documentation? + options: + - New users/beginners + - Experienced developers + - API integrators + - Contributors + - DevOps/deployment teams + - All users + validations: + required: true + + - type: textarea + id: user-impact + attributes: + label: User Impact + description: How does this documentation issue affect users? + placeholder: | + - Difficulty understanding how to use the feature + - Unable to complete setup/configuration + - Confusion about API behavior + - Deployment/integration challenges + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Any other relevant information + placeholder: | + - Related issues or PRs + - External resources that might help + - Screenshots or examples + + - type: checkboxes + id: contribution + attributes: + label: Contribution + description: Are you willing to help improve the documentation? + options: + - label: I am willing to submit a pull request to fix this + - label: I can help review and test the improved documentation diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..d32a071 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,218 @@ +name: ✨ Feature Request +description: Suggest a new feature or enhancement +title: "[FEATURE] " +labels: ["enhancement", "needs-triage"] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a new feature! + + Before submitting, please: + - Search existing issues to avoid duplicates + - Check if the feature aligns with the repository goals + - Consider if this could be implemented as a plugin or extension + + - type: checkboxes + id: checklist + attributes: + label: Pre-submission Checklist + description: Please confirm the following before submitting + options: + - label: I have searched existing issues + required: true + - label: I have checked the roadmap and documentation + required: true + - label: This feature aligns with the repository's goals + required: true + + - type: textarea + id: feature-summary + attributes: + label: Feature Summary + description: A brief, clear summary of the feature + placeholder: What feature would you like to see added? + validations: + required: true + + - type: textarea + id: problem-statement + attributes: + label: Problem Statement + description: What problem does this feature solve? + placeholder: | + Describe the problem or limitation you're experiencing: + - What is currently difficult or impossible to do? + - How does this impact your workflow? + - What is the current workaround (if any)? + validations: + required: true + + - type: textarea + id: proposed-solution + attributes: + label: Proposed Solution + description: Describe your proposed solution in detail + placeholder: | + Explain how you envision this feature working: + - What would the API look like? + - How would users interact with it? + - What would be the expected behavior? + validations: + required: true + + - type: textarea + id: use-cases + attributes: + label: Use Cases + description: Provide specific use cases and examples + placeholder: | + Example use cases: + 1. As a developer, I want to... + 2. When translating documents, I need to... + 3. For improved functionality, it would be helpful to... + validations: + required: true + + - type: textarea + id: api-design + attributes: + label: API Design (if applicable) + description: Propose the API design for this feature + placeholder: | + // Example API usage + POST /api/new-feature + { + "parameter1": "value", + "parameter2": true + } + + // Response + { + "result": "...", + "status": "success" + } + render: typescript + + - type: dropdown + id: feature-type + attributes: + label: Feature Type + description: What type of feature is this? + options: + - New API endpoint + - Enhancement to existing functionality + - Performance improvement + - Developer experience improvement + - Documentation enhancement + - Configuration option + - Other (specify in additional context) + validations: + required: true + + - type: dropdown + id: priority + attributes: + label: Priority + description: How important is this feature to you? + options: + - Critical (blocks major functionality) + - High (significant improvement) + - Medium (nice to have) + - Low (minor enhancement) + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternative Solutions + description: Have you considered any alternative solutions? + placeholder: | + - Alternative approach 1: ... + - Alternative approach 2: ... + - Why the proposed solution is preferred: ... + + - type: textarea + id: implementation-thoughts + attributes: + label: Implementation Thoughts + description: Any thoughts on how this could be implemented? + placeholder: | + - Which files/modules would need changes? + - Are there any technical challenges? + - Dependencies or breaking changes? + - Performance considerations? + + - type: dropdown + id: breaking-changes + attributes: + label: Breaking Changes + description: Would this feature require breaking changes? + options: + - No breaking changes + - Minor breaking changes (with migration path) + - Major breaking changes + - Unsure + validations: + required: true + + - type: textarea + id: compatibility + attributes: + label: Compatibility Considerations + description: Any compatibility concerns? + placeholder: | + - Backward compatibility requirements + - Impact on existing APIs + - Migration strategy (if applicable) + + - type: checkboxes + id: related-areas + attributes: + label: Related Areas + description: Which areas of the codebase would this affect? + options: + - Translation API + - Rate limiting + - Caching + - Authentication + - Error handling + - Performance monitoring + - Configuration + - Documentation + - Testing + - Deployment + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Any other information that might be helpful + placeholder: | + - Links to related resources + - Screenshots or mockups + - Performance requirements + - Security considerations + + - type: checkboxes + id: contribution + attributes: + label: Contribution + description: Are you willing to help implement this feature? + options: + - label: I am willing to submit a pull request for this feature + - label: I can help with design and testing + - label: I can provide detailed requirements and feedback + + - type: textarea + id: timeline + attributes: + label: Timeline + description: When would you need this feature? + placeholder: | + - Immediate need + - Within next month + - Within next quarter + - No specific timeline diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..1b8c33a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,134 @@ +name: ❓ Question +description: Ask a question about DeepLX usage, configuration, or implementation +title: "[QUESTION] " +labels: ["question", "needs-triage"] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thanks for your question! + + Before submitting, please: + - Check the documentation and README + - Search existing issues and discussions + - Review the FAQ section (if available) + + For general discussions, consider using [GitHub Discussions](../../discussions) instead. + + - type: checkboxes + id: checklist + attributes: + label: Pre-submission Checklist + description: Please confirm the following before submitting + options: + - label: I have searched existing issues and discussions + required: true + - label: I have checked the documentation + required: true + - label: This is a specific question that needs clarification + required: true + + - type: dropdown + id: question-category + attributes: + label: Question Category + description: What category does your question fall into? + options: + - Setup and installation + - Configuration + - API usage + - Integration + - Performance and optimization + - Error troubleshooting + - Best practices + - Architecture and design + - Contributing and development + - Deployment + - Other (specify below) + validations: + required: true + + - type: textarea + id: question + attributes: + label: Your Question + description: What would you like to know? + placeholder: Ask your question clearly and specifically... + validations: + required: true + + - type: textarea + id: context + attributes: + label: Context and Background + description: Provide context about what you're trying to achieve + placeholder: | + - What are you trying to accomplish? + - What have you already tried? + - What is your current setup? + validations: + required: true + + - type: textarea + id: current-setup + attributes: + label: Current Setup + description: Describe your current configuration and environment + placeholder: | + - DeepLX version: + - Environment: (Cloudflare Workers/Local/Docker) + - Configuration details: + - Dependencies: + + - type: textarea + id: code-example + attributes: + label: Relevant Code + description: Share any relevant code or configuration + placeholder: | + // Your current implementation + const config = { + // your configuration + }; + render: typescript + + - type: textarea + id: expected-outcome + attributes: + label: Expected Outcome + description: What result are you expecting or hoping to achieve? + placeholder: Describe what you want to happen... + + - type: textarea + id: additional-info + attributes: + label: Additional Information + description: Any other details that might be helpful + placeholder: | + - Error messages (if any) + - Logs or debugging output + - Related documentation you've read + - External resources you've consulted + + - type: dropdown + id: urgency + attributes: + label: Urgency + description: How urgent is this question for you? + options: + - Not urgent (learning/exploring) + - Moderate (affecting development) + - High (blocking progress) + - Critical (production issue) + + - type: checkboxes + id: help-others + attributes: + label: Community Help + description: How can this help others? + options: + - label: This question might be common for other users + - label: The answer could improve documentation + - label: This relates to a potential feature request + - label: This might reveal a bug or issue diff --git a/.github/ISSUE_TEMPLATE/security.yml b/.github/ISSUE_TEMPLATE/security.yml new file mode 100644 index 0000000..6b75e9d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/security.yml @@ -0,0 +1,134 @@ +name: 🔒 Security Issue +description: Report a security vulnerability (for non-critical issues only) +title: "[SECURITY] " +labels: ["security", "needs-triage"] +assignees: [] +body: + - type: markdown + attributes: + value: | + ⚠️ **IMPORTANT: For critical security vulnerabilities, do NOT create a public issue.** + + Instead, please follow our [Security Policy](../../security/policy) to report the issue privately. + + This template is only for: + - Non-critical security improvements + - Security-related questions + - General security hardening suggestions + + - type: checkboxes + id: security-acknowledgment + attributes: + label: Security Acknowledgment + description: Please confirm this is appropriate for a public issue + options: + - label: This is NOT a critical security vulnerability + required: true + - label: I have read the Security Policy + required: true + - label: This issue can be discussed publicly + required: true + + - type: dropdown + id: security-type + attributes: + label: Security Issue Type + description: What type of security issue is this? + options: + - Security hardening suggestion + - Non-critical security improvement + - Security configuration question + - Security documentation improvement + - Security best practices question + - Other (specify below) + validations: + required: true + + - type: textarea + id: security-description + attributes: + label: Security Issue Description + description: Describe the security concern or suggestion + placeholder: | + - What is the security concern? + - How could it be improved? + - What is the potential impact? + validations: + required: true + + - type: textarea + id: current-behavior + attributes: + label: Current Behavior + description: How does the current implementation handle security? + placeholder: Describe the current security implementation... + + - type: textarea + id: suggested-improvement + attributes: + label: Suggested Improvement + description: What security improvements do you suggest? + placeholder: | + - Specific changes to implement + - Security controls to add + - Configuration improvements + - Best practices to follow + + - type: textarea + id: impact-assessment + attributes: + label: Impact Assessment + description: What is the potential impact if not addressed? + placeholder: | + - Risk level: Low/Medium/High + - Affected components + - Potential consequences + - Likelihood of exploitation + + - type: textarea + id: mitigation + attributes: + label: Current Mitigation + description: Are there any current mitigations or workarounds? + placeholder: | + - Existing security controls + - Workarounds users can implement + - Configuration changes that help + + - type: textarea + id: references + attributes: + label: References + description: Any relevant security resources or standards + placeholder: | + - OWASP guidelines + - Security standards + - Research papers + - CVE references (if applicable) + + - type: checkboxes + id: affected-areas + attributes: + label: Affected Areas + description: Which areas of the system are affected? + options: + - Authentication + - Authorization + - Input validation + - Output encoding + - Rate limiting + - Configuration + - Dependencies + - Deployment + - Logging and monitoring + - Data handling + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Any other relevant information + placeholder: | + - Environment details + - Compliance requirements + - Integration considerations diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..f5bb755 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,193 @@ +# Pull Request + +## Description + + +## Type of Change + + +- [ ] 🐛 Bug fix (non-breaking change that fixes an issue) +- [ ] ✨ New feature (non-breaking change that adds functionality) +- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] 📚 Documentation update +- [ ] 🎨 Code style/formatting change +- [ ] ♻️ Code refactoring (no functional changes) +- [ ] ⚡ Performance improvement +- [ ] 🧪 Test updates +- [ ] 🔧 Build/CI changes +- [ ] 🔒 Security improvement + +## Related Issues + + +- Closes # +- Related to # + +## Changes Made + + +- +- +- + +## Testing + + +### Test Coverage + +- [ ] Unit tests added/updated +- [ ] Integration tests added/updated +- [ ] Performance tests added/updated +- [ ] Manual testing completed + +### Test Results + + +```bash +# Test command and results +npm test +``` + +## Performance Impact + + +- [ ] No performance impact +- [ ] Performance improvement +- [ ] Potential performance regression (explain below) + +**Performance Details:** + + +## Security Considerations + + +- [ ] No security implications +- [ ] Security improvement +- [ ] Potential security impact (explain below) + +**Security Details:** + + +## Breaking Changes + + +- [ ] No breaking changes +- [ ] Breaking changes included (describe below) + +**Breaking Change Details:** + + +## Documentation + + +- [ ] Code is self-documenting +- [ ] Added/updated inline comments +- [ ] Added/updated JSDoc comments +- [ ] Updated README.md +- [ ] Updated API documentation +- [ ] Added/updated examples +- [ ] Updated CHANGELOG.md + +## Configuration Changes + + +- [ ] No configuration changes +- [ ] New configuration options added +- [ ] Existing configuration changed +- [ ] Environment variables added/changed + +**Configuration Details:** + + +## Dependencies + + +- [ ] No dependency changes +- [ ] Dependencies added +- [ ] Dependencies updated +- [ ] Dependencies removed + +**Dependency Changes:** + + +## Deployment Notes + + +- [ ] No special deployment requirements +- [ ] Requires environment variable updates +- [ ] Requires configuration changes +- [ ] Requires database migrations +- [ ] Requires infrastructure changes + +**Deployment Details:** + + +## Screenshots/Media + + +## Checklist + + +### Code Quality + +- [ ] Code follows the repository's style guidelines +- [ ] Self-review of the code completed +- [ ] Code is properly formatted +- [ ] No console.log or debug statements left +- [ ] Error handling is appropriate +- [ ] Code is optimized for Cloudflare Workers environment + +### Test Execution + +- [ ] All existing tests pass +- [ ] New tests added for new functionality +- [ ] Tests cover edge cases +- [ ] Integration tests pass +- [ ] Performance tests pass (if applicable) + +### Documentation Updates + +- [ ] Documentation updated (if applicable) +- [ ] Code comments added where necessary +- [ ] API documentation updated (if applicable) +- [ ] Examples updated (if applicable) + +### Review Readiness + +- [ ] PR title follows conventional commit format +- [ ] PR description is clear and complete +- [ ] Related issues are linked +- [ ] PR is ready for review +- [ ] Requested reviewers assigned + +## Additional Context + + +## Review Guidelines + + +**Focus Areas:** + +- [ ] Code correctness and logic +- [ ] Performance implications +- [ ] Security considerations +- [ ] Test coverage and quality +- [ ] Documentation completeness +- [ ] Breaking change impact +- [ ] API design consistency + +**Questions for Discussion:** + + +- +- + +--- + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b281481 --- /dev/null +++ b/.gitignore @@ -0,0 +1,172 @@ +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +\*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +\*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +\*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +\*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.cache +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +.cache/ + +# Comment in the public line in if your repository uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp +.cache + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.\* + +# wrangler repository + +.dev.vars +.wrangler/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..10bfcb3 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +i@xi-xu.me. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..162617c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,262 @@ +# Contributing to DeepLX + +Thank you for your interest in contributing to DeepLX! This document provides guidelines and information for contributors. + +## Code of Conduct + +This repository and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. + +## Getting Started + +1. Fork the repository on GitHub +2. Clone your fork locally +3. Set up the development environment +4. Create a branch for your changes +5. Make your changes +6. Test your changes +7. Submit a pull request + +## Development Setup + +### Prerequisites + +- Node.js >= 18.0.0 +- npm or yarn +- Cloudflare Wrangler CLI + +### Installation + +1. Clone your fork: + + ```bash + git clone https://github.com/YOUR_USERNAME/DeepLX.git + cd DeepLX + ``` + +2. Install dependencies: + + ```bash + npm install + ``` + +3. Set up your development environment: + + ```bash + npm run test:setup + ``` + +4. Start the development server: + + ```bash + npm run dev + ``` + +## Making Changes + +### Branch Naming Convention + +- `feature/description` - New features +- `fix/description` - Bug fixes +- `docs/description` - Documentation updates +- `refactor/description` - Code refactoring +- `test/description` - Test improvements + +### Commit Message Format + +Use conventional commits format: + +``` +type(scope): description + +[optional body] + +[optional footer] +``` + +Types: + +- `feat`: A new feature +- `fix`: A bug fix +- `docs`: Documentation only changes +- `style`: Changes that do not affect the meaning of the code +- `refactor`: A code change that neither fixes a bug nor adds a feature +- `test`: Adding missing tests or correcting existing tests +- `chore`: Changes to the build process or auxiliary tools + +Example: + +``` +feat(api): add new translation feature + +Implement new endpoint that accepts multiple text inputs +for translation to improve performance for bulk operations. + +Closes #123 +``` + +## Testing + +### Running Tests + +```bash +# Run all tests +npm test + +# Run tests with coverage +npm run test:coverage + +# Run specific test types +npm run test:unit +npm run test:integration +npm run test:performance + +# Run tests in watch mode +npm run test:watch +``` + +### Writing Tests + +- Write unit tests for all new functions and classes +- Add integration tests for API endpoints +- Include performance tests for critical paths +- Ensure tests are deterministic and isolated +- Use descriptive test names and organize with `describe` blocks + +### Test Requirements + +- All tests must pass +- Code coverage should not decrease +- Integration tests should verify real-world scenarios +- Performance tests should validate response times + +## Submitting Changes + +### Before Submitting + +1. Ensure all tests pass: `npm test` +2. Check code style: `npm run lint` +3. Update documentation if needed +4. Add tests for new functionality +5. Update CHANGELOG.md if applicable + +### Pull Request Process + +1. Create a pull request from your feature branch +2. Fill out the PR template completely +3. Ensure the PR passes all CI checks +4. Request review from maintainers +5. Address any feedback promptly +6. Keep the PR updated with the main branch + +### PR Requirements + +- Clear description of changes +- Reference related issues +- Include screenshots for UI changes +- Add performance impact notes if applicable +- Update documentation as needed + +## Coding Standards + +### TypeScript Guidelines + +- Use strict TypeScript configuration +- Define explicit types for function parameters and returns +- Avoid `any` type unless absolutely necessary +- Use meaningful variable and function names +- Follow consistent naming conventions + +### Code Style + +- Use 2 spaces for indentation +- Use single quotes for strings +- Add trailing commas in multiline structures +- Keep lines under 100 characters +- Use meaningful comments for complex logic + +### File Organization + +- Keep files focused on a single responsibility +- Use consistent file naming (kebab-case) +- Export types and interfaces from dedicated files +- Group related functionality in modules + +### Performance Considerations + +- Optimize for Cloudflare Workers environment +- Minimize memory allocations +- Use efficient algorithms and data structures +- Cache frequently accessed data +- Monitor and test performance impact + +## Security + +### Security Guidelines + +- Never commit sensitive information +- Use environment variables for configuration +- Validate all user inputs +- Implement proper error handling +- Follow security best practices for APIs + +### Reporting Security Issues + +If you discover a security vulnerability, please follow our [Security Policy](SECURITY.md) instead of creating a public issue. + +## Documentation + +### Code Documentation + +- Add JSDoc comments for public APIs +- Document complex algorithms and business logic +- Include usage examples in documentation +- Keep documentation up to date with code changes + +### README Updates + +- Update feature lists for new functionality +- Add new configuration options +- Update API documentation +- Include new examples and use cases + +## Release Process + +### Versioning + +We follow [Semantic Versioning](https://semver.org/): + +- MAJOR: Breaking changes +- MINOR: New features (backward compatible) +- PATCH: Bug fixes (backward compatible) + +### Changelog + +- Update CHANGELOG.md for all user-facing changes +- Group changes by type (Added, Changed, Fixed, Removed) +- Include migration notes for breaking changes +- Reference related issues and PRs + +## Getting Help + +### Communication Channels + +- GitHub Issues: Bug reports and feature requests +- GitHub Discussions: Questions and general discussion +- Pull Request comments: Code review and implementation discussion + +### Resources + +- [repository Documentation](README.md) +- [API Reference](docs/api.md) +- [Cloudflare Workers Documentation](https://developers.cloudflare.com/workers/) +- [TypeScript Documentation](https://www.typescriptlang.org/docs/) + +## Recognition + +Contributors will be recognized in: + +- CHANGELOG.md for significant contributions +- README.md contributors section +- GitHub contributors page + +Thank you for contributing to DeepLX! 🚀 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..99f5cea --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Xi Xu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..15b72d5 --- /dev/null +++ b/README.md @@ -0,0 +1,548 @@ +# DeepLX + +[![License](https://img.shields.io/github/license/xixu-me/deeplx)](#-license) +[![Deployment Status](https://img.shields.io/website?url=https://dplx.xi-xu.me/translate&label=Online%20Service)](#-online-service) +[![Cloudflare Workers](https://img.shields.io/badge/Cloudflare-Workers-orange?logo=cloudflare)](#-self-deployment) + +Currently, the best serverless implementation of [DeepLX](https://github.com/OwO-Network/DeepLX), optimized for Cloudflare Workers. Through intelligent proxy endpoint rotation, advanced rate limiting algorithms, and circuit breaker mechanisms, it almost completely avoids HTTP 429 errors, providing higher request rate limits and lower network round-trip times than the DeepL API. + +## ✨ Features & Performance Advantages + +### 🚀 Performance Advantages + +DeepLX has significant improvements in performance and stability compared to the DeepL API. Here are key metric comparisons based on specific network environments: + +| Metric | DeepL API | DeepLX (Pre-deployed Instance) | +|--------|-----------|-------------------------------| +| Rate Limit | 50 requests/sec | 80 requests/sec (8 req/sec × 10 proxy endpoints) | +| Average Network RTT | ~450ms | ~180ms (edge network acceleration) | +| HTTP 429 Error Rate | 10-30% | <1% | +| Concurrent Support | Single endpoint limit | Multi-endpoint load balancing | +| Geographic Distribution | Limited | 330+ global edge nodes | + +#### Core Performance Features + +- **Higher Rate Limits**: Intelligent load balancing supports higher concurrent requests than DeepL API +- **Lower Latency**: Global edge network deployment based on Cloudflare Workers +- **Zero Cold Start**: Serverless architecture with instant response +- **Intelligent Caching**: Dual-layer caching system (memory + KV storage) reduces duplicate requests + +#### Technical Advantages + +- **Intelligent Load Balancing**: 10 proxy endpoints automatically distribute requests +- **Dynamic Rate Limiting Algorithm**: Automatically adjusts rate limits based on proxy count +- **Dual-layer Caching System**: Memory cache + KV storage reduces duplicate requests +- **Circuit Breaker Mechanism**: Automatic failover for failed endpoints ensures service continuity +- **Edge Computing**: Global deployment on Cloudflare Workers reduces latency + +### 🛡️ Stability Guarantees + +- **Avoid HTTP 429 Errors**: Almost completely avoids rate limiting through proxy endpoint rotation and token bucket algorithm +- **Circuit Breaker Mechanism**: Automatically detects failed endpoints and performs failover +- **Exponential Backoff Retry**: Intelligent retry mechanism improves success rate + +### 🔒 Security Features + +- **Input Validation**: Comprehensive parameter validation and text sanitization +- **Rate Limiting**: Multi-dimensional rate limiting based on client IP and proxy endpoints +- **CORS Support**: Flexible cross-origin resource sharing configuration +- **Security Headers**: Automatically adds security-related HTTP headers +- **Error Sanitization**: Sensitive information is never exposed + +## 🏗️ Architecture Overview + +```mermaid +graph TB + %% Client Layer + subgraph "Client Layer" + APIClient[API Client] + end + + %% Cloudflare Workers Layer + subgraph "Cloudflare Workers" + direction TB + Router[Hono Router] + + subgraph "API Endpoints" + Translate[POST /translate] + Debug[POST /debug] + end + + subgraph "Core Components" + Security[Security Middleware] + RateLimit[Rate Limiting System] + Cache[Dual-layer Cache] + Query[Translation Engine] + Proxy[Proxy Management] + end + end + + %% Storage Layer + subgraph "Cloudflare Storage" + CacheKV[(Cache KV)] + RateLimitKV[(Rate Limit KV)] + Analytics[(Analytics Engine)] + end + + %% External Services + subgraph "Translation Service" + XDPL[XDPL Proxy Cluster
Vercel Deployment] + end + + %% Connections + APIClient --> Router + Router --> Translate + Router --> Debug + + Translate --> Security + Security --> RateLimit + RateLimit --> Cache + Cache --> Query + Query --> Proxy + + Cache -.-> CacheKV + RateLimit -.-> RateLimitKV + Query -.-> Analytics + + Proxy --> XDPL + + %% Styles + classDef clientClass fill:#e3f2fd,stroke:#1976d2 + classDef workerClass fill:#f3e5f5,stroke:#7b1fa2 + classDef coreClass fill:#e8f5e8,stroke:#388e3c + classDef storageClass fill:#fff3e0,stroke:#f57c00 + classDef externalClass fill:#ffebee,stroke:#d32f2f + + class APIClient clientClass + class Router,Translate,Debug workerClass + class Security,RateLimit,Cache,Query,Proxy coreClass + class CacheKV,RateLimitKV,Analytics storageClass + class XDPL externalClass +``` + +## 🌐 Online Service + +**Pre-deployed Instance**: `https://dplx.xi-xu.me` + +## 📦 Quick Start + +### cURL Example + +```bash +curl -X POST https://dplx.xi-xu.me/translate \ + -H "Content-Type: application/json" \ + -d '{ + "text": "Hello, world!", + "source_lang": "EN", + "target_lang": "ZH" + }' +``` + +### JavaScript Example + +```javascript +async function translate(text, sourceLang = 'auto', targetLang = 'zh') { + const response = await fetch('https://dplx.xi-xu.me/translate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + text: text, + source_lang: sourceLang, + target_lang: targetLang + }) + }); + + const result = await response.json(); + return result.data; +} + +// Usage example +translate('Hello, world!', 'en', 'zh') + .then(result => console.log(result)) + .catch(error => console.error(error)); +``` + +### Python Example + +```python +import requests +import json + +def translate(text, source_lang='auto', target_lang='zh'): + url = 'https://dplx.xi-xu.me/translate' + data = { + 'text': text, + 'source_lang': source_lang, + 'target_lang': target_lang + } + + response = requests.post(url, json=data) + result = response.json() + + if result['code'] == 200: + return result['data'] + else: + raise Exception(f"Translation failed: {result.get('message', 'Unknown error')}") + +# Usage example +try: + result = translate('Hello, world!', 'en', 'zh') + print(result) +except Exception as e: + print(f"Error: {e}") +``` + +## API Client Integration + +Configure API clients to use the pre-deployed instance: + +### [Immersive Translate](https://immersivetranslate.com/) (Closed-source browser extension) + +1. [Install Immersive Translate](https://immersivetranslate.com/download/) +2. Go to developer settings and enable beta testing features +3. Go to translation services and add custom translation service DeepLX, set API URL to: `https://dplx.xi-xu.me` +4. Set the maximum requests per second and the maximum text length per request to appropriate values (e.g., `80` and `5000`) to ensure stability and performance + +### [Pot](https://github.com/pot-app/pot-desktop/blob/master/README_EN.md) (Open-source cross-platform Windows, macOS and Linux app) + +1. [Download and install Pot for your platform](https://github.com/pot-app/pot-desktop/releases/latest) +2. Open Pot settings and navigate to service settings +3. Configure DeepL service type as DeepLX and set custom URL to: `https://dplx.xi-xu.me` + +### [Bob](https://bobtranslate.com/) (Closed-source macOS app) + +1. [Download and install Bob from Mac App Store](https://apps.apple.com/app/id1630034110) +2. Download and install [bob-plugin-deeplx](https://github.com/missuo/bob-plugin-deeplx) plugin +3. Configure plugin to use `https://dplx.xi-xu.me` service address + +## 🚀 Self-deployment + +[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/xixu-me/DeepLX) + +### Prerequisites + +- Node.js 18+ +- Cloudflare Workers account +- Wrangler CLI + +### 1. Clone Repository + +```bash +git clone https://github.com/xixu-me/DeepLX.git +cd DeepLX +``` + +### 2. Install Dependencies + +```bash +npm install +``` + +### 3. Configure Environment + +Edit the `wrangler.jsonc` file and update the following configuration: + +```jsonc +{ + "account_id": "YOUR_CLOUDFLARE_ACCOUNT_ID", + "name": "YOUR_WORKER_NAME", + "vars": { + "DEBUG_MODE": "false", + "PROXY_URLS": "your_proxy_endpoint_list,comma_separated" + } +} +``` + +### 4. Create KV Namespaces + +```bash +# Create cache KV namespace +npx wrangler kv namespace create "CACHE_KV" + +# Create rate limit KV namespace +npx wrangler kv namespace create "RATE_LIMIT_KV" +``` + +Update the returned namespace IDs to the `kv_namespaces` configuration in `wrangler.jsonc`. + +### 5. Deploy to Cloudflare Workers + +```bash +# Development environment +npx wrangler dev + +# Production deployment +npx wrangler deploy +``` + +## 🔧 Proxy Endpoint Deployment + +For optimal performance and stability, it's recommended to deploy as many [XDPL](https://github.com/xixu-me/XDPL) proxy endpoints as possible: + +### Quick Deploy XDPL + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/xixu-me/XDPL) + +### Configure Proxy Endpoints + +1. Deploy multiple XDPL instances +2. Add the deployed URLs to DeepLX's `PROXY_URLS` environment variable: + +```jsonc +{ + "vars": { + "PROXY_URLS": "https://your-xdpl-1.vercel.app/jsonrpc,https://your-xdpl-2.vercel.app/jsonrpc,https://your-xdpl-3.vercel.app/jsonrpc,https://your-xdpl-n.vercel.app/jsonrpc" + } +} +``` + +## 📖 API Reference + +### `/translate` + +**Request Method**: `POST` + +**Request Headers**: `Content-Type: application/json` + +**Request Parameters**: + +| Parameter | Type | Description | Required | +| - | - | - | - | +| `text` | string | Text to translate | Yes | +| `source_lang` | string | Source language code | No, default `AUTO` | +| `target_lang` | string | Target language code | No, default `EN` | + +**Response**: + +```json +{ + "code": 200, + "data": "Translation result", + "id": "Random identifier", + "source_lang": "Detected source language code", + "target_lang": "Target language code" +} +``` + +**Supported Language Codes**: + +- `AUTO` - Auto-detect (source language only) +- `AR` - Arabic +- `BG` - Bulgarian +- `CS` - Czech +- `DA` - Danish +- `DE` - German +- `EL` - Greek +- `EN` - English +- `ES` - Spanish +- `ET` - Estonian +- `FI` - Finnish +- `FR` - French +- `HE` - Hebrew +- `HU` - Hungarian +- `ID` - Indonesian +- `IT` - Italian +- `JA` - Japanese +- `KO` - Korean +- `LT` - Lithuanian +- `LV` - Latvian +- `NB` - Norwegian Bokmål +- `NL` - Dutch +- `PL` - Polish +- `PT` - Portuguese +- `RO` - Romanian +- `RU` - Russian +- `SK` - Slovak +- `SL` - Slovenian +- `SV` - Swedish +- `TH` - Thai +- `TR` - Turkish +- `UK` - Ukrainian +- `VI` - Vietnamese +- `ZH` - Chinese + +For the latest language support list, please refer to [Supported Languages - DeepL Documentation](https://developers.deepl.com/docs/getting-started/supported-languages#translation-source-languages). + +### `/debug` (Only available when `DEBUG_MODE=true`) + +**Request Method**: `POST` + +Used to verify request format and troubleshoot issues. + +### Error Codes + +| Code | Description | +|------|-------------| +| 200 | Translation successful | +| 400 | Request parameter error | +| 429 | Request rate too high | +| 500 | Internal server error | +| 503 | Service temporarily unavailable | + +## ⚙️ Configuration + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `DEBUG_MODE` | Debug mode switch | `false` | +| `PROXY_URLS` | Proxy endpoint list, comma-separated | None | + +### Performance Configuration + +Can be adjusted in `src/lib/config.ts`: + +```typescript +// Request timeout +export const REQUEST_TIMEOUT = 10000; // 10 seconds + +// Retry configuration +export const DEFAULT_RETRY_CONFIG = { + maxRetries: 3, // Maximum retry attempts + initialDelay: 1000, // Initial delay + backoffFactor: 2, // Backoff factor +}; + +// Rate limit configuration +export const RATE_LIMIT_CONFIG = { + PROXY_TOKENS_PER_SECOND: 8, // Tokens per proxy per second + PROXY_MAX_TOKENS: 16, // Maximum tokens per proxy + BASE_TOKENS_PER_MINUTE: 480, // Base tokens per minute +}; + +// Payload limits +export const PAYLOAD_LIMITS = { + MAX_TEXT_LENGTH: 5000, // Maximum text length + MAX_REQUEST_SIZE: 32768, // Maximum request size +}; +``` + +## 🧪 Testing + +```bash +# Run all tests +npm test + +# Run unit tests +npm run test:unit + +# Run integration tests +npm run test:integration + +# Run performance tests +npm run test:performance + +# Generate coverage report +npm run test:coverage +``` + +## 🔧 Troubleshooting + +### Common Issues + +#### 1. HTTP 429 errors still occur frequently + +- Check if proxy endpoint configuration is correct +- Increase the number of proxy endpoints +- Adjust rate limiting configuration + +#### 2. Translation results are inaccurate + +- Confirm source language detection is correct +- Check if text encoding is correct +- Verify language code format + +#### 3. Deployment fails + +- Check Cloudflare account configuration +- Verify KV namespaces are created +- Confirm wrangler.jsonc configuration is correct + +### Debug Mode + +Enable debug mode for detailed information: + +```jsonc +{ + "vars": { + "DEBUG_MODE": "true" + } +} +``` + +Then use the debug endpoint: + +```bash +curl -X POST https://your-domain.workers.dev/debug \ + -H "Content-Type: application/json" \ + -d '{"text": "test", "source_lang": "EN", "target_lang": "ZH"}' +``` + +## 🙏 Acknowledgments + +- [OwO-Network/DeepLX](https://github.com/OwO-Network/DeepLX) - Original implementation based on Go programming language +- [Cloudflare Workers](https://workers.cloudflare.com/) - Hosting platform +- [Hono](https://hono.dev/) - Fast web framework +- [XDPL](https://github.com/xixu-me/XDPL) - Proxy endpoint solution + +## 🤝 Contributing + +We welcome all forms of contributions! Please check the [Contributing Guide](CONTRIBUTING.md) to learn how to participate in repository development. + +1. **Report Issues**: Use [issue templates](https://github.com/xixu-me/DeepLX/issues/new/choose) to report bugs or request features +2. **Submit Code**: Fork the repository, create feature branches, submit pull requests +3. **Improve Documentation**: Fix errors, add examples, improve descriptions +4. **Test Feedback**: Test in different environments and provide feedback + +## 🌟 Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=xixu-me/DeepLX&type=Date)](https://star-history.com/#xixu-me/DeepLX&Date) + +## 📞 Contact + +- **Author**: [Xi Xu](https://xi-xu.me) +- **Email**: [Contact Email](mailto:contact@xi-xu.me) +- **Sponsor**: [Sponsor Link](https://xi-xu.me/#sponsorships) + +## ⚠️ Disclaimer + +This repository is for learning and research purposes only. When using this repository, please comply with the following terms: + +### Terms of Use + +1. **Compliant Use**: Users are responsible for ensuring that use of this repository complies with local laws and regulations and relevant terms of service +2. **Commercial Use**: Before commercial use, please confirm compliance with DeepL's terms of service and usage policies +3. **Service Stability**: This repository relies on third-party services and does not guarantee 100% service availability +4. **Data Privacy**: Translation content is processed through third-party services, please do not translate sensitive or confidential information + +### Limitation of Liability + +- The author is not responsible for any direct or indirect losses caused by using this repository +- Users should bear the risks of use, including but not limited to service interruption, data loss, etc. +- This repository provides no warranty of any kind, including merchantability, fitness for a particular purpose, etc. + +### Terms of Service + +By using this repository, you agree to: + +- Not use this repository for any illegal or harmful purposes +- Not abuse the service or conduct malicious attacks +- Follow reasonable use principles and avoid excessive load on the service + +**Please use this repository only after fully understanding and agreeing to the above terms.** + +## 📝 License + +This repository is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +--- + +
+ +**If this repository is helpful to you, please consider giving it a ⭐ Star!** + +Made with ❤️ by [Xi Xu](https://xi-xu.me) + +
diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 0000000..8f5185c --- /dev/null +++ b/README.zh.md @@ -0,0 +1,548 @@ +# DeepLX + +[![许可证](https://img.shields.io/github/license/xixu-me/deeplx)](#-许可证) +[![部署状态](https://img.shields.io/website?url=https://dplx.xi-xu.me/translate&label=在线服务)](#-在线服务) +[![Cloudflare Workers](https://img.shields.io/badge/Cloudflare-Workers-orange?logo=cloudflare)](#-自部署) + +目前 [DeepLX](https://github.com/OwO-Network/DeepLX) 的最佳无服务器实现,专为 Cloudflare Workers 优化设计。通过智能代理端点轮换、高级限流算法和熔断器机制,几乎完全避免了 HTTP 429 错误,提供比 DeepL API 更高的请求速率限制和更低的网络往返时间。 + +## ✨ 特性与性能优势 + +### 🚀 性能优势 + +DeepLX 在性能和稳定性方面相较于 DeepL API 有显著提升,以下是基于特定网络环境下的关键指标对比: + +| 指标 | DeepL API | DeepLX (预部署实例) | +|------|-----------|-------------------| +| 速率限制 | 50 请求/秒 | 80 请求/秒 (8 请求/秒 × 10 代理端点) | +| 平均网络往返时间 | ~450ms | ~180ms (边缘网络加速) | +| HTTP 429 错误率 | 10-30% | <1% | +| 并发支持 | 单端点限制 | 多端点负载均衡 | +| 地理分布 | 有限 | 全球 330+ 边缘节点 | + +#### 核心性能特性 + +- **更高速率限制**:智能负载均衡,比 DeepL API 支持更高的并发请求 +- **更低延迟**:基于 Cloudflare Workers 的全球边缘网络部署 +- **零冷启动**:无服务器架构,瞬时响应 +- **智能缓存**:双层缓存系统(内存 + KV 存储)减少重复请求 + +#### 技术优势 + +- **智能负载均衡**:10 个代理端点自动分发请求 +- **动态限流算法**:基于代理数量自动调整速率限制 +- **双层缓存系统**:内存缓存 + KV 存储减少重复请求 +- **熔断器机制**:故障端点自动切换,保证服务连续性 +- **边缘计算**:Cloudflare Workers 全球部署,降低延迟 + +### 🛡️ 稳定性保障 + +- **避免 HTTP 429 错误**:通过代理端点轮换和令牌桶算法几乎完全避免限流 +- **熔断器机制**:自动检测故障端点并进行故障转移 +- **指数退避重试**:智能重试机制提高成功率 + +### 🔒 安全特性 + +- **输入验证**:全面的参数校验和文本清理 +- **速率限制**:基于客户端 IP 和代理端点的多维度限流 +- **CORS 支持**:灵活的跨域资源共享配置 +- **安全头部**:自动添加安全相关的 HTTP 头部 +- **错误净化**:敏感信息永不暴露 + +## 🏗️ 架构概览 + +```mermaid +graph TB + %% 客户端层 + subgraph "客户端层" + Client[API 客户端] + end + + %% Cloudflare Workers 层 + subgraph "Cloudflare Workers" + direction TB + Router[Hono 路由器] + + subgraph "API 端点" + Translate[POST /translate] + Debug[POST /debug] + end + + subgraph "核心组件" + Security[安全中间件] + RateLimit[限流系统] + Cache[双层缓存] + Query[翻译引擎] + Proxy[代理管理] + end + end + + %% 存储层 + subgraph "Cloudflare 存储" + CacheKV[(缓存 KV)] + RateLimitKV[(限流 KV)] + Analytics[(分析引擎)] + end + + %% 外部服务 + subgraph "翻译服务" + XDPL[XDPL 代理集群
Vercel 部署] + end + + %% 连接关系 + Client --> Router + Router --> Translate + Router --> Debug + + Translate --> Security + Security --> RateLimit + RateLimit --> Cache + Cache --> Query + Query --> Proxy + + Cache -.-> CacheKV + RateLimit -.-> RateLimitKV + Query -.-> Analytics + + Proxy --> XDPL + + %% 样式 + classDef clientClass fill:#e3f2fd,stroke:#1976d2 + classDef workerClass fill:#f3e5f5,stroke:#7b1fa2 + classDef coreClass fill:#e8f5e8,stroke:#388e3c + classDef storageClass fill:#fff3e0,stroke:#f57c00 + classDef externalClass fill:#ffebee,stroke:#d32f2f + + class Client clientClass + class Router,Translate,Debug workerClass + class Security,RateLimit,Cache,Query,Proxy coreClass + class CacheKV,RateLimitKV,Analytics storageClass + class XDPL externalClass +``` + +## 🌐 在线服务 + +**预部署实例**:`https://dplx.xi-xu.me` + +## 📦 快速开始 + +### cURL 示例 + +```bash +curl -X POST https://dplx.xi-xu.me/translate \ + -H "Content-Type: application/json" \ + -d '{ + "text": "Hello, world!", + "source_lang": "EN", + "target_lang": "ZH" + }' +``` + +### JavaScript 示例 + +```javascript +async function translate(text, sourceLang = 'auto', targetLang = 'zh') { + const response = await fetch('https://dplx.xi-xu.me/translate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + text: text, + source_lang: sourceLang, + target_lang: targetLang + }) + }); + + const result = await response.json(); + return result.data; +} + +// 使用示例 +translate('Hello, world!', 'en', 'zh') + .then(result => console.log(result)) + .catch(error => console.error(error)); +``` + +### Python 示例 + +```python +import requests +import json + +def translate(text, source_lang='auto', target_lang='zh'): + url = 'https://dplx.xi-xu.me/translate' + data = { + 'text': text, + 'source_lang': source_lang, + 'target_lang': target_lang + } + + response = requests.post(url, json=data) + result = response.json() + + if result['code'] == 200: + return result['data'] + else: + raise Exception(f"翻译失败: {result.get('message', '未知错误')}") + +# 使用示例 +try: + result = translate('Hello, world!', 'en', 'zh') + print(result) +except Exception as e: + print(f"错误: {e}") +``` + +## API 客户端集成 + +配置 API 客户端以使用预部署实例: + +### [沉浸式翻译](https://immersivetranslate.com/zh-Hans/)(闭源浏览器扩展) + +1. [安装沉浸式翻译](https://immersivetranslate.com/zh-Hans/download/) +2. 进入开发者设置并开启 beta 测试特性 +3. 进入翻译服务添加自定义翻译服务 DeepLX,将 API URL 设置为:`https://dplx.xi-xu.me` +4. 将每秒最大请求数和每次请求最大文本长度设置为合适的值(例如 `80` 和 `5000`),以确保稳定性和性能 + +### [Pot](https://github.com/pot-app/pot-desktop)(开源跨平台 Windows、macOS 和 Linux 应用) + +1. [下载并安装适用于您平台的 Pot](https://github.com/pot-app/pot-desktop/releases/latest) +2. 打开 Pot 设置并导航到服务设置 +3. 将 DeepL 服务类型配置为 DeepLX,并将自定义 URL 设置为:`https://dplx.xi-xu.me` + +### [Bob](https://bobtranslate.com/)(闭源 macOS 应用) + +1. [从 Mac App Store 下载并安装 Bob](https://apps.apple.com/cn/app/id1630034110) +2. 下载并安装 [bob-plugin-deeplx](https://github.com/missuo/bob-plugin-deeplx) 插件 +3. 配置插件使用 `https://dplx.xi-xu.me` 服务地址 + +## 🚀 自部署 + +[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/xixu-me/DeepLX) + +### 前置要求 + +- Node.js 18+ +- Cloudflare Workers 账户 +- Wrangler CLI + +### 1. 克隆存储库 + +```bash +git clone https://github.com/xixu-me/DeepLX.git +cd DeepLX +``` + +### 2. 安装依赖 + +```bash +npm install +``` + +### 3. 配置环境 + +编辑 `wrangler.jsonc` 文件,更新以下配置: + +```jsonc +{ + "account_id": "你的_CLOUDFLARE_账户_ID", + "name": "你的_Worker_名称", + "vars": { + "DEBUG_MODE": "false", + "PROXY_URLS": "你的代理端点列表,用逗号分隔" + } +} +``` + +### 4. 创建 KV 命名空间 + +```bash +# 创建缓存 KV 命名空间 +npx wrangler kv namespace create "CACHE_KV" + +# 创建限流 KV 命名空间 +npx wrangler kv namespace create "RATE_LIMIT_KV" +``` + +将返回的命名空间 ID 更新到 `wrangler.jsonc` 的 `kv_namespaces` 配置中。 + +### 5. 部署到 Cloudflare Workers + +```bash +# 开发环境 +npx wrangler dev + +# 生产部署 +npx wrangler deploy +``` + +## 🔧 代理端点部署 + +为了获得最佳性能和稳定性,建议部署尽可能多的 [XDPL](https://github.com/xixu-me/XDPL) 代理端点: + +### 快速部署 XDPL + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/xixu-me/XDPL) + +### 配置代理端点 + +1. 部署多个 XDPL 实例 +2. 将部署后的 URL 添加到 DeepLX 的 `PROXY_URLS` 环境变量中: + +```jsonc +{ + "vars": { + "PROXY_URLS": "https://your-xdpl-1.vercel.app/jsonrpc,https://your-xdpl-2.vercel.app/jsonrpc,https://your-xdpl-3.vercel.app/jsonrpc,https://your-xdpl-n.vercel.app/jsonrpc" + } +} +``` + +## 📖 API 参考 + +### `/translate` + +**请求方法**:`POST` + +**请求标头**:`Content-Type: application/json` + +**请求参数**: + +| 参数 | 类型 | 说明 | 是否必要 | +| - | - | - | - | +| `text` | string | 要翻译的文本 | 是 | +| `source_lang` | string | 源语言代码 | 否,默认值 `AUTO` | +| `target_lang` | string | 目标语言代码 | 否,默认值 `EN` | + +**响应**: + +```json +{ + "code": 200, + "data": "翻译结果", + "id": "随机标识符", + "source_lang": "检测到的源语言代码", + "target_lang": "目标语言代码" +} +``` + +**支持的语言代码**: + +- `AUTO` - 自动检测(仅作为源语言) +- `AR` - 阿拉伯语 +- `BG` - 保加利亚语 +- `CS` - 捷克语 +- `DA` - 丹麦语 +- `DE` - 德语 +- `EL` - 希腊语 +- `EN` - 英语 +- `ES` - 西班牙语 +- `ET` - 爱沙尼亚语 +- `FI` - 芬兰语 +- `FR` - 法语 +- `HE` - 希伯来语 +- `HU` - 匈牙利语 +- `ID` - 印尼语 +- `IT` - 意大利语 +- `JA` - 日语 +- `KO` - 韩语 +- `LT` - 立陶宛语 +- `LV` - 拉脱维亚语 +- `NB` - 挪威博克马尔语 +- `NL` - 荷兰语 +- `PL` - 波兰语 +- `PT` - 葡萄牙语 +- `RO` - 罗马尼亚语 +- `RU` - 俄语 +- `SK` - 斯洛伐克语 +- `SL` - 斯洛文尼亚语 +- `SV` - 瑞典语 +- `TH` - 泰语 +- `TR` - 土耳其语 +- `UK` - 乌克兰语 +- `VI` - 越南语 +- `ZH` - 汉语 + +最新的语言支持列表请参考[支持的语言 - DeepL 文档](https://developers.deepl.com/docs/getting-started/supported-languages#translation-source-languages)。 + +### `/debug`(仅在 `DEBUG_MODE=true` 时可用) + +**请求方法**:`POST` + +用于验证请求格式和排查问题。 + +### 错误代码 + +| 代码 | 说明 | +|------|------| +| 200 | 翻译成功 | +| 400 | 请求参数错误 | +| 429 | 请求频率过高 | +| 500 | 服务器内部错误 | +| 503 | 服务暂时不可用 | + +## ⚙️ 配置说明 + +### 环境变量 + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| `DEBUG_MODE` | 调试模式开关 | `false` | +| `PROXY_URLS` | 代理端点列表,逗号分隔 | 无 | + +### 性能配置 + +可在 `src/lib/config.ts` 中调整: + +```typescript +// 请求超时时间 +export const REQUEST_TIMEOUT = 10000; // 10秒 + +// 重试配置 +export const DEFAULT_RETRY_CONFIG = { + maxRetries: 3, // 最大重试次数 + initialDelay: 1000, // 初始延迟 + backoffFactor: 2, // 退避因子 +}; + +// 限流配置 +export const RATE_LIMIT_CONFIG = { + PROXY_TOKENS_PER_SECOND: 8, // 每代理每秒令牌数 + PROXY_MAX_TOKENS: 16, // 代理最大令牌数 + BASE_TOKENS_PER_MINUTE: 480, // 基础每分钟令牌数 +}; + +// 负载限制 +export const PAYLOAD_LIMITS = { + MAX_TEXT_LENGTH: 5000, // 最大文本长度 + MAX_REQUEST_SIZE: 32768, // 最大请求大小 +}; +``` + +## 🧪 测试 + +```bash +# 运行所有测试 +npm test + +# 运行单元测试 +npm run test:unit + +# 运行集成测试 +npm run test:integration + +# 运行性能测试 +npm run test:performance + +# 生成覆盖率报告 +npm run test:coverage +``` + +## 🔧 故障排除 + +### 常见问题 + +#### 1. HTTP 429 错误仍然频繁出现 + +- 检查代理端点配置是否正确 +- 增加代理端点数量 +- 调整限流配置 + +#### 2. 翻译结果不准确 + +- 确认源语言检测正确 +- 检查文本编码是否正确 +- 验证语言代码格式 + +#### 3. 部署失败 + +- 检查 Cloudflare 账户配置 +- 验证 KV 命名空间是否创建 +- 确认 wrangler.jsonc 配置正确 + +### 调试模式 + +启用调试模式获取详细信息: + +```jsonc +{ + "vars": { + "DEBUG_MODE": "true" + } +} +``` + +然后使用调试端点: + +```bash +curl -X POST https://your-domain.workers.dev/debug \ + -H "Content-Type: application/json" \ + -d '{"text": "test", "source_lang": "EN", "target_lang": "ZH"}' +``` + +## 🙏 致谢 + +- [OwO-Network/DeepLX](https://github.com/OwO-Network/DeepLX) - 原始实现,基于 Go 编程语言 +- [Cloudflare Workers](https://workers.cloudflare.com/) - 托管平台 +- [Hono](https://hono.dev/) - 快速 Web 框架 +- [XDPL](https://github.com/xixu-me/XDPL) - 代理端点解决方案 + +## 🤝 贡献 + +我们欢迎各种形式的贡献!请查看[贡献指南](CONTRIBUTING.md)了解如何参与存储库开发。 + +1. **报告问题**: 使用 [issue 模板](https://github.com/xixu-me/DeepLX/issues/new/choose)报告 bug 或提出功能请求 +2. **提交代码**: fork 存储库,创建功能分支,提交 pull request +3. **改进文档**: 修正错误、添加示例、完善说明 +4. **测试反馈**: 在不同环境下测试并提供反馈 + +## 🌟 Star 历史 + +[![Star History Chart](https://api.star-history.com/svg?repos=xixu-me/DeepLX&type=Date)](https://star-history.com/#xixu-me/DeepLX&Date) + +## 📞 联系方式 + +- **作者**: [Xi Xu](https://xi-xu.me) +- **邮箱**: [联系邮箱](mailto:contact@xi-xu.me) +- **赞助**: [赞助链接](https://xi-xu.me/#sponsorships) + +## ⚠️ 免责声明 + +本存储库仅供学习和研究目的使用。使用本存储库时,请遵守以下条款: + +### 使用条款 + +1. **合规使用**:用户有责任确保使用本存储库符合当地法律法规和相关服务条款 +2. **商业使用**:商业使用前请确认是否符合 DeepL 的服务条款和使用政策 +3. **服务稳定性**:本存储库依赖第三方服务,不保证 100% 的服务可用性 +4. **数据隐私**:翻译内容会通过第三方服务处理,请勿翻译敏感或机密信息 + +### 责任限制 + +- 作者不对使用本存储库造成的任何直接或间接损失承担责任 +- 用户应自行承担使用风险,包括但不限于服务中断、数据丢失等 +- 本存储库不提供任何形式的担保,包括适销性、特定用途适用性等 + +### 服务条款 + +使用本存储库即表示您同意: + +- 不将本存储库用于任何非法或有害目的 +- 不滥用服务或进行恶意攻击 +- 遵守合理使用原则,避免对服务造成过度负载 + +**请在充分理解并同意上述条款后使用本存储库。** + +## 📝 许可证 + +本存储库采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。 + +--- + +
+ +**如果这个存储库对您有帮助,请考虑给它一个 ⭐ Star!** + +Made with ❤️ by [Xi Xu](https://xi-xu.me) + +
diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..13a1fb8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,278 @@ +# Security Policy + +## Overview + +DeepLX is a serverless translation API built on Cloudflare Workers that prioritizes security through multiple layers of protection. This document outlines our security practices, vulnerability reporting procedures, and security considerations for users. + +## Supported Versions + +We provide security updates for the following versions: + +| Version | Supported | +| ------- | ------------------ | +| 1.0.x | :white_check_mark: | +| < 1.0 | :x: | + +## Security Features + +### 🛡️ Built-in Security Measures + +#### 1. Input Validation & Sanitization + +- **Text Length Limits**: Maximum 5,000 characters per translation request +- **Language Code Validation**: Strict validation of language codes (2-5 characters, alphanumeric and hyphens only) +- **Request Size Limits**: Maximum 32KB request payload +- **Parameter Type Checking**: Strict type validation for all input parameters + +#### 2. Rate Limiting & DDoS Protection + +- **Multi-layer Rate Limiting**: + - Per-proxy endpoint: 8 requests/second + - Global rate limiting with token bucket algorithm + - Client IP-based rate limiting +- **Circuit Breaker**: Automatic failover when endpoints become unavailable +- **Cloudflare Protection**: Built-in DDoS protection and bot mitigation + +#### 3. Security Headers + +All responses include comprehensive security headers: + +```http +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +X-XSS-Protection: 1; mode=block +Referrer-Policy: strict-origin-when-cross-origin +Permissions-Policy: geolocation=(), microphone=(), camera=() +Content-Security-Policy: default-src 'self'; script-src 'none'; object-src 'none'; +``` + +#### 4. CORS Configuration + +- Configurable CORS policies +- Preflight request handling +- Secure cross-origin resource sharing + +#### 5. IP Address Validation + +- Trusted IP extraction from Cloudflare headers (`CF-Connecting-IP`) +- Fallback to `X-Forwarded-For` with validation +- IPv4 and IPv6 address validation +- Protection against IP spoofing + +### 🔒 Data Protection + +#### 1. Data Handling + +- **No Persistent Storage**: Translation requests are not permanently stored +- **Temporary Caching**: Results cached for performance (configurable TTL) +- **Memory Management**: Automatic cache cleanup every 5 minutes +- **No Logging of Sensitive Data**: Translation content is not logged + +#### 2. Privacy Considerations + +- **Client IP Handling**: IPs used only for rate limiting, not stored permanently +- **Request Sanitization**: All input parameters are sanitized before processing +- **Error Response Sanitization**: Sensitive information never exposed in error messages + +### 🚨 Debug Mode Security + +The debug endpoint (`/debug`) includes additional security measures: + +- **Production Disabled**: Automatically disabled unless `DEBUG_MODE=true` +- **Limited Information**: Only shows sanitized request structure +- **No Sensitive Data**: Never exposes API keys, tokens, or raw translation content + +## Security Best Practices for Users + +### 🔧 Deployment Security + +#### 1. Environment Configuration + +```jsonc +{ + "vars": { + "DEBUG_MODE": "false", // Always false in production + "PROXY_URLS": "https://your-secure-endpoints.com/jsonrpc" + } +} +``` + +#### 2. Proxy Endpoint Security + +- Use HTTPS-only endpoints +- Regularly rotate proxy endpoints +- Monitor proxy endpoint health +- Implement endpoint-specific rate limiting + +#### 3. Access Control + +- Restrict API access using Cloudflare Access (if needed) +- Implement custom authentication for sensitive use cases +- Monitor usage patterns for anomalies + +### 📊 Monitoring & Logging + +#### 1. Security Monitoring + +- Monitor rate limit violations +- Track unusual request patterns +- Set up alerts for service degradation +- Regular security audits of proxy endpoints + +#### 2. Recommended Logging + +```javascript +// Example: Custom logging for security events +if (rateLimitExceeded) { + console.log(`Rate limit exceeded for IP: ${hashedIP}`); +} +``` + +## Vulnerability Reporting + +### 🚨 Reporting Security Issues + +We take security vulnerabilities seriously. If you discover a security issue, please follow these steps: + +#### 1. **DO NOT** create a public GitHub issue + +#### 2. Send details to: [contact@xi-xu.me](mailto:contact@xi-xu.me) + +#### 3. Include the following information + +``` +Subject: [SECURITY] DeepLX Vulnerability Report + +- Description of the vulnerability +- Steps to reproduce the issue +- Potential impact assessment +- Suggested fix (if available) +- Your contact information +``` + +### 🕐 Response Timeline + +- **Initial Response**: Within 48 hours +- **Vulnerability Assessment**: Within 7 days +- **Fix Development**: Within 14 days (depending on severity) +- **Public Disclosure**: After fix deployment (coordinated disclosure) + +### 🏆 Recognition + +We appreciate security researchers who help improve DeepLX security: + +- Public acknowledgment (with permission) +- Credit in release notes +- Priority support for future issues + +## Security Considerations + +### ⚠️ Known Limitations + +#### 1. Third-party Dependencies + +- **Proxy Endpoints**: Security depends on proxy endpoint implementations +- **Cloudflare Workers**: Subject to Cloudflare's security model +- **External Translation Services**: Data passes through third-party services + +#### 2. Rate Limiting Bypass + +- **Distributed Attacks**: Multiple IPs can potentially bypass rate limits +- **Proxy Rotation**: Attackers might use multiple proxy endpoints + +#### 3. Cache Poisoning + +- **Cache Key Collision**: Theoretical risk of cache key collisions +- **Memory Cache**: In-memory cache vulnerable to memory-based attacks + +### 🛠️ Mitigation Strategies + +#### 1. Enhanced Rate Limiting + +```typescript +// Example: Additional rate limiting layers +const ENHANCED_RATE_LIMITS = { + PER_IP_PER_MINUTE: 60, + PER_IP_PER_HOUR: 1000, + GLOBAL_PER_SECOND: 100 +}; +``` + +#### 2. Request Validation + +```typescript +// Example: Enhanced input validation +function validateTranslationRequest(params: any): boolean { + return ( + params.text && + typeof params.text === 'string' && + params.text.length <= MAX_TEXT_LENGTH && + !containsSuspiciousPatterns(params.text) + ); +} +``` + +## Compliance & Standards + +### 📋 Security Standards + +- **OWASP Top 10**: Protection against common web vulnerabilities +- **CSP**: Content Security Policy implementation +- **HTTPS Only**: All communications encrypted in transit +- **Input Validation**: Following OWASP input validation guidelines + +### 🌍 Privacy Compliance + +- **GDPR Considerations**: No personal data storage by default +- **Data Minimization**: Only necessary data processed +- **Right to Erasure**: Cache expiration ensures data removal + +## Security Updates + +### 📢 Update Notifications + +- Security updates announced via GitHub releases +- Critical vulnerabilities communicated via email (if contact provided) +- Security advisories published for high-severity issues + +### 🔄 Update Process + +1. **Assessment**: Evaluate security impact +2. **Development**: Create and test security fix +3. **Testing**: Comprehensive security testing +4. **Deployment**: Coordinated release deployment +5. **Communication**: Public disclosure after fix deployment + +## Additional Resources + +### 📚 Security Documentation + +- [Cloudflare Workers Security](https://developers.cloudflare.com/workers/platform/security/) +- [OWASP API Security Top 10](https://owasp.org/www-project-api-security/) +- [Content Security Policy Guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) + +### 🔧 Security Tools + +- [Security Headers Checker](https://securityheaders.com/) +- [SSL Labs SSL Test](https://www.ssllabs.com/ssltest/) +- [OWASP ZAP](https://owasp.org/www-project-zap/) for security testing + +### 📞 Emergency Contact + +For critical security issues requiring immediate attention: + +- **Email**: [contact@xi-xu.me](mailto:contact@xi-xu.me) +- **Subject**: `[URGENT SECURITY] DeepLX Critical Vulnerability` + +--- + +## Disclaimer + +While we implement comprehensive security measures, users should: + +- **Avoid Sensitive Data**: Don't translate confidential or sensitive information +- **Monitor Usage**: Regularly review API usage patterns +- **Keep Updated**: Use the latest version of DeepLX +- **Follow Best Practices**: Implement additional security measures as needed + +**Remember**: Security is a shared responsibility. While we provide a secure foundation, proper deployment and usage practices are essential for maintaining security. diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..60bef1b --- /dev/null +++ b/jest.config.js @@ -0,0 +1,27 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + roots: ["/src", "/tests"], + testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"], + transform: { + "^.+\\.ts$": "ts-jest", + }, + collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts", "!src/**/index.ts"], + coverageDirectory: "coverage", + coverageReporters: ["text", "lcov", "html"], + setupFilesAfterEnv: ["/tests/setup.ts"], + moduleNameMapping: { + "^@/(.*)$": "/src/$1", + }, + testTimeout: 30000, + globals: { + "ts-jest": { + tsconfig: { + target: "ES2020", + module: "commonjs", + lib: ["ES2020", "WebWorker"], + types: ["@cloudflare/workers-types", "jest"], + }, + }, + }, +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a64823b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4546 @@ +{ + "lockfileVersion": 3, + "name": "deeplx", + "packages": { + "": { + "dependencies": { + "hono": "^4.6.14" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250722.0", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "ts-jest": "^29.1.2", + "typescript": "^5.5.2", + "wrangler": "^4.25.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "license": "MIT", + "name": "deeplx", + "version": "1.0.0" + }, + "node_modules/@ampproject/remapping": { + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "dev": true, + "engines": { + "node": ">=6.0.0" + }, + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "version": "2.3.0" + }, + "node_modules/@ampproject/remapping/node_modules/@jridgewell/trace-mapping": { + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + }, + "dev": true, + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "version": "0.3.29" + }, + "node_modules/@babel/code-frame": { + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "version": "7.27.1" + }, + "node_modules/@babel/compat-data": { + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "version": "7.28.0" + }, + "node_modules/@babel/core": { + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + }, + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "version": "7.28.0" + }, + "node_modules/@babel/core/node_modules/semver": { + "bin": { + "semver": "bin/semver.js" + }, + "dev": true, + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "version": "6.3.1" + }, + "node_modules/@babel/generator": { + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "version": "7.28.0" + }, + "node_modules/@babel/generator/node_modules/@jridgewell/trace-mapping": { + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + }, + "dev": true, + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "version": "0.3.29" + }, + "node_modules/@babel/helper-compilation-targets": { + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "version": "7.27.2" + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "bin": { + "semver": "bin/semver.js" + }, + "dev": true, + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "version": "6.3.1" + }, + "node_modules/@babel/helper-globals": { + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "version": "7.28.0" + }, + "node_modules/@babel/helper-module-imports": { + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "version": "7.27.1" + }, + "node_modules/@babel/helper-module-transforms": { + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0" + }, + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "version": "7.27.3" + }, + "node_modules/@babel/helper-plugin-utils": { + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "version": "7.27.1" + }, + "node_modules/@babel/helper-string-parser": { + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "version": "7.27.1" + }, + "node_modules/@babel/helper-validator-identifier": { + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "version": "7.27.1" + }, + "node_modules/@babel/helper-validator-option": { + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "version": "7.27.1" + }, + "node_modules/@babel/helpers": { + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2" + }, + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz", + "version": "7.28.2" + }, + "node_modules/@babel/parser": { + "bin": { + "parser": "bin/babel-parser.js" + }, + "dependencies": { + "@babel/types": "^7.28.0" + }, + "dev": true, + "engines": { + "node": ">=6.0.0" + }, + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "version": "7.28.0" + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "dev": true, + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "version": "7.8.4" + }, + "node_modules/@babel/plugin-syntax-bigint": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "dev": true, + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "version": "7.8.3" + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "dev": true, + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "version": "7.12.13" + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "version": "7.14.5" + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "version": "7.27.1" + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "dev": true, + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "version": "7.10.4" + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "dev": true, + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "version": "7.8.3" + }, + "node_modules/@babel/plugin-syntax-jsx": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "version": "7.27.1" + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "dev": true, + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "version": "7.10.4" + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "dev": true, + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "version": "7.8.3" + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "dev": true, + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "version": "7.10.4" + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "dev": true, + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "version": "7.8.3" + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "dev": true, + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "version": "7.8.3" + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "dev": true, + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "version": "7.8.3" + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "version": "7.14.5" + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "version": "7.14.5" + }, + "node_modules/@babel/plugin-syntax-typescript": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "version": "7.27.1" + }, + "node_modules/@babel/template": { + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "version": "7.27.2" + }, + "node_modules/@babel/traverse": { + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "version": "7.28.0" + }, + "node_modules/@babel/types": { + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "version": "7.28.2" + }, + "node_modules/@bcoe/v8-coverage": { + "dev": true, + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "version": "0.2.3" + }, + "node_modules/@cloudflare/kv-asset-handler": { + "dependencies": { + "mime": "^3.0.0" + }, + "dev": true, + "engines": { + "node": ">=18.0.0" + }, + "integrity": "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==", + "license": "MIT OR Apache-2.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz", + "version": "0.4.0" + }, + "node_modules/@cloudflare/unenv-preset": { + "dev": true, + "integrity": "sha512-/M3MEcj3V2WHIRSW1eAQBPRJ6JnGQHc6JKMAPLkDb7pLs3m6X9ES/+K3ceGqxI6TKeF32AWAi7ls0AYzVxCP0A==", + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.17", + "workerd": "^1.20250508.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + }, + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.3.3.tgz", + "version": "2.3.3" + }, + "node_modules/@cloudflare/workerd-windows-64": { + "cpu": [ + "x64" + ], + "dev": true, + "engines": { + "node": ">=16" + }, + "integrity": "sha512-qS8H5RCYwE21Om9wo5/F807ClBJIfknhuLBj16eYxvJcj9JqgAKWi12BGgjyGxHuJJjeoQ63lr4wHAdbFntDDg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250712.0.tgz", + "version": "1.20250712.0" + }, + "node_modules/@cloudflare/workers-types": { + "dev": true, + "integrity": "sha512-pTY+A07DTSacgUBYcVEEb78/KG7THdcRpPqXLeH/A/LHHobAddgN4zyXBldsoZuzy7bD9tZYJW+wkcyR4k7fDA==", + "license": "MIT OR Apache-2.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250722.0.tgz", + "version": "4.20250722.0" + }, + "node_modules/@cspotcode/source-map-support": { + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "dev": true, + "engines": { + "node": ">=12" + }, + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "version": "0.8.1" + }, + "node_modules/@img/sharp-win32-x64": { + "cpu": [ + "x64" + ], + "dev": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "version": "0.33.5" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "version": "1.1.0" + }, + "node_modules/@istanbuljs/schema": { + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "version": "0.1.3" + }, + "node_modules/@jest/console": { + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/@jest/core": { + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "license": "MIT", + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + }, + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/@jest/environment": { + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/@jest/expect": { + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/@jest/expect-utils": { + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/@jest/fake-timers": { + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/@jest/globals": { + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/@jest/reporters": { + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "license": "MIT", + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + }, + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/@jest/reporters/node_modules/@jridgewell/trace-mapping": { + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + }, + "dev": true, + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "version": "0.3.29" + }, + "node_modules/@jest/schemas": { + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "version": "29.6.3" + }, + "node_modules/@jest/source-map": { + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "version": "29.6.3" + }, + "node_modules/@jest/source-map/node_modules/@jridgewell/trace-mapping": { + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + }, + "dev": true, + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "version": "0.3.29" + }, + "node_modules/@jest/test-result": { + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/@jest/test-sequencer": { + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/@jest/transform": { + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/@jest/transform/node_modules/@jridgewell/trace-mapping": { + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + }, + "dev": true, + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "version": "0.3.29" + }, + "node_modules/@jest/types": { + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "version": "29.6.3" + }, + "node_modules/@jridgewell/gen-mapping": { + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "dev": true, + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "version": "0.3.12" + }, + "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + }, + "dev": true, + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "version": "0.3.29" + }, + "node_modules/@jridgewell/resolve-uri": { + "dev": true, + "engines": { + "node": ">=6.0.0" + }, + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "version": "3.1.2" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "dev": true, + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "version": "1.5.4" + }, + "node_modules/@jridgewell/trace-mapping": { + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + }, + "dev": true, + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "version": "0.3.9" + }, + "node_modules/@poppinss/colors": { + "dependencies": { + "kleur": "^4.1.5" + }, + "dev": true, + "integrity": "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.5.tgz", + "version": "4.1.5" + }, + "node_modules/@poppinss/dumper": { + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + }, + "dev": true, + "integrity": "sha512-iG0TIdqv8xJ3Lt9O8DrPRxw1MRLjNpoqiSGU03P/wNLP/s0ra0udPJ1J2Tx5M0J3H/cVyEgpbn8xUKRY9j59kQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.4.tgz", + "version": "0.6.4" + }, + "node_modules/@poppinss/exception": { + "dev": true, + "integrity": "sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.2.tgz", + "version": "1.2.2" + }, + "node_modules/@sinclair/typebox": { + "dev": true, + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "version": "0.27.8" + }, + "node_modules/@sindresorhus/is": { + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + }, + "integrity": "sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.0.2.tgz", + "version": "7.0.2" + }, + "node_modules/@sinonjs/commons": { + "dependencies": { + "type-detect": "4.0.8" + }, + "dev": true, + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "version": "3.0.1" + }, + "node_modules/@sinonjs/fake-timers": { + "dependencies": { + "@sinonjs/commons": "^3.0.0" + }, + "dev": true, + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "version": "10.3.0" + }, + "node_modules/@speed-highlight/core": { + "dev": true, + "integrity": "sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==", + "license": "CC0-1.0", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.7.tgz", + "version": "1.2.7" + }, + "node_modules/@types/babel__core": { + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + }, + "dev": true, + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "version": "7.20.5" + }, + "node_modules/@types/babel__generator": { + "dependencies": { + "@babel/types": "^7.0.0" + }, + "dev": true, + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "version": "7.27.0" + }, + "node_modules/@types/babel__template": { + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + }, + "dev": true, + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "version": "7.4.4" + }, + "node_modules/@types/babel__traverse": { + "dependencies": { + "@babel/types": "^7.20.7" + }, + "dev": true, + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "version": "7.20.7" + }, + "node_modules/@types/graceful-fs": { + "dependencies": { + "@types/node": "*" + }, + "dev": true, + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "version": "4.1.9" + }, + "node_modules/@types/istanbul-lib-coverage": { + "dev": true, + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "version": "2.0.6" + }, + "node_modules/@types/istanbul-lib-report": { + "dependencies": { + "@types/istanbul-lib-coverage": "*" + }, + "dev": true, + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "version": "3.0.3" + }, + "node_modules/@types/istanbul-reports": { + "dependencies": { + "@types/istanbul-lib-report": "*" + }, + "dev": true, + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "version": "3.0.4" + }, + "node_modules/@types/jest": { + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + }, + "dev": true, + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "version": "29.5.14" + }, + "node_modules/@types/node": { + "dependencies": { + "undici-types": "~7.8.0" + }, + "dev": true, + "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", + "version": "24.1.0" + }, + "node_modules/@types/stack-utils": { + "dev": true, + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "version": "2.0.3" + }, + "node_modules/@types/yargs": { + "dependencies": { + "@types/yargs-parser": "*" + }, + "dev": true, + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "version": "17.0.33" + }, + "node_modules/@types/yargs-parser": { + "dev": true, + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "version": "21.0.3" + }, + "node_modules/acorn": { + "bin": { + "acorn": "bin/acorn" + }, + "dev": true, + "engines": { + "node": ">=0.4.0" + }, + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "version": "8.14.0" + }, + "node_modules/acorn-walk": { + "dev": true, + "engines": { + "node": ">=0.4.0" + }, + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "version": "8.3.2" + }, + "node_modules/ansi-escapes": { + "dependencies": { + "type-fest": "^0.21.3" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "version": "4.3.2" + }, + "node_modules/ansi-regex": { + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "version": "5.0.1" + }, + "node_modules/ansi-styles": { + "dependencies": { + "color-convert": "^2.0.1" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + }, + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "version": "4.3.0" + }, + "node_modules/anymatch": { + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "dev": true, + "engines": { + "node": ">= 8" + }, + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "version": "3.1.3" + }, + "node_modules/argparse": { + "dependencies": { + "sprintf-js": "~1.0.2" + }, + "dev": true, + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "version": "1.0.10" + }, + "node_modules/async": { + "dev": true, + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "version": "3.2.6" + }, + "node_modules/babel-jest": { + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.8.0" + }, + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/babel-plugin-istanbul": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "version": "6.1.1" + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "version": "5.2.1" + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "bin": { + "semver": "bin/semver.js" + }, + "dev": true, + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "version": "6.3.1" + }, + "node_modules/babel-plugin-jest-hoist": { + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "version": "29.6.3" + }, + "node_modules/babel-preset-current-node-syntax": { + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "dev": true, + "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0" + }, + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", + "version": "1.1.0" + }, + "node_modules/babel-preset-jest": { + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0" + }, + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "version": "29.6.3" + }, + "node_modules/balanced-match": { + "dev": true, + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "version": "1.0.2" + }, + "node_modules/blake3-wasm": { + "dev": true, + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "version": "2.1.5" + }, + "node_modules/brace-expansion": { + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + }, + "dev": true, + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "version": "1.1.12" + }, + "node_modules/braces": { + "dependencies": { + "fill-range": "^7.1.1" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "version": "3.0.3" + }, + "node_modules/browserslist": { + "bin": { + "browserslist": "cli.js" + }, + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "dev": true, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + }, + "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" + } + ], + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "version": "4.25.1" + }, + "node_modules/bs-logger": { + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "dev": true, + "engines": { + "node": ">= 6" + }, + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "version": "0.2.6" + }, + "node_modules/bser": { + "dependencies": { + "node-int64": "^0.4.0" + }, + "dev": true, + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "version": "2.1.1" + }, + "node_modules/buffer-from": { + "dev": true, + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "version": "1.1.2" + }, + "node_modules/callsites": { + "dev": true, + "engines": { + "node": ">=6" + }, + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "version": "3.1.0" + }, + "node_modules/camelcase": { + "dev": true, + "engines": { + "node": ">=6" + }, + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "version": "5.3.1" + }, + "node_modules/caniuse-lite": { + "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" + } + ], + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "license": "CC-BY-4.0", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "version": "1.0.30001727" + }, + "node_modules/chalk": { + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + }, + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "version": "4.1.2" + }, + "node_modules/chalk/node_modules/supports-color": { + "dependencies": { + "has-flag": "^4.0.0" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "version": "7.2.0" + }, + "node_modules/char-regex": { + "dev": true, + "engines": { + "node": ">=10" + }, + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "version": "1.0.2" + }, + "node_modules/ci-info": { + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "version": "3.9.0" + }, + "node_modules/cjs-module-lexer": { + "dev": true, + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "version": "1.4.3" + }, + "node_modules/cliui": { + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "dev": true, + "engines": { + "node": ">=12" + }, + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "version": "8.0.1" + }, + "node_modules/co": { + "dev": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + }, + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "version": "4.6.0" + }, + "node_modules/collect-v8-coverage": { + "dev": true, + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "version": "1.0.2" + }, + "node_modules/color": { + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "dev": true, + "engines": { + "node": ">=12.5.0" + }, + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "version": "4.2.3" + }, + "node_modules/color-convert": { + "dependencies": { + "color-name": "~1.1.4" + }, + "dev": true, + "engines": { + "node": ">=7.0.0" + }, + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/color-name": { + "dev": true, + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "version": "1.1.4" + }, + "node_modules/color-string": { + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + }, + "dev": true, + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "version": "1.9.1" + }, + "node_modules/concat-map": { + "dev": true, + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "version": "0.0.1" + }, + "node_modules/convert-source-map": { + "dev": true, + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "version": "2.0.0" + }, + "node_modules/cookie": { + "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "version": "1.0.2" + }, + "node_modules/create-jest": { + "bin": { + "create-jest": "bin/create-jest.js" + }, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/cross-spawn": { + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "dev": true, + "engines": { + "node": ">= 8" + }, + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "version": "7.0.6" + }, + "node_modules/debug": { + "dependencies": { + "ms": "^2.1.3" + }, + "dev": true, + "engines": { + "node": ">=6.0" + }, + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + }, + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "version": "4.4.1" + }, + "node_modules/dedent": { + "dev": true, + "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + }, + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", + "version": "1.6.0" + }, + "node_modules/deepmerge": { + "dev": true, + "engines": { + "node": ">=0.10.0" + }, + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "version": "4.3.1" + }, + "node_modules/defu": { + "dev": true, + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "version": "6.1.4" + }, + "node_modules/detect-libc": { + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "version": "2.0.4" + }, + "node_modules/detect-newline": { + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "version": "3.1.0" + }, + "node_modules/diff-sequences": { + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "version": "29.6.3" + }, + "node_modules/ejs": { + "bin": { + "ejs": "bin/cli.js" + }, + "dependencies": { + "jake": "^10.8.5" + }, + "dev": true, + "engines": { + "node": ">=0.10.0" + }, + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "version": "3.1.10" + }, + "node_modules/electron-to-chromium": { + "dev": true, + "integrity": "sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.190.tgz", + "version": "1.5.190" + }, + "node_modules/emittery": { + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + }, + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "version": "0.13.1" + }, + "node_modules/emoji-regex": { + "dev": true, + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "version": "8.0.0" + }, + "node_modules/error-ex": { + "dependencies": { + "is-arrayish": "^0.2.1" + }, + "dev": true, + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "version": "1.3.2" + }, + "node_modules/error-ex/node_modules/is-arrayish": { + "dev": true, + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "version": "0.2.1" + }, + "node_modules/error-stack-parser-es": { + "dev": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "version": "1.0.5" + }, + "node_modules/escalade": { + "dev": true, + "engines": { + "node": ">=6" + }, + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "version": "3.2.0" + }, + "node_modules/escape-string-regexp": { + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "version": "2.0.0" + }, + "node_modules/esprima": { + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "dev": true, + "engines": { + "node": ">=4" + }, + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "version": "4.0.1" + }, + "node_modules/execa": { + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + }, + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "version": "5.1.1" + }, + "node_modules/exit": { + "dev": true, + "engines": { + "node": ">= 0.8.0" + }, + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "version": "0.1.2" + }, + "node_modules/exit-hook": { + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "version": "2.2.1" + }, + "node_modules/expect": { + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/exsolve": { + "dev": true, + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "version": "1.0.7" + }, + "node_modules/fast-json-stable-stringify": { + "dev": true, + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "version": "2.1.0" + }, + "node_modules/fb-watchman": { + "dependencies": { + "bser": "2.1.1" + }, + "dev": true, + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "version": "2.0.2" + }, + "node_modules/filelist": { + "dependencies": { + "minimatch": "^5.0.1" + }, + "dev": true, + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "version": "1.0.4" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "dependencies": { + "balanced-match": "^1.0.0" + }, + "dev": true, + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "version": "2.0.2" + }, + "node_modules/filelist/node_modules/minimatch": { + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "dev": true, + "engines": { + "node": ">=10" + }, + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "version": "5.1.6" + }, + "node_modules/fill-range": { + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "version": "7.1.1" + }, + "node_modules/find-up": { + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "version": "4.1.0" + }, + "node_modules/fs.realpath": { + "dev": true, + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "version": "1.0.0" + }, + "node_modules/fsevents": { + "dev": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + }, + "hasInstallScript": true, + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "version": "2.3.3" + }, + "node_modules/function-bind": { + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + }, + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "version": "1.1.2" + }, + "node_modules/gensync": { + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "version": "1.0.0-beta.2" + }, + "node_modules/get-caller-file": { + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + }, + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "version": "2.0.5" + }, + "node_modules/get-package-type": { + "dev": true, + "engines": { + "node": ">=8.0.0" + }, + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "version": "0.1.0" + }, + "node_modules/get-stream": { + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "version": "6.0.1" + }, + "node_modules/glob": { + "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" + }, + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "version": "7.2.3" + }, + "node_modules/glob-to-regexp": { + "dev": true, + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "version": "0.4.1" + }, + "node_modules/graceful-fs": { + "dev": true, + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "version": "4.2.11" + }, + "node_modules/has-flag": { + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "version": "4.0.0" + }, + "node_modules/hasown": { + "dependencies": { + "function-bind": "^1.1.2" + }, + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "version": "2.0.2" + }, + "node_modules/hono": { + "engines": { + "node": ">=16.9.0" + }, + "integrity": "sha512-Up2cQbtNz1s111qpnnECdTGqSIUIhZJMLikdKkshebQSEBcoUKq6XJayLGqSZWidiH0zfHRCJqFu062Mz5UuRA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.8.5.tgz", + "version": "4.8.5" + }, + "node_modules/html-escaper": { + "dev": true, + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "version": "2.0.2" + }, + "node_modules/human-signals": { + "dev": true, + "engines": { + "node": ">=10.17.0" + }, + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "version": "2.1.0" + }, + "node_modules/import-local": { + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "version": "3.2.0" + }, + "node_modules/imurmurhash": { + "dev": true, + "engines": { + "node": ">=0.8.19" + }, + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "version": "0.1.4" + }, + "node_modules/inflight": { + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + }, + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "version": "1.0.6" + }, + "node_modules/inherits": { + "dev": true, + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "version": "2.0.4" + }, + "node_modules/is-arrayish": { + "dev": true, + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "version": "0.3.2" + }, + "node_modules/is-core-module": { + "dependencies": { + "hasown": "^2.0.2" + }, + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + }, + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "version": "2.16.1" + }, + "node_modules/is-fullwidth-code-point": { + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "version": "3.0.0" + }, + "node_modules/is-generator-fn": { + "dev": true, + "engines": { + "node": ">=6" + }, + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "version": "2.1.0" + }, + "node_modules/is-number": { + "dev": true, + "engines": { + "node": ">=0.12.0" + }, + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "version": "7.0.0" + }, + "node_modules/is-stream": { + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "version": "2.0.1" + }, + "node_modules/isexe": { + "dev": true, + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "version": "2.0.0" + }, + "node_modules/istanbul-lib-coverage": { + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "version": "3.2.2" + }, + "node_modules/istanbul-lib-instrument": { + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "dev": true, + "engines": { + "node": ">=10" + }, + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "version": "6.0.3" + }, + "node_modules/istanbul-lib-report": { + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "dev": true, + "engines": { + "node": ">=10" + }, + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "version": "3.0.1" + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "dependencies": { + "has-flag": "^4.0.0" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "version": "7.2.0" + }, + "node_modules/istanbul-lib-source-maps": { + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "dev": true, + "engines": { + "node": ">=10" + }, + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "version": "4.0.1" + }, + "node_modules/istanbul-reports": { + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "version": "3.1.7" + }, + "node_modules/jake": { + "bin": { + "jake": "bin/cli.js" + }, + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "dev": true, + "engines": { + "node": ">=10" + }, + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", + "version": "10.9.2" + }, + "node_modules/jest": { + "bin": { + "jest": "bin/jest.js" + }, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "license": "MIT", + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + }, + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-changed-files": { + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-circus": { + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-cli": { + "bin": { + "jest": "bin/jest.js" + }, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "license": "MIT", + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + }, + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-config": { + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "license": "MIT", + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + }, + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-diff": { + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-docblock": { + "dependencies": { + "detect-newline": "^3.0.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-each": { + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-environment-node": { + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-get-type": { + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "version": "29.6.3" + }, + "node_modules/jest-haste-map": { + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "license": "MIT", + "optionalDependencies": { + "fsevents": "^2.3.2" + }, + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-leak-detector": { + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-matcher-utils": { + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-message-util": { + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-mock": { + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-pnp-resolver": { + "dev": true, + "engines": { + "node": ">=6" + }, + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "license": "MIT", + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + }, + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "version": "1.2.3" + }, + "node_modules/jest-regex-util": { + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "version": "29.6.3" + }, + "node_modules/jest-resolve": { + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-resolve-dependencies": { + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-runner": { + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-runtime": { + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-snapshot": { + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-util": { + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-validate": { + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-validate/node_modules/camelcase": { + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "version": "6.3.0" + }, + "node_modules/jest-watcher": { + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-worker": { + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/jest-worker/node_modules/supports-color": { + "dependencies": { + "has-flag": "^4.0.0" + }, + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + }, + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "version": "8.1.1" + }, + "node_modules/js-tokens": { + "dev": true, + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "version": "4.0.0" + }, + "node_modules/js-yaml": { + "bin": { + "js-yaml": "bin/js-yaml.js" + }, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "dev": true, + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "version": "3.14.1" + }, + "node_modules/jsesc": { + "bin": { + "jsesc": "bin/jsesc" + }, + "dev": true, + "engines": { + "node": ">=6" + }, + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "version": "3.1.0" + }, + "node_modules/json-parse-even-better-errors": { + "dev": true, + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "version": "2.3.1" + }, + "node_modules/json5": { + "bin": { + "json5": "lib/cli.js" + }, + "dev": true, + "engines": { + "node": ">=6" + }, + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "version": "2.2.3" + }, + "node_modules/kleur": { + "dev": true, + "engines": { + "node": ">=6" + }, + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "version": "4.1.5" + }, + "node_modules/leven": { + "dev": true, + "engines": { + "node": ">=6" + }, + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "version": "3.1.0" + }, + "node_modules/lines-and-columns": { + "dev": true, + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "version": "1.2.4" + }, + "node_modules/locate-path": { + "dependencies": { + "p-locate": "^4.1.0" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "version": "5.0.0" + }, + "node_modules/lodash.memoize": { + "dev": true, + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "version": "4.1.2" + }, + "node_modules/lru-cache": { + "dependencies": { + "yallist": "^3.0.2" + }, + "dev": true, + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "version": "5.1.1" + }, + "node_modules/make-dir": { + "dependencies": { + "semver": "^7.5.3" + }, + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "version": "4.0.0" + }, + "node_modules/make-error": { + "dev": true, + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "version": "1.3.6" + }, + "node_modules/makeerror": { + "dependencies": { + "tmpl": "1.0.5" + }, + "dev": true, + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "version": "1.0.12" + }, + "node_modules/merge-stream": { + "dev": true, + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "version": "2.0.0" + }, + "node_modules/micromatch": { + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "dev": true, + "engines": { + "node": ">=8.6" + }, + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "version": "4.0.8" + }, + "node_modules/mime": { + "bin": { + "mime": "cli.js" + }, + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "version": "3.0.0" + }, + "node_modules/mimic-fn": { + "dev": true, + "engines": { + "node": ">=6" + }, + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "version": "2.1.0" + }, + "node_modules/miniflare": { + "bin": { + "miniflare": "bootstrap.js" + }, + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "sharp": "^0.33.5", + "stoppable": "1.1.0", + "undici": "^7.10.0", + "workerd": "1.20250712.0", + "ws": "8.18.0", + "youch": "4.1.0-beta.10", + "zod": "3.22.3" + }, + "dev": true, + "engines": { + "node": ">=18.0.0" + }, + "integrity": "sha512-o7zYqG4pMi3gQTjiGhgZ82bQfexNwK+bzAaNlu8f7m3Kra4DcU5LC9nznfq2rfIBnUnMgwtU2VUfMlN1TuI8Og==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20250712.0.tgz", + "version": "4.20250712.0" + }, + "node_modules/miniflare/node_modules/zod": { + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + }, + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "version": "3.22.3" + }, + "node_modules/minimatch": { + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "dev": true, + "engines": { + "node": "*" + }, + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "version": "3.1.2" + }, + "node_modules/ms": { + "dev": true, + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "version": "2.1.3" + }, + "node_modules/natural-compare": { + "dev": true, + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "version": "1.4.0" + }, + "node_modules/node-int64": { + "dev": true, + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "version": "0.4.0" + }, + "node_modules/node-releases": { + "dev": true, + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "version": "2.0.19" + }, + "node_modules/normalize-path": { + "dev": true, + "engines": { + "node": ">=0.10.0" + }, + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "version": "3.0.0" + }, + "node_modules/npm-run-path": { + "dependencies": { + "path-key": "^3.0.0" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "version": "4.0.1" + }, + "node_modules/ohash": { + "dev": true, + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "version": "2.0.11" + }, + "node_modules/once": { + "dependencies": { + "wrappy": "1" + }, + "dev": true, + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "version": "1.4.0" + }, + "node_modules/onetime": { + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "version": "5.1.2" + }, + "node_modules/p-limit": { + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "version": "3.1.0" + }, + "node_modules/p-locate": { + "dependencies": { + "p-limit": "^2.2.0" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "version": "4.1.0" + }, + "node_modules/p-locate/node_modules/p-limit": { + "dependencies": { + "p-try": "^2.0.0" + }, + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "version": "2.3.0" + }, + "node_modules/p-try": { + "dev": true, + "engines": { + "node": ">=6" + }, + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "version": "2.2.0" + }, + "node_modules/parse-json": { + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "version": "5.2.0" + }, + "node_modules/path-exists": { + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "version": "4.0.0" + }, + "node_modules/path-is-absolute": { + "dev": true, + "engines": { + "node": ">=0.10.0" + }, + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "version": "1.0.1" + }, + "node_modules/path-key": { + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "version": "3.1.1" + }, + "node_modules/path-parse": { + "dev": true, + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "version": "1.0.7" + }, + "node_modules/path-to-regexp": { + "dev": true, + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "version": "6.3.0" + }, + "node_modules/pathe": { + "dev": true, + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "version": "2.0.3" + }, + "node_modules/picocolors": { + "dev": true, + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "version": "1.1.1" + }, + "node_modules/picomatch": { + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + }, + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "version": "2.3.1" + }, + "node_modules/pirates": { + "dev": true, + "engines": { + "node": ">= 6" + }, + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "version": "4.0.7" + }, + "node_modules/pkg-dir": { + "dependencies": { + "find-up": "^4.0.0" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "version": "4.2.0" + }, + "node_modules/pretty-format": { + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "version": "29.7.0" + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + }, + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "version": "5.2.0" + }, + "node_modules/prompts": { + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "dev": true, + "engines": { + "node": ">= 6" + }, + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "version": "2.4.2" + }, + "node_modules/prompts/node_modules/kleur": { + "dev": true, + "engines": { + "node": ">=6" + }, + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "version": "3.0.3" + }, + "node_modules/pure-rand": { + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "version": "6.1.0" + }, + "node_modules/react-is": { + "dev": true, + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "version": "18.3.1" + }, + "node_modules/require-directory": { + "dev": true, + "engines": { + "node": ">=0.10.0" + }, + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "version": "2.1.1" + }, + "node_modules/resolve": { + "bin": { + "resolve": "bin/resolve" + }, + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + }, + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "version": "1.22.10" + }, + "node_modules/resolve-cwd": { + "dependencies": { + "resolve-from": "^5.0.0" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "version": "3.0.0" + }, + "node_modules/resolve-from": { + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "version": "5.0.0" + }, + "node_modules/resolve.exports": { + "dev": true, + "engines": { + "node": ">=10" + }, + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "version": "2.0.3" + }, + "node_modules/semver": { + "bin": { + "semver": "bin/semver.js" + }, + "dev": true, + "engines": { + "node": ">=10" + }, + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "version": "7.7.2" + }, + "node_modules/sharp": { + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "dev": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "hasInstallScript": true, + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "license": "Apache-2.0", + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + }, + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "version": "0.33.5" + }, + "node_modules/shebang-command": { + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "version": "2.0.0" + }, + "node_modules/shebang-regex": { + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "version": "3.0.0" + }, + "node_modules/signal-exit": { + "dev": true, + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "version": "3.0.7" + }, + "node_modules/simple-swizzle": { + "dependencies": { + "is-arrayish": "^0.3.1" + }, + "dev": true, + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "version": "0.2.2" + }, + "node_modules/sisteransi": { + "dev": true, + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "version": "1.0.5" + }, + "node_modules/slash": { + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "version": "3.0.0" + }, + "node_modules/source-map": { + "dev": true, + "engines": { + "node": ">=0.10.0" + }, + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "version": "0.6.1" + }, + "node_modules/source-map-support": { + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + }, + "dev": true, + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "version": "0.5.13" + }, + "node_modules/sprintf-js": { + "dev": true, + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "version": "1.0.3" + }, + "node_modules/stack-utils": { + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "dev": true, + "engines": { + "node": ">=10" + }, + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "version": "2.0.6" + }, + "node_modules/stoppable": { + "dev": true, + "engines": { + "node": ">=4", + "npm": ">=6" + }, + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "version": "1.1.0" + }, + "node_modules/string-length": { + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "dev": true, + "engines": { + "node": ">=10" + }, + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "version": "4.0.2" + }, + "node_modules/string-width": { + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "version": "4.2.3" + }, + "node_modules/strip-ansi": { + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "version": "6.0.1" + }, + "node_modules/strip-bom": { + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "version": "4.0.0" + }, + "node_modules/strip-final-newline": { + "dev": true, + "engines": { + "node": ">=6" + }, + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "version": "2.0.0" + }, + "node_modules/strip-json-comments": { + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "version": "3.1.1" + }, + "node_modules/supports-color": { + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + }, + "integrity": "sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.0.0.tgz", + "version": "10.0.0" + }, + "node_modules/supports-preserve-symlinks-flag": { + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + }, + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "version": "1.0.0" + }, + "node_modules/test-exclude": { + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "dev": true, + "engines": { + "node": ">=8" + }, + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "version": "6.0.0" + }, + "node_modules/tmpl": { + "dev": true, + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "version": "1.0.5" + }, + "node_modules/to-regex-range": { + "dependencies": { + "is-number": "^7.0.0" + }, + "dev": true, + "engines": { + "node": ">=8.0" + }, + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "version": "5.0.1" + }, + "node_modules/ts-jest": { + "bin": { + "ts-jest": "cli.js" + }, + "dependencies": { + "bs-logger": "^0.2.6", + "ejs": "^3.1.10", + "fast-json-stable-stringify": "^2.1.0", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.2", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "integrity": "sha512-d423TJMnJGu80/eSgfQ5w/R+0zFJvdtTxwtF9KzFFunOpSeD+79lHJQIiAhluJoyGRbvj9NZJsl9WjCUo0ND7Q==", + "license": "MIT", + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + }, + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.0.tgz", + "version": "29.4.0" + }, + "node_modules/ts-jest/node_modules/type-fest": { + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "version": "4.41.0" + }, + "node_modules/type-detect": { + "dev": true, + "engines": { + "node": ">=4" + }, + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "version": "4.0.8" + }, + "node_modules/type-fest": { + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "version": "0.21.3" + }, + "node_modules/typescript": { + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "dev": true, + "engines": { + "node": ">=14.17" + }, + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "version": "5.8.3" + }, + "node_modules/ufo": { + "dev": true, + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "version": "1.6.1" + }, + "node_modules/undici": { + "dev": true, + "engines": { + "node": ">=20.18.1" + }, + "integrity": "sha512-GrKEsc3ughskmGA9jevVlIOPMiiAHJ4OFUtaAH+NhfTUSiZ1wMPIQqQvAJUrJspFXJt3EBWgpAeoHEDVT1IBug==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.12.0.tgz", + "version": "7.12.0" + }, + "node_modules/undici-types": { + "dev": true, + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "version": "7.8.0" + }, + "node_modules/unenv": { + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.4", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "ufo": "^1.6.1" + }, + "dev": true, + "integrity": "sha512-B06u0wXkEd+o5gOCMl/ZHl5cfpYbDZKAT+HWTL+Hws6jWu7dCiqBBXXXzMFcFVJb8D4ytAnYmxJA83uwOQRSsg==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.17.tgz", + "version": "2.0.0-rc.17" + }, + "node_modules/update-browserslist-db": { + "bin": { + "update-browserslist-db": "cli.js" + }, + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "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" + } + ], + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "license": "MIT", + "peerDependencies": { + "browserslist": ">= 4.21.0" + }, + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "version": "1.1.3" + }, + "node_modules/v8-to-istanbul": { + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "dev": true, + "engines": { + "node": ">=10.12.0" + }, + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "version": "9.3.0" + }, + "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": { + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + }, + "dev": true, + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "version": "0.3.29" + }, + "node_modules/walker": { + "dependencies": { + "makeerror": "1.0.12" + }, + "dev": true, + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "version": "1.0.8" + }, + "node_modules/which": { + "bin": { + "node-which": "bin/node-which" + }, + "dependencies": { + "isexe": "^2.0.0" + }, + "dev": true, + "engines": { + "node": ">= 8" + }, + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "version": "2.0.2" + }, + "node_modules/workerd": { + "bin": { + "workerd": "bin/workerd" + }, + "dev": true, + "engines": { + "node": ">=16" + }, + "hasInstallScript": true, + "integrity": "sha512-7h+k1OxREpiZW0849g0uQNexRWMcs5i5gUGhJzCY8nIx6Tv4D/ndlXJ47lEFj7/LQdp165IL9dM2D5uDiedZrg==", + "license": "Apache-2.0", + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20250712.0", + "@cloudflare/workerd-darwin-arm64": "1.20250712.0", + "@cloudflare/workerd-linux-64": "1.20250712.0", + "@cloudflare/workerd-linux-arm64": "1.20250712.0", + "@cloudflare/workerd-windows-64": "1.20250712.0" + }, + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250712.0.tgz", + "version": "1.20250712.0" + }, + "node_modules/wrangler": { + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "dependencies": { + "@cloudflare/kv-asset-handler": "0.4.0", + "@cloudflare/unenv-preset": "2.3.3", + "blake3-wasm": "2.1.5", + "esbuild": "0.25.4", + "miniflare": "4.20250712.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.17", + "workerd": "1.20250712.0" + }, + "dev": true, + "engines": { + "node": ">=18.0.0" + }, + "integrity": "sha512-SepXQbzWkdp0O7qAx3i0go+fw5I0VkDqLV2G3ewbffO5k8kpvuCkhalS23KO+7+o8+Oa3vfC7x+16IL3rj2n4w==", + "license": "MIT OR Apache-2.0", + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20250712.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + }, + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.25.0.tgz", + "version": "4.25.0" + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "cpu": [ + "x64" + ], + "dev": true, + "engines": { + "node": ">=18" + }, + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "version": "0.25.4" + }, + "node_modules/wrangler/node_modules/esbuild": { + "bin": { + "esbuild": "bin/esbuild" + }, + "dev": true, + "engines": { + "node": ">=18" + }, + "hasInstallScript": true, + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "license": "MIT", + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" + }, + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "version": "0.25.4" + }, + "node_modules/wrap-ansi": { + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + }, + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "version": "7.0.0" + }, + "node_modules/wrappy": { + "dev": true, + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "version": "1.0.2" + }, + "node_modules/write-file-atomic": { + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "dev": true, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "version": "4.0.2" + }, + "node_modules/ws": { + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + }, + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "version": "8.18.0" + }, + "node_modules/y18n": { + "dev": true, + "engines": { + "node": ">=10" + }, + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "version": "5.0.8" + }, + "node_modules/yallist": { + "dev": true, + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "version": "3.1.1" + }, + "node_modules/yargs": { + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "dev": true, + "engines": { + "node": ">=12" + }, + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "version": "17.7.2" + }, + "node_modules/yargs-parser": { + "dev": true, + "engines": { + "node": ">=12" + }, + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "version": "21.1.1" + }, + "node_modules/yocto-queue": { + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "version": "0.1.0" + }, + "node_modules/youch": { + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + }, + "dev": true, + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "version": "4.1.0-beta.10" + }, + "node_modules/youch-core": { + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + }, + "dev": true, + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "license": "MIT", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "version": "0.3.3" + } + }, + "requires": true, + "version": "1.0.0" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4382c5f --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "author": "xixu-me", + "dependencies": { + "hono": "^4.6.14" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250722.0", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "ts-jest": "^29.1.2", + "typescript": "^5.5.2", + "wrangler": "^4.25.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "license": "MIT", + "main": "src/index.ts", + "name": "deeplx", + "private": false, + "repository": { + "type": "git", + "url": "https://github.com/xixu-me/DeepLX.git" + }, + "scripts": { + "cf-typegen": "wrangler types", + "deploy": "wrangler deploy", + "dev": "wrangler dev", + "fix-lockfile": "node scripts/fix-lockfile.js", + "lint": "tsc --noEmit", + "start": "wrangler dev", + "test": "jest", + "test:ci": "jest --coverage --ci --watchAll=false", + "test:coverage": "jest --coverage", + "test:debug": "jest --detectOpenHandles --forceExit", + "test:integration": "jest --testPathPattern=tests/integration", + "test:performance": "jest --testPathPattern=tests/performance", + "test:runner": "node scripts/test-runner.js", + "test:setup": "node scripts/setup-tests.js", + "test:unit": "jest --testPathPattern=tests/lib", + "test:verbose": "jest --verbose", + "test:watch": "jest --watch" + }, + "version": "1.0.0" +} diff --git a/scripts/complete-setup.js b/scripts/complete-setup.js new file mode 100644 index 0000000..dbbb642 --- /dev/null +++ b/scripts/complete-setup.js @@ -0,0 +1,306 @@ +#!/usr/bin/env node + +/** + * Complete DeepLX Test Suite Setup + * One-command solution to set up the entire testing environment + */ + +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +// ANSI color codes +const colors = { + reset: "\x1b[0m", + bright: "\x1b[1m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + cyan: "\x1b[36m", + magenta: "\x1b[35m", +}; + +function colorize(text, color) { + return `${colors[color]}${text}${colors.reset}`; +} + +function log(message, color = "reset") { + console.log(colorize(message, color)); +} + +function logHeader(message) { + console.log("\n" + colorize("=".repeat(70), "cyan")); + console.log(colorize(message.toUpperCase(), "bright")); + console.log(colorize("=".repeat(70), "cyan") + "\n"); +} + +function logStep(step, message) { + console.log( + colorize(`[${step}]`, "blue") + " " + colorize(message, "bright") + ); +} + +function runCommand(command, description, options = {}) { + log(`🔄 ${description}...`, "blue"); + try { + const output = execSync(command, { + stdio: options.silent ? "pipe" : "inherit", + encoding: "utf8", + ...options, + }); + log(`✅ ${description} completed`, "green"); + return { success: true, output }; + } catch (error) { + log(`❌ ${description} failed: ${error.message}`, "red"); + return { success: false, error }; + } +} + +function checkPrerequisites() { + logStep("1", "Checking Prerequisites"); + + // Check Node.js version + const nodeVersion = process.version; + const majorVersion = parseInt(nodeVersion.slice(1).split(".")[0]); + + if (majorVersion < 18) { + log(`❌ Node.js ${nodeVersion} detected. Node.js 18+ is required.`, "red"); + log("Please upgrade Node.js and try again.", "yellow"); + return false; + } + + log(`✅ Node.js ${nodeVersion} is compatible`, "green"); + + // Check if we're in the right directory + if (!fs.existsSync("package.json")) { + log("❌ package.json not found. Are you in the repository root?", "red"); + return false; + } + + log("✅ Repository structure looks good", "green"); + return true; +} + +function fixLockFile() { + logStep("2", "Fixing Package Lock File Issues"); + + // Remove problematic files + const toRemove = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]; + + toRemove.forEach((file) => { + if (fs.existsSync(file)) { + log(`Removing ${file}...`, "yellow"); + fs.unlinkSync(file); + } + }); + + // Remove node_modules + if (fs.existsSync("node_modules")) { + log("Removing node_modules...", "yellow"); + try { + execSync("rm -rf node_modules", { stdio: "pipe" }); + } catch (error) { + // Fallback for Windows + try { + execSync("rmdir /s /q node_modules", { stdio: "pipe" }); + } catch (winError) { + log( + "⚠️ Could not remove node_modules. Please remove it manually.", + "yellow" + ); + } + } + } + + log("✅ Cleanup completed", "green"); +} + +function installDependencies() { + logStep("3", "Installing Dependencies"); + + const result = runCommand("npm install", "Install all dependencies"); + + if (!result.success) { + log( + "⚠️ Standard install failed, trying with legacy peer deps...", + "yellow" + ); + const legacyResult = runCommand( + "npm install --legacy-peer-deps", + "Install with legacy peer deps" + ); + + if (!legacyResult.success) { + log( + "❌ Installation failed completely. Please check the errors above.", + "red" + ); + return false; + } + } + + return true; +} + +function verifyInstallation() { + logStep("4", "Verifying Installation"); + + // Check if key dependencies are installed + const keyDeps = ["jest", "typescript", "@cloudflare/workers-types"]; + + for (const dep of keyDeps) { + const depPath = path.join("node_modules", dep); + if (!fs.existsSync(depPath)) { + log(`❌ ${dep} not found in node_modules`, "red"); + return false; + } + } + + log("✅ All key dependencies verified", "green"); + + // Test TypeScript compilation + const tsResult = runCommand("npm run lint", "TypeScript compilation check", { + silent: true, + }); + + if (!tsResult.success) { + log( + "⚠️ TypeScript compilation has issues. Tests may not work properly.", + "yellow" + ); + log("This is not critical - you can still run tests.", "blue"); + } else { + log("✅ TypeScript compilation successful", "green"); + } + + return true; +} + +function runSampleTests() { + logStep("5", "Running Sample Tests"); + + // Try to run a quick test to verify everything works + log("Running a quick test to verify the setup...", "blue"); + + const testResult = runCommand( + "npm test -- --testTimeout=10000 --maxWorkers=1 --passWithNoTests", + "Sample test run", + { silent: true } + ); + + if (testResult.success) { + log("✅ Test environment is working correctly!", "green"); + return true; + } else { + log("⚠️ Tests encountered issues, but setup is complete.", "yellow"); + log("You can still run tests manually to debug specific issues.", "blue"); + return false; + } +} + +function showSummary() { + logHeader("Setup Complete!"); + + log("🎉 DeepLX Test Suite setup completed successfully!", "green"); + + console.log("\n" + colorize("📊 Test Suite Statistics:", "bright")); + log(" • 565+ comprehensive tests", "cyan"); + log(" • 12 unit test files covering all modules", "cyan"); + log(" • Integration and performance tests", "cyan"); + log(" • Complete CI/CD pipeline", "cyan"); + log(" • Extensive documentation", "cyan"); + + console.log("\n" + colorize("🚀 Available Commands:", "bright")); + log(" npm test # Run all tests", "cyan"); + log(" npm run test:unit # Run unit tests only", "cyan"); + log(" npm run test:integration # Run integration tests", "cyan"); + log(" npm run test:performance # Run performance tests", "cyan"); + log(" npm run test:coverage # Run tests with coverage", "cyan"); + log(" npm run test:watch # Run tests in watch mode", "cyan"); + + console.log("\n" + colorize("📚 Documentation:", "bright")); + log(" • tests/README.md # Comprehensive test docs", "cyan"); + log(" • TESTING.md # Quick start guide", "cyan"); + log(" • DEPLOYMENT_GUIDE.md # Deployment help", "cyan"); + log(" • README_TEST_SUITE.md # Complete overview", "cyan"); + + console.log("\n" + colorize("🎯 Next Steps:", "bright")); + log(' 1. Run "npm test" to execute all tests', "magenta"); + log(' 2. Check coverage with "npm run test:coverage"', "magenta"); + log(' 3. Use "npm run test:watch" for development', "magenta"); + log(" 4. Review the documentation in tests/README.md", "magenta"); + + console.log( + "\n" + + colorize( + "🏆 Your DeepLX service is now thoroughly tested and production-ready!", + "green" + ) + ); +} + +function main() { + logHeader("DeepLX Complete Test Suite Setup"); + + log( + "This script will set up the complete testing environment for DeepLX.", + "cyan" + ); + log( + "It includes 565+ tests covering all functionality with CI/CD integration.\n", + "cyan" + ); + + let success = true; + + // Step 1: Check prerequisites + if (!checkPrerequisites()) { + process.exit(1); + } + + // Step 2: Fix lock file issues + fixLockFile(); + + // Step 3: Install dependencies + if (!installDependencies()) { + log("\n❌ Setup failed during dependency installation.", "red"); + log("Please check the error messages above and try again.", "yellow"); + process.exit(1); + } + + // Step 4: Verify installation + if (!verifyInstallation()) { + log("\n❌ Setup failed during verification.", "red"); + process.exit(1); + } + + // Step 5: Run sample tests + const testsWorking = runSampleTests(); + if (!testsWorking) { + success = false; + } + + // Show summary + showSummary(); + + if (!success) { + log( + "\n⚠️ Setup completed with some warnings. Check the messages above.", + "yellow" + ); + process.exit(0); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + checkPrerequisites, + fixLockFile, + installDependencies, + verifyInstallation, + runSampleTests, +}; diff --git a/scripts/fix-lockfile.js b/scripts/fix-lockfile.js new file mode 100644 index 0000000..4e73ce2 --- /dev/null +++ b/scripts/fix-lockfile.js @@ -0,0 +1,175 @@ +#!/usr/bin/env node + +/** + * Fix package-lock.json sync issues + * This script helps resolve npm ci failures by regenerating the lock file + */ + +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +// ANSI color codes +const colors = { + reset: "\x1b[0m", + bright: "\x1b[1m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + cyan: "\x1b[36m", +}; + +function colorize(text, color) { + return `${colors[color]}${text}${colors.reset}`; +} + +function log(message, color = "reset") { + console.log(colorize(message, color)); +} + +function logHeader(message) { + console.log("\n" + colorize("=".repeat(50), "cyan")); + console.log(colorize(message.toUpperCase(), "bright")); + console.log(colorize("=".repeat(50), "cyan") + "\n"); +} + +function runCommand(command, description) { + log(`🔄 ${description}...`, "blue"); + try { + execSync(command, { stdio: "inherit" }); + log(`✅ ${description} completed`, "green"); + return true; + } catch (error) { + log(`❌ ${description} failed`, "red"); + return false; + } +} + +function main() { + logHeader("Package Lock File Fix"); + + log( + "This script will fix package-lock.json sync issues for CI/CD deployment.", + "cyan" + ); + + // Check if package.json exists + if (!fs.existsSync("package.json")) { + log("❌ package.json not found. Run this from the repository root.", "red"); + process.exit(1); + } + + // Remove existing lock files + const lockFiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]; + + log("🧹 Cleaning up existing lock files...", "blue"); + lockFiles.forEach((file) => { + if (fs.existsSync(file)) { + log(`Removing ${file}...`, "yellow"); + fs.unlinkSync(file); + } + }); + + // Remove node_modules if it exists + if (fs.existsSync("node_modules")) { + log("Removing node_modules...", "yellow"); + if (process.platform === "win32") { + runCommand("rmdir /s /q node_modules", "Remove node_modules (Windows)"); + } else { + runCommand("rm -rf node_modules", "Remove node_modules"); + } + } + + // Clear npm cache + log("🗑️ Clearing npm cache...", "blue"); + runCommand("npm cache clean --force", "Clear npm cache"); + + // Install dependencies to generate new lock file + log("📦 Installing dependencies to generate new lock file...", "blue"); + if (!runCommand("npm install", "Install dependencies")) { + log("❌ Failed to install dependencies", "red"); + process.exit(1); + } + + // Verify the installation + if (!fs.existsSync("package-lock.json")) { + log("❌ package-lock.json was not generated", "red"); + process.exit(1); + } + + if (!fs.existsSync("node_modules")) { + log("❌ node_modules was not created", "red"); + process.exit(1); + } + + // Test that npm ci would work now + log("🧪 Testing npm ci compatibility...", "blue"); + + // Save current node_modules + if (fs.existsSync("node_modules_backup")) { + if (process.platform === "win32") { + runCommand("rmdir /s /q node_modules_backup", "Remove old backup"); + } else { + runCommand("rm -rf node_modules_backup", "Remove old backup"); + } + } + + if (process.platform === "win32") { + runCommand("move node_modules node_modules_backup", "Backup node_modules"); + } else { + runCommand("mv node_modules node_modules_backup", "Backup node_modules"); + } + + // Test npm ci + const ciSuccess = runCommand("npm ci", "Test npm ci"); + + if (ciSuccess) { + log("✅ npm ci test successful!", "green"); + // Remove backup + if (process.platform === "win32") { + runCommand("rmdir /s /q node_modules_backup", "Remove backup"); + } else { + runCommand("rm -rf node_modules_backup", "Remove backup"); + } + } else { + log("⚠️ npm ci test failed, restoring backup...", "yellow"); + // Restore backup + if (fs.existsSync("node_modules")) { + if (process.platform === "win32") { + runCommand("rmdir /s /q node_modules", "Remove failed install"); + } else { + runCommand("rm -rf node_modules", "Remove failed install"); + } + } + + if (process.platform === "win32") { + runCommand("move node_modules_backup node_modules", "Restore backup"); + } else { + runCommand("mv node_modules_backup node_modules", "Restore backup"); + } + } + + logHeader("Fix Complete"); + + if (ciSuccess) { + log("🎉 Package lock file has been fixed!", "green"); + log( + "Your repository should now work with npm ci in CI/CD environments.", + "bright" + ); + log("\nNext steps:", "cyan"); + log("1. Commit the new package-lock.json file", "blue"); + log("2. Push to trigger CI/CD deployment", "blue"); + log("3. Run tests: npm test", "blue"); + } else { + log("❌ Could not fix the lock file issue.", "red"); + log("You may need to manually resolve dependency conflicts.", "yellow"); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { main }; diff --git a/scripts/setup-tests.js b/scripts/setup-tests.js new file mode 100644 index 0000000..3c6da9f --- /dev/null +++ b/scripts/setup-tests.js @@ -0,0 +1,245 @@ +#!/usr/bin/env node + +/** + * Test setup script for DeepLX + * Helps set up the testing environment and resolve common issues + */ + +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +// ANSI color codes +const colors = { + reset: "\x1b[0m", + bright: "\x1b[1m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + cyan: "\x1b[36m", +}; + +function colorize(text, color) { + return `${colors[color]}${text}${colors.reset}`; +} + +function log(message, color = "reset") { + console.log(colorize(message, color)); +} + +function logHeader(message) { + console.log("\n" + colorize("=".repeat(60), "cyan")); + console.log(colorize(message.toUpperCase(), "bright")); + console.log(colorize("=".repeat(60), "cyan") + "\n"); +} + +function runCommand(command, description, options = {}) { + log(`🔄 ${description}...`, "blue"); + try { + const output = execSync(command, { + stdio: options.silent ? "pipe" : "inherit", + encoding: "utf8", + ...options, + }); + log(`✅ ${description} completed`, "green"); + return { success: true, output }; + } catch (error) { + log(`❌ ${description} failed: ${error.message}`, "red"); + return { success: false, error }; + } +} + +function checkNodeVersion() { + const nodeVersion = process.version; + const majorVersion = parseInt(nodeVersion.slice(1).split(".")[0]); + + if (majorVersion < 18) { + log( + `⚠️ Node.js ${nodeVersion} detected. Node.js 18+ is recommended.`, + "yellow" + ); + return false; + } + + log(`✅ Node.js ${nodeVersion} is compatible`, "green"); + return true; +} + +function checkPackageJson() { + if (!fs.existsSync("package.json")) { + log("❌ package.json not found. Are you in the repository root?", "red"); + return false; + } + + try { + const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); + if (!pkg.scripts || !pkg.scripts.test) { + log("⚠️ No test script found in package.json", "yellow"); + return false; + } + + log("✅ package.json looks good", "green"); + return true; + } catch (error) { + log(`❌ Error reading package.json: ${error.message}`, "red"); + return false; + } +} + +function cleanInstall() { + log("🧹 Cleaning up old installations...", "blue"); + + // Remove node_modules and lock files + const toRemove = ["node_modules", "package-lock.json", "yarn.lock"]; + + toRemove.forEach((item) => { + if (fs.existsSync(item)) { + log(`Removing ${item}...`, "yellow"); + if (item === "node_modules") { + runCommand(`rm -rf ${item}`, `Remove ${item}`, { silent: true }); + } else { + fs.unlinkSync(item); + } + } + }); + + log("✅ Cleanup completed", "green"); +} + +function installDependencies() { + log("📦 Installing dependencies...", "blue"); + + // Try npm install first + const result = runCommand("npm install", "Install dependencies"); + + if (!result.success) { + log("⚠️ npm install failed, trying with --legacy-peer-deps...", "yellow"); + const legacyResult = runCommand( + "npm install --legacy-peer-deps", + "Install with legacy peer deps" + ); + + if (!legacyResult.success) { + log( + "❌ Installation failed. Please check the error messages above.", + "red" + ); + return false; + } + } + + return true; +} + +function verifyInstallation() { + log("🔍 Verifying installation...", "blue"); + + // Check if node_modules exists + if (!fs.existsSync("node_modules")) { + log("❌ node_modules directory not found", "red"); + return false; + } + + // Check if key dependencies are installed + const keyDeps = ["jest", "typescript", "@cloudflare/workers-types"]; + + for (const dep of keyDeps) { + const depPath = path.join("node_modules", dep); + if (!fs.existsSync(depPath)) { + log(`❌ ${dep} not found in node_modules`, "red"); + return false; + } + } + + log("✅ Installation verified", "green"); + return true; +} + +function runSampleTest() { + log("🧪 Running a sample test...", "blue"); + + const result = runCommand("npm run lint", "TypeScript compilation check"); + + if (!result.success) { + log( + "⚠️ TypeScript compilation has issues. Tests may not work properly.", + "yellow" + ); + return false; + } + + return true; +} + +function main() { + logHeader("DeepLX Test Environment Setup"); + + log( + "This script will help you set up the testing environment for DeepLX.", + "cyan" + ); + log( + "It will clean up any existing installations and reinstall dependencies.\n", + "cyan" + ); + + // Check prerequisites + if (!checkNodeVersion()) { + log("Please upgrade to Node.js 18 or higher and try again.", "red"); + process.exit(1); + } + + if (!checkPackageJson()) { + log( + "Please run this script from the DeepLX repository root directory.", + "red" + ); + process.exit(1); + } + + // Clean and install + cleanInstall(); + + if (!installDependencies()) { + log("\n❌ Setup failed during dependency installation.", "red"); + log("Please check the error messages above and try again.", "yellow"); + process.exit(1); + } + + if (!verifyInstallation()) { + log("\n❌ Setup failed during verification.", "red"); + process.exit(1); + } + + if (!runSampleTest()) { + log( + "\n⚠️ Setup completed but there may be issues with the code.", + "yellow" + ); + } + + // Success message + logHeader("Setup Complete!"); + + log("🎉 Test environment setup completed successfully!", "green"); + log("\nYou can now run tests using:", "bright"); + log(" npm test # Run all tests", "cyan"); + log(" npm run test:unit # Run unit tests only", "cyan"); + log(" npm run test:coverage # Run tests with coverage", "cyan"); + log(" npm run test:watch # Run tests in watch mode", "cyan"); + + log("\nFor more information, see TESTING.md", "blue"); +} + +if (require.main === module) { + main(); +} + +module.exports = { + checkNodeVersion, + checkPackageJson, + cleanInstall, + installDependencies, + verifyInstallation, +}; diff --git a/scripts/test-runner.js b/scripts/test-runner.js new file mode 100644 index 0000000..9bd60aa --- /dev/null +++ b/scripts/test-runner.js @@ -0,0 +1,175 @@ +#!/usr/bin/env node + +/** + * Test runner script for DeepLX + * Provides enhanced test execution with reporting and analysis + */ + +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +// ANSI color codes for console output +const colors = { + reset: "\x1b[0m", + bright: "\x1b[1m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", +}; + +function colorize(text, color) { + return `${colors[color]}${text}${colors.reset}`; +} + +function log(message, color = "reset") { + console.log(colorize(message, color)); +} + +function logHeader(message) { + console.log("\n" + colorize("=".repeat(60), "cyan")); + console.log(colorize(message.toUpperCase(), "bright")); + console.log(colorize("=".repeat(60), "cyan") + "\n"); +} + +function logSection(message) { + console.log("\n" + colorize("-".repeat(40), "blue")); + console.log(colorize(message, "bright")); + console.log(colorize("-".repeat(40), "blue")); +} + +function runCommand(command, description) { + logSection(description); + try { + const startTime = Date.now(); + const output = execSync(command, { + stdio: "inherit", + encoding: "utf8", + }); + const duration = Date.now() - startTime; + log(`✅ ${description} completed in ${duration}ms`, "green"); + return true; + } catch (error) { + log(`❌ ${description} failed`, "red"); + console.error(error.message); + return false; + } +} + +function checkTestFiles() { + const testDirs = ["tests/lib", "tests/integration", "tests/performance"]; + let totalTests = 0; + + logSection("Test File Analysis"); + + testDirs.forEach((dir) => { + if (fs.existsSync(dir)) { + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".test.ts")); + log(`${dir}: ${files.length} test files`, "cyan"); + totalTests += files.length; + } + }); + + log(`Total test files: ${totalTests}`, "bright"); + return totalTests > 0; +} + +function generateTestReport() { + logSection("Generating Test Report"); + + const reportData = { + timestamp: new Date().toISOString(), + testSuite: "DeepLX Translation API", + environment: process.env.NODE_ENV || "test", + nodeVersion: process.version, + }; + + const reportPath = path.join("coverage", "test-report.json"); + + try { + if (!fs.existsSync("coverage")) { + fs.mkdirSync("coverage", { recursive: true }); + } + + fs.writeFileSync(reportPath, JSON.stringify(reportData, null, 2)); + log(`Test report generated: ${reportPath}`, "green"); + } catch (error) { + log(`Failed to generate test report: ${error.message}`, "red"); + } +} + +function main() { + const args = process.argv.slice(2); + const testType = args[0] || "all"; + + logHeader("DeepLX Test Suite Runner"); + + // Check if test files exist + if (!checkTestFiles()) { + log("No test files found!", "red"); + process.exit(1); + } + + let success = true; + + // Run different test suites based on argument + switch (testType) { + case "unit": + success = runCommand("npm run test:unit", "Unit Tests"); + break; + + case "integration": + success = runCommand("npm run test:integration", "Integration Tests"); + break; + + case "performance": + success = runCommand("npm run test:performance", "Performance Tests"); + break; + + case "coverage": + success = runCommand("npm run test:coverage", "Coverage Tests"); + break; + + case "ci": + success = runCommand("npm run test:ci", "CI Tests"); + break; + + case "all": + default: + // Run all test suites + success = runCommand("npm run lint", "Type Checking") && success; + success = runCommand("npm run test:unit", "Unit Tests") && success; + success = + runCommand("npm run test:integration", "Integration Tests") && success; + success = + runCommand("npm run test:performance", "Performance Tests") && success; + success = + runCommand("npm run test:coverage", "Coverage Report") && success; + break; + } + + // Generate test report + generateTestReport(); + + // Final status + logHeader("Test Suite Summary"); + + if (success) { + log("🎉 All tests passed successfully!", "green"); + log("The DeepLX translation service is ready for deployment.", "bright"); + } else { + log("💥 Some tests failed!", "red"); + log("Please review the test output and fix any issues.", "yellow"); + process.exit(1); + } +} + +// Handle command line arguments +if (require.main === module) { + main(); +} + +module.exports = { runCommand, checkTestFiles, generateTestReport }; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..7e638de --- /dev/null +++ b/src/index.ts @@ -0,0 +1,298 @@ +/** + * DeepLX + */ + +import { Hono } from "hono"; +import { + clearMemoryCache, + generateCacheKey, + getCachedTranslation, + query, + setCachedTranslation, +} from "./lib"; + +import { PAYLOAD_LIMITS } from "./lib/config"; +import { createErrorResponse } from "./lib/errorHandler"; +import { normalizeLanguageCode } from "./lib/query"; +import { + getSecureClientIP, + handleCORSPreflight, + validateLanguageCode, +} from "./lib/security"; +import { createStandardResponse } from "./lib/types"; + +/** + * Initialize Hono application with environment bindings + */ +const app = new Hono<{ Bindings: Env }>(); + +/** + * Worker export configuration + * Defines the main fetch handler and scheduled event handler + */ +export default { + fetch: app.fetch, + scheduled, +}; + +/** + * Scheduled event handler for periodic maintenance tasks + * Executes every 5 minutes as configured in wrangler.jsonc + * @param event The scheduled event object + * @param env Environment bindings + * @param ctx Execution context for background tasks + */ +async function scheduled( + event: ScheduledEvent, + env: Env, + ctx: ExecutionContext +): Promise { + ctx.waitUntil(handleScheduled(event, env)); +} + +/** + * Handle scheduled maintenance tasks + * Performs cache cleanup and other periodic maintenance + * @param event The scheduled event object + * @param env Environment bindings + */ +async function handleScheduled(event: ScheduledEvent, env: Env): Promise { + // Clear the in-memory cache every 5 minutes to prevent memory leaks + clearMemoryCache(); +} + +/** + * API Route Definitions + * Defines all available endpoints and their handlers + */ +app + // Add CORS preflight handling for all routes + .options("*", (c) => handleCORSPreflight(c)) + + .get("/translate", (c) => c.text("Please use POST method :)")) + + /** + * Debug endpoint for request format validation and troubleshooting + * SECURITY: This endpoint is disabled in production unless DEBUG_MODE is explicitly enabled + * POST /debug + */ + .post("/debug", async (c) => { + // Check if debug mode is enabled via environment variable + if (!c.env.DEBUG_MODE) { + return c.json(createStandardResponse(404, null), 404); + } + + const env = c.env; + const clientIP = getSecureClientIP(c.req.raw) || "unknown"; + + try { + const params = await c.req.json().catch(() => ({})); + + // Import buildRequestBody from query module for debugging + const { buildRequestBody } = await import("./lib/query"); + + if (!params.text) { + return c.json( + createStandardResponse(400, "Missing text parameter"), + 400 + ); + } + + // Basic text validation + const sanitizedText = params.text; + if (!sanitizedText) { + return c.json( + createStandardResponse(400, "Invalid text parameter"), + 400 + ); + } + + // Validate language codes + const sourceLang = params.source_lang + ? validateLanguageCode(params.source_lang) + : "auto"; + const targetLang = params.target_lang + ? validateLanguageCode(params.target_lang) + : "en"; + + if (!sourceLang || !targetLang) { + return c.json( + createStandardResponse(400, "Invalid language codes"), + 400 + ); + } + + const sanitizedParams = { + text: sanitizedText, + source_lang: sourceLang, + target_lang: targetLang, + }; + + try { + const requestBody = buildRequestBody(sanitizedParams); + const parsedBody = JSON.parse(requestBody); + + const debugInfo = { + status: "Request format is valid", + client_ip: clientIP, // Safe to show in debug mode + generated_request: parsedBody, + sanitized_params: sanitizedParams, // Show sanitized version + validation: { + text_length: sanitizedText.length, + sanitized_text_length: sanitizedText.length, + has_source_lang: !!sourceLang, + has_target_lang: !!targetLang, + request_id: parsedBody.id, + timestamp: parsedBody.params?.timestamp, + method_format: requestBody.includes('"method" : "') + ? "spaced" + : "normal", + normalized_source_lang: sourceLang, + normalized_target_lang: targetLang, + }, + }; + + return c.json( + createStandardResponse(200, JSON.stringify(debugInfo)), + 200 + ); + } catch (buildError) { + const errorMessage = + buildError instanceof Error + ? buildError.message + : "Request build failed"; + return c.json(createStandardResponse(400, errorMessage), 400); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return c.json(createStandardResponse(400, errorMessage), 400); + } + }) + + /** + * Main translation endpoint with comprehensive features + * Handles single text translation with rate limiting, caching, and error handling + * POST /translate + */ + .post("/translate", async (c) => { + const env = c.env; + const clientIP = getSecureClientIP(c.req.raw) || "unknown"; + + try { + // Parse request parameters with better error handling + let params; + try { + params = await c.req.json(); + } catch (parseError) { + return c.json(createStandardResponse(400, null), 400); + } + + // Enhanced parameter validation with input sanitization + if (!params || typeof params !== "object") { + return c.json(createStandardResponse(400, null), 400); + } + + if (!params.text || typeof params.text !== "string") { + return c.json(createStandardResponse(400, null), 400); + } + + // Basic text validation + let sanitizedText; + try { + sanitizedText = params.text; + if (sanitizedText.length > PAYLOAD_LIMITS.MAX_TEXT_LENGTH) { + sanitizedText = sanitizedText.slice( + 0, + PAYLOAD_LIMITS.MAX_TEXT_LENGTH + ); + } + } catch (sanitizeError) { + return c.json(createStandardResponse(400, null), 400); + } + + // Validate text length + if (!sanitizedText) { + return c.json(createStandardResponse(400, null), 400); + } + + // Validate and sanitize language parameters + const sourceLang = params.source_lang + ? validateLanguageCode(params.source_lang) + : "auto"; + const targetLang = params.target_lang + ? validateLanguageCode(params.target_lang) + : "en"; + + if (!sourceLang || !targetLang) { + return c.json(createStandardResponse(400, null), 400); + } + + // Check cache first for faster response + const normalizedSourceLang = normalizeLanguageCode(sourceLang); + const normalizedTargetLang = normalizeLanguageCode(targetLang); + const cacheKey = generateCacheKey( + sanitizedText, + normalizedSourceLang, + normalizedTargetLang + ); + const cached = await getCachedTranslation(cacheKey, env); + + if (cached) { + return c.json( + createStandardResponse( + 200, + cached.data, + cached.id || Math.floor(Math.random() * 10000000000), + cached.source_lang, + cached.target_lang + ) + ); + } + + // Prepare validated parameters for translation + const validatedParams = { + text: sanitizedText, + source_lang: normalizedSourceLang, + target_lang: normalizedTargetLang, + }; + + // Process translation with comprehensive rate limiting built-in + const result = await query(validatedParams, { + env, + clientIP, // Pass client IP to query function + }); + + // Cache successful translations + if (result.code === 200 && result.data) { + await setCachedTranslation( + cacheKey, + { + data: result.data, + timestamp: Date.now(), + source_lang: + result.source_lang || validatedParams.source_lang.toUpperCase(), + target_lang: + result.target_lang || validatedParams.target_lang.toUpperCase(), + id: result.id, + }, + env + ); + } + + return c.json(result, result.code as any); + } catch (error) { + const errorResponse = createErrorResponse(error, { + endpoint: "/translate", + clientIP, + }); + + return c.json(errorResponse.response, errorResponse.httpStatus as any); + } + }) + + /** + * Catch-all route for undefined paths + * Redirects all other requests to the GitHub repository + */ + .all("*", (c) => c.redirect("https://github.com/xixu-me/DeepLX")); diff --git a/src/lib/cache.ts b/src/lib/cache.ts new file mode 100644 index 0000000..d3add95 --- /dev/null +++ b/src/lib/cache.ts @@ -0,0 +1,142 @@ +/** + * Two-level caching system: In-memory + Cloudflare KV + * Provides fast access to cached translations with fallback to persistent storage + */ + +/** + * In-memory cache for fast access to recent translations + */ +const memoryCache = new Map(); + +/** + * Cache configuration constants + */ +const CACHE_TTL = 3600; // 1 hour in seconds + +/** + * Generate a unique cache key for translation requests + * Uses deterministic hashing to ensure consistent keys across requests + * @param text The text to translate + * @param sourceLang The source language code + * @param targetLang The target language code + * @returns A unique cache key string + */ + +export function generateCacheKey( + text: string, + sourceLang: string, + targetLang: string +): string { + // Normalize language codes to uppercase for consistent caching + const normalizedSourceLang = + sourceLang === "auto" ? "auto" : sourceLang.toUpperCase(); + const normalizedTargetLang = targetLang.toUpperCase(); + const content = `${text}:${normalizedSourceLang}:${normalizedTargetLang}`; + + // Use crypto.subtle to generate a hash instead of btoa for Unicode safety + try { + // For environments that support crypto.subtle + const encoder = new TextEncoder(); + const data = encoder.encode(content); + + // Create a simple hash using a deterministic method + let hash = 0; + for (let i = 0; i < data.length; i++) { + const char = data[i]; + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + + // Convert to positive number and then to base36 string + const hashStr = Math.abs(hash).toString(36); + + // Add a prefix and ensure consistent length + return `cache_${hashStr}_${normalizedSourceLang}_${normalizedTargetLang}`.substring( + 0, + 50 + ); + } catch (error) { + // Fallback: use content directly with timestamp for uniqueness + return `cache_${content}_${normalizedSourceLang}_${normalizedTargetLang}_${ + Date.now() % 10000 + }`.substring(0, 50); + } +} + +export async function getCachedTranslation( + key: string, + env: Env +): Promise { + try { + // Check in-memory cache first + const memoryResult = memoryCache.get(key); + if ( + memoryResult && + Date.now() - memoryResult.timestamp < CACHE_TTL * 1000 + ) { + return memoryResult; + } + + // Check KV cache with improved error handling + try { + const kvResult = (await env.CACHE_KV.get( + key, + "json" + )) as CacheEntry | null; + if (kvResult && Date.now() - kvResult.timestamp < CACHE_TTL * 1000) { + // Store in memory cache for faster future access + memoryCache.set(key, kvResult); + return kvResult; + } + } catch (kvError) { + console.error("Failed to get cached translation from KV:", kvError); + // Continue without cache if KV fails + } + + return null; + } catch (error) { + console.error("Cache retrieval failed:", error); + return null; + } +} + +/** + * Store translation in two-level cache system + * Stores in both in-memory cache and KV storage for persistence + * @param key The cache key to store under + * @param entry The cache entry to store + * @param env Environment bindings containing KV namespace + * @returns Promise + */ +export async function setCachedTranslation( + key: string, + entry: CacheEntry, + env: Env +): Promise { + try { + // Store in memory cache (this should always succeed) + memoryCache.set(key, entry); + + // Store in KV cache (may fail, but don't let it break the response) + try { + await env.CACHE_KV.put(key, JSON.stringify(entry), { + expirationTtl: CACHE_TTL, + }); + } catch (kvError) { + console.error("Failed to store cached translation in KV:", kvError); + // Don't throw - the translation was successful, caching is just an optimization + } + } catch (error) { + console.error("Cache storage failed:", error); + // Don't throw - the translation was successful, caching is just an optimization + } +} + +/** + * Clear the in-memory cache + * Useful for testing or when memory needs to be freed + * @returns void + */ +export function clearMemoryCache(): void { + memoryCache.clear(); +} diff --git a/src/lib/circuitBreaker.ts b/src/lib/circuitBreaker.ts new file mode 100644 index 0000000..6051721 --- /dev/null +++ b/src/lib/circuitBreaker.ts @@ -0,0 +1,119 @@ +/** + * Circuit breaker pattern implementation for upstream service protection + * Prevents cascade failures by temporarily blocking requests to failing services + */ + +/** + * Circuit breaker state interface + */ +interface CircuitBreakerState { + state: "CLOSED" | "OPEN" | "HALF_OPEN"; + failureCount: number; + lastFailureTime: number; + successCount: number; +} + +/** + * Circuit breaker configuration constants + */ +const FAILURE_THRESHOLD = 5; // Number of failures before opening circuit +const RECOVERY_TIMEOUT = 30000; // 30 seconds recovery period +const SUCCESS_THRESHOLD = 3; // Required successes to close circuit + +/** + * Circuit breaker implementation for service protection + * Implements the circuit breaker pattern with three states: CLOSED, OPEN, HALF_OPEN + */ +export class CircuitBreaker { + private state: CircuitBreakerState = { + state: "CLOSED", + failureCount: 0, + lastFailureTime: 0, + successCount: 0, + }; + + /** + * Execute operation with circuit breaker protection + * Monitors operation success/failure and manages circuit state + * @param operation The async operation to execute + * @returns Promise - Result of the operation + * @throws Will throw if circuit is open or operation fails + */ + async execute(operation: () => Promise): Promise { + if (this.state.state === "OPEN") { + if (Date.now() - this.state.lastFailureTime > RECOVERY_TIMEOUT) { + this.state.state = "HALF_OPEN"; + this.state.successCount = 0; + } else { + throw new Error("Circuit breaker is OPEN"); + } + } + + try { + const result = await operation(); + this.onSuccess(); + return result; + } catch (error) { + this.onFailure(); + throw error; + } + } + + /** + * Handle successful operation + * Updates state based on current circuit breaker state + * @private + */ + private onSuccess(): void { + if (this.state.state === "HALF_OPEN") { + this.state.successCount++; + if (this.state.successCount >= SUCCESS_THRESHOLD) { + this.state.state = "CLOSED"; + this.state.failureCount = 0; + } + } else if (this.state.state === "CLOSED") { + this.state.failureCount = 0; + } + } + + /** + * Handle failed operation + * Updates failure count and potentially opens circuit + * @private + */ + private onFailure(): void { + this.state.failureCount++; + this.state.lastFailureTime = Date.now(); + + if (this.state.failureCount >= FAILURE_THRESHOLD) { + this.state.state = "OPEN"; + } + } + + /** + * Get current circuit breaker state + * Returns a copy of the internal state for monitoring + * @returns CircuitBreakerState - Current state information + */ + getState(): CircuitBreakerState { + return { ...this.state }; + } +} + +/** + * Global circuit breaker instances per proxy URL + */ +const circuitBreakers = new Map(); + +/** + * Get or create circuit breaker instance for a proxy URL + * Ensures one circuit breaker per proxy for proper isolation + * @param proxyUrl - The proxy URL to get circuit breaker for + * @returns CircuitBreaker - Circuit breaker instance for the proxy + */ +export function getCircuitBreaker(proxyUrl: string): CircuitBreaker { + if (!circuitBreakers.has(proxyUrl)) { + circuitBreakers.set(proxyUrl, new CircuitBreaker()); + } + return circuitBreakers.get(proxyUrl)!; +} diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..e24a1d1 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,83 @@ +/** + * Configuration constants for the DeepLX application + * Centralizes all configuration values for easy maintenance and consistency + */ + +/** + * Request timeout configurations + */ +export const REQUEST_TIMEOUT = 10000; // 10 seconds for single requests + +/** + * Retry mechanism configuration + */ +export const DEFAULT_RETRY_CONFIG = { + maxRetries: 3, + initialDelay: 1000, // 1 second initial delay + backoffFactor: 2, // Exponential backoff multiplier +}; + +/** + * Rate limiting configuration for client and proxy limits + * Based on DeepL API best practices and community feedback + */ +export const RATE_LIMIT_CONFIG = { + // Base per-proxy limits + PROXY_TOKENS_PER_SECOND: 8, // Conservative backend proxy rate limit (below 8 to prevent overload) + PROXY_MAX_TOKENS: 16, // 2 seconds worth of burst capacity (8 * 2) + PROXY_REFILL_RATE: 8, // Proxy token refill rate + + // Dynamic client limits (calculated based on proxy count) + BASE_TOKENS_PER_MINUTE: 480, // Fallback when no proxies available +}; + +/** + * Cache system configuration + */ +export const CACHE_CONFIG = { + TTL: 3600, // 1 hour cache time-to-live + MEMORY_CACHE_SIZE: 1000, // Maximum items in memory cache +}; + +/** + * Payload size limits configuration + */ +export const PAYLOAD_LIMITS = { + MAX_TEXT_LENGTH: 5000, // Maximum length of text allowed for a single request + MAX_REQUEST_SIZE: 32768, // Maximum size of a request payload in bytes + PROXY_MAX_TEXT_LENGTH: 3000, // Maximum text length allowed per proxy request + CHUNK_SIZE: 2000, // Size of text chunks for splitting large requests +}; + +/** + * Error handling and logging configuration + */ +export const ERROR_CONFIG = { + LOG_SENSITIVE_DATA: false, // Never log sensitive information in production + INCLUDE_STACK_TRACES: false, // Include stack traces in error responses (for debugging) +}; + +/** + * Calculate dynamic rate limits based on available proxy endpoints + * Per-client limit = number of proxies × per-proxy limit × 60 (to convert to per-minute) + * @param proxyCount - Number of available proxy endpoints + * @returns Object containing calculated rate limits + */ +export function calculateDynamicRateLimits(proxyCount: number) { + // If no proxies available, use base limit + if (proxyCount === 0) { + return { + TOKENS_PER_MINUTE: RATE_LIMIT_CONFIG.BASE_TOKENS_PER_MINUTE, + REFILL_RATE: RATE_LIMIT_CONFIG.BASE_TOKENS_PER_MINUTE / 60, + }; + } + + // Calculate based on proxy capacity: proxies × tokens/sec × 60 sec/min + const tokensPerMinute = + proxyCount * RATE_LIMIT_CONFIG.PROXY_TOKENS_PER_SECOND * 60; + + return { + TOKENS_PER_MINUTE: tokensPerMinute, + REFILL_RATE: tokensPerMinute / 60, // Convert to tokens per second + }; +} diff --git a/src/lib/const.ts b/src/lib/const.ts new file mode 100644 index 0000000..7833cda --- /dev/null +++ b/src/lib/const.ts @@ -0,0 +1,14 @@ +/** + * Application constants for DeepLX translation service + * Contains API endpoints, language definitions, and other immutable values + */ + +/** + * DeepL API endpoint URL + */ +export const API_URL = "https://www2.deepl.com/jsonrpc"; + +/** + * Number of alternative translations to request + */ +export const REQUEST_ALTERNATIVES = 0; diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..346b91c --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,54 @@ +/** + * Environment validation utilities for DeepLX + * Validates required bindings and environment variables + */ + +/** + * Validate required environment bindings and variables + * Ensures all necessary Cloudflare Workers bindings are available + * @param env The environment object to validate + * @returns Array of error messages (empty if validation passes) + */ +export function validateEnvironment(env: Env): string[] { + const errors: string[] = []; + + if (!env.CACHE_KV) { + errors.push("CACHE_KV binding is required"); + } + + if (!env.RATE_LIMIT_KV) { + errors.push("RATE_LIMIT_KV binding is required"); + } + + // Optional environment variables are handled gracefully + // PROXY_URLS is not required but recommended for production use + + return errors; +} + +/** + * Parse and validate proxy URLs from environment + * Extracts valid proxy URLs from comma-separated environment variable + * @param env The environment object containing proxy configuration + * @returns Array of valid proxy URLs + */ +export function getProxyUrls(env: Env): string[] { + if (!env.PROXY_URLS) { + return []; + } + + return env.PROXY_URLS.split(",") + .map((url) => url.trim()) + .filter((url) => { + try { + // Basic URL validation for proxy endpoints + if (url.startsWith("http://") || url.startsWith("https://")) { + return true; + } + return false; + } catch { + // Invalid URL format, skip silently + return false; + } + }); +} diff --git a/src/lib/errorHandler.ts b/src/lib/errorHandler.ts new file mode 100644 index 0000000..10ebb6a --- /dev/null +++ b/src/lib/errorHandler.ts @@ -0,0 +1,209 @@ +/** + * Enhanced error handling and monitoring for DeepLX API + * Provides standardized error processing, sanitization, and response formatting + */ + +import { createStandardResponse } from "./types"; + +/** + * Detailed error information interface + */ +export interface ErrorDetails { + message: string; + stack?: string; + code?: number; + status?: number; + endpoint?: string; + clientIP?: string; + timestamp: number; +} + +/** + * Sanitized error details for public responses (removes sensitive information) + */ +export interface SanitizedErrorDetails { + message: string; + code?: number; + status?: number; + endpoint?: string; + timestamp: number; + // Note: Excludes stack traces and client IP for security +} + +/** + * Check if a given code is a valid HTTP status code + * @param code The code to validate + * @returns True if the code is a valid HTTP status code + * @private + */ + +function isValidHttpStatusCode(code: any): code is number { + const numCode = Number(code); + return ( + !isNaN(numCode) && + numCode >= 200 && + numCode <= 599 && + Number.isInteger(numCode) + ); +} + +/** + * Sanitize status code to ensure it's a valid HTTP status code + * Maps common invalid codes to appropriate HTTP status codes + * @param code The status code to sanitize + * @returns A valid HTTP status code + * @private + */ +function sanitizeStatusCode(code: any): number { + // Try to convert to number + const numCode = Number(code); + + // If it's a valid HTTP status code, use it + if (isValidHttpStatusCode(numCode)) { + return numCode; + } + + // Map common invalid codes to appropriate HTTP status codes + if (code === 5 || code === "5") { + return 500; // Internal server error + } + + // Default to 500 for any invalid status code + return 500; +} + +/** + * Sanitize error message by removing potentially sensitive information + * @param message The error message to sanitize + * @returns The sanitized error message + * @private + */ + +function sanitizeErrorMessage(message: string): string { + // Remove potentially sensitive information + return message + .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[IP]") // IP addresses + .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, "[EMAIL]") // Email addresses + .replace(/\b[A-Fa-f0-9]{32,}\b/g, "[HASH]") // Potential hashes + .substring(0, 500); // Limit message length +} + +/** + * Log error with structured format and sanitization + * Removes sensitive information and provides detailed logging for debugging + * @param error The error to log + * @param context Optional context information about where the error occurred + * @returns ErrorDetails - Detailed error information for internal use + */ +export function logError( + error: any, + context?: { endpoint?: string; clientIP?: string } +): ErrorDetails { + const errorDetails: ErrorDetails = { + message: sanitizeErrorMessage( + error instanceof Error ? error.message : String(error) + ), + stack: error instanceof Error ? error.stack : undefined, + code: error?.code, + status: error?.status, + endpoint: context?.endpoint, + clientIP: context?.clientIP, + timestamp: Date.now(), + }; + + // Log to console with structured format - don't log sensitive information + const sanitizedDetails: SanitizedErrorDetails = { + message: errorDetails.message, + code: errorDetails.code, + status: errorDetails.status, + endpoint: errorDetails.endpoint, + timestamp: errorDetails.timestamp, + }; + + console.error("DeepLX Error:", sanitizedDetails); + + return errorDetails; +} + +/** + * Create standardized error response for API endpoints + * Provides consistent error response format across the application + * @param error The error that occurred + * @param context Optional context about where the error occurred + * @returns Object containing the response data and HTTP status code + */ + +export function createErrorResponse( + error: any, + context?: { endpoint?: string; clientIP?: string } +) { + const errorDetails = logError(error, context); + + // Return appropriate HTTP status code with sanitization + let httpStatus = 500; + if (errorDetails.status) { + httpStatus = sanitizeStatusCode(errorDetails.status); + } else if (errorDetails.code) { + httpStatus = sanitizeStatusCode(errorDetails.code); + } + + // Import createStandardResponse from types + + return { + response: createStandardResponse(httpStatus, null), + httpStatus, + }; +} + +export function isNetworkError(error: any): boolean { + return ( + (error?.name === "TypeError" && error?.message?.includes("fetch")) || + error?.name === "AbortError" || + error?.message?.includes("network") || + error?.message?.includes("timeout") + ); +} + +export function isRateLimitError(error: any): boolean { + return error?.status === 429 || error?.code === 429; +} + +export function isServerError(error: any): boolean { + const status = error?.status || error?.code; + return status >= 500 && status < 600; +} + +export function isPayloadTooLargeError(error: any): boolean { + return ( + error?.status === 413 || + error?.code === 413 || + error?.message?.includes("Payload Too Large") || + error?.message?.includes("payload too large") + ); +} + +/** + * Enhanced rate limit error handling + * Provides specific handling for 429 errors with retry-after headers + * @param error - The error object that may contain rate limit information + * @returns Enhanced error with retry suggestions + */ +export function enhanceRateLimitError(error: any): any { + if (isRateLimitError(error)) { + const enhancedError = { ...error }; + + // Add helpful suggestions for rate limit errors + enhancedError.suggestions = [ + "Wait 60 seconds before retrying", + "Reduce request frequency", + "Implement exponential backoff", + ]; + + // Add retry-after hint (DeepL typically requires 60+ seconds) + enhancedError.retryAfter = 60; // seconds + + return enhancedError; + } + + return error; +} diff --git a/src/lib/htmlUtils.ts b/src/lib/htmlUtils.ts new file mode 100644 index 0000000..b39a28f --- /dev/null +++ b/src/lib/htmlUtils.ts @@ -0,0 +1,136 @@ +/** + * HTML content preservation utilities for DeepLX API + * Handles HTML tags and entities to prevent corruption during translation + */ + +/** + * HTML tag and entity patterns for preservation + */ +const HTML_PATTERNS = { + // HTML tags (opening and closing) + TAGS: /<\/?[a-zA-Z][a-zA-Z0-9]*(?:\s+[^>]*)?>/g, + // HTML entities + ENTITIES: /&(?:[a-zA-Z][a-zA-Z0-9]*|#(?:\d+|x[0-9a-fA-F]+));/g, + // Common punctuation that might be converted + PUNCTUATION: /[::]/g, +}; + +/** + * Placeholder patterns for temporary replacement + */ +const PLACEHOLDER_PREFIX = "__DEEPLX_PRESERVE_"; +const PLACEHOLDER_SUFFIX = "__"; + +/** + * Storage for preserved content during translation + */ +interface PreservedContent { + placeholders: Map; + counter: number; +} + +/** + * Create a unique placeholder for preserved content + * @param content The content to preserve + * @param preserved The preservation storage object + * @returns The placeholder string + */ +function createPlaceholder( + content: string, + preserved: PreservedContent +): string { + const placeholder = `${PLACEHOLDER_PREFIX}${preserved.counter}${PLACEHOLDER_SUFFIX}`; + preserved.placeholders.set(placeholder, content); + preserved.counter++; + return placeholder; +} + +/** + * Preserve HTML tags and entities before translation + * Replaces HTML content with placeholders to prevent corruption + * @param text The text containing HTML content + * @returns Object with processed text and preservation data + */ +export function preserveHtmlContent(text: string): { + processedText: string; + preserved: PreservedContent; +} { + const preserved: PreservedContent = { + placeholders: new Map(), + counter: 0, + }; + + let processedText = text; + + // Preserve HTML tags + processedText = processedText.replace(HTML_PATTERNS.TAGS, (match) => { + return createPlaceholder(match, preserved); + }); + + // Preserve HTML entities + processedText = processedText.replace(HTML_PATTERNS.ENTITIES, (match) => { + return createPlaceholder(match, preserved); + }); + + // Preserve colons to prevent conversion to Chinese colons + processedText = processedText.replace(/:/g, (match) => { + return createPlaceholder(match, preserved); + }); + + return { processedText, preserved }; +} + +/** + * Restore preserved HTML content after translation + * Replaces placeholders with original HTML content + * @param translatedText The translated text with placeholders + * @param preserved The preservation data from preserveHtmlContent + * @returns The text with restored HTML content + */ +export function restoreHtmlContent( + translatedText: string, + preserved: PreservedContent +): string { + let restoredText = translatedText; + + // Restore all preserved content + for (const [placeholder, originalContent] of preserved.placeholders) { + restoredText = restoredText.replace( + new RegExp(placeholder, "g"), + originalContent + ); + } + + // Additional cleanup: fix any remaining Chinese colons that might have been introduced + restoredText = restoredText.replace(/:/g, ":"); + + return restoredText; +} + +/** + * Check if text contains HTML content that should be preserved + * @param text The text to check + * @returns True if the text contains HTML content + */ +export function containsHtmlContent(text: string): boolean { + return HTML_PATTERNS.TAGS.test(text) || HTML_PATTERNS.ENTITIES.test(text); +} + +/** + * Sanitize HTML content while preserving structure + * Removes potentially dangerous HTML while keeping formatting tags + * @param text The text to sanitize + * @returns Sanitized text with safe HTML preserved + */ +export function sanitizeHtmlContent(text: string): string { + // Allow only safe formatting tags + const SAFE_TAGS = + /^<\/?(?:strong|b|em|i|u|code|pre|span|div|p|br|hr)(?:\s+[^>]*)?>/i; + + return text.replace(HTML_PATTERNS.TAGS, (match) => { + if (SAFE_TAGS.test(match)) { + return match; // Keep safe tags + } + return ""; // Remove unsafe tags + }); +} diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..31b991e --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1,20 @@ +/** + * DeepLX Library Module Exports + * Central export file for all DeepLX library modules and utilities + */ + +import { query } from "./query"; + +// Core functionality exports +export * from "./cache"; +export * from "./circuitBreaker"; +export * from "./errorHandler"; +export * from "./htmlUtils"; +export * from "./proxyManager"; +export * from "./rateLimit"; +export * from "./retryLogic"; +export * from "./textUtils"; +export * from "./types"; + +// Named export for the main query function +export { query }; diff --git a/src/lib/performance.ts b/src/lib/performance.ts new file mode 100644 index 0000000..a7ec067 --- /dev/null +++ b/src/lib/performance.ts @@ -0,0 +1,132 @@ +/** + * Performance monitoring and optimization utilities for DeepLX + * Provides request tracking, metrics collection, and performance analysis + */ + +/** + * Performance metrics interface for request tracking + */ +export interface PerformanceMetrics { + requestId: string; + endpoint: string; + startTime: number; + endTime?: number; + duration?: number; + cacheHit: boolean; + proxyUsed: boolean; + rateLimited: boolean; + retryCount: number; + success: boolean; +} + +/** + * In-memory metrics storage + */ +const metrics: PerformanceMetrics[] = []; + +/** + * Maximum number of metrics to keep in memory + */ +const MAX_METRICS = 1000; // Keep only recent metrics + +/** + * Start performance tracking for a request + * Creates a new performance tracking entry with unique ID + * @param endpoint The API endpoint being called + * @returns Unique request ID for tracking + */ +export function startPerformanceTracking(endpoint: string): string { + // Generate UUID in Workers-compatible way + const requestId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + metrics.push({ + requestId, + endpoint, + startTime: Date.now(), + cacheHit: false, + proxyUsed: false, + rateLimited: false, + retryCount: 0, + success: false, + }); + + // Clean up old metrics to prevent memory leaks + if (metrics.length > MAX_METRICS) { + metrics.splice(0, metrics.length - MAX_METRICS); + } + + return requestId; +} + +/** + * Update performance metrics for a tracked request + * Allows partial updates to existing metrics entries + * @param requestId The unique request ID to update + * @param updates Partial metrics object with values to update + * @returns void + */ +export function updatePerformanceMetrics( + requestId: string, + updates: Partial +): void { + const metric = metrics.find((m) => m.requestId === requestId); + if (metric) { + Object.assign(metric, updates); + } +} + +/** + * End performance tracking for a request + * Finalizes metrics collection and calculates duration + * @param requestId The unique request ID to finalize + * @param success Whether the request was successful + * @returns void + */ +export function endPerformanceTracking( + requestId: string, + success: boolean +): void { + const metric = metrics.find((m) => m.requestId === requestId); + if (metric) { + metric.endTime = Date.now(); + metric.duration = metric.endTime - metric.startTime; + metric.success = success; + } +} + +/** + * Generate aggregated performance statistics + * Analyzes recent metrics to provide insights into system performance + * @returns Performance statistics object or null if no metrics available + */ +export function getPerformanceStats() { + if (metrics.length === 0) return null; + + const recent = metrics.slice(-100); // Last 100 requests for analysis + const successful = recent.filter((m) => m.success); + const failed = recent.filter((m) => !m.success); + + const avgDuration = + successful.length > 0 + ? successful.reduce((sum, m) => sum + (m.duration || 0), 0) / + successful.length + : 0; + + return { + totalRequests: recent.length, + successfulRequests: successful.length, + failedRequests: failed.length, + successRate: + recent.length > 0 ? (successful.length / recent.length) * 100 : 0, + averageDuration: Math.round(avgDuration), + cacheHitRate: + recent.length > 0 + ? (recent.filter((m) => m.cacheHit).length / recent.length) * 100 + : 0, + proxyUsageRate: + recent.length > 0 + ? (recent.filter((m) => m.proxyUsed).length / recent.length) * 100 + : 0, + rateLimitedRequests: recent.filter((m) => m.rateLimited).length, + }; +} diff --git a/src/lib/proxyManager.ts b/src/lib/proxyManager.ts new file mode 100644 index 0000000..443c3dc --- /dev/null +++ b/src/lib/proxyManager.ts @@ -0,0 +1,85 @@ +/** + * IP rotation and proxy management for DeepLX + * Handles proxy selection, browser fingerprinting, and request routing + */ + +/** + * Collection of realistic browser user agents for fingerprinting + */ +const USER_AGENTS = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0", +]; + +/** + * Collection of realistic accept-language headers for regional diversity + */ +const ACCEPT_LANGUAGES = [ + "en-US,en;q=0.9", + "en-GB,en;q=0.9", + "en-US,en;q=0.8,es;q=0.6", + "en-US,en;q=0.9,fr;q=0.8", + "en-US,en;q=0.9,de;q=0.8", +]; + +/** + * Get available proxy endpoints from environment configuration + * Parses and validates proxy URLs from environment variables + * @param env Environment bindings containing proxy configuration + * @returns Promise - Array of available proxy endpoints + */ +export async function getProxyEndpoints(env: Env): Promise { + // Get proxy URLs from environment variables + const proxyUrls = env.PROXY_URLS + ? env.PROXY_URLS.split(",").map((url) => url.trim()) + : []; + + // Return validated proxy endpoints + return proxyUrls.map((url) => ({ + url, + })); +} + +/** + * Select a random proxy from available endpoints + * Implements random selection for load distribution + * @param env Environment bindings containing proxy configuration + * @returns Promise - Selected proxy or null if none available + */ +export async function selectProxy(env: Env): Promise { + try { + const proxies = await getProxyEndpoints(env); + + if (proxies.length === 0) { + return null; + } + + // Random selection for load balancing + const index = Math.floor(Math.random() * proxies.length); + return proxies[index]; + } catch (error) { + console.error("Failed to select proxy:", error); + return null; + } +} + +/** + * Generate realistic browser fingerprint headers + * Creates randomized headers to mimic real browser requests + * @returns Record - Object containing HTTP headers + */ +export function generateBrowserFingerprint(): Record { + return { + "User-Agent": USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)], + "Accept-Language": + ACCEPT_LANGUAGES[Math.floor(Math.random() * ACCEPT_LANGUAGES.length)], + Accept: "application/json, text/plain, */*", + "Accept-Encoding": "gzip, deflate, br", + DNT: "1", + Connection: "keep-alive", + "Upgrade-Insecure-Requests": "1", + }; +} diff --git a/src/lib/query.ts b/src/lib/query.ts new file mode 100644 index 0000000..eb72d6f --- /dev/null +++ b/src/lib/query.ts @@ -0,0 +1,488 @@ +/** + * Core translation query functionality for DeepLX API + * Handles communication with DeepL API endpoints with retry logic and rate limiting + */ + +import { + DEFAULT_RETRY_CONFIG, + PAYLOAD_LIMITS, + REQUEST_TIMEOUT, +} from "./config"; +import { API_URL, REQUEST_ALTERNATIVES } from "./const"; +import { createErrorResponse } from "./errorHandler"; +import { + containsHtmlContent, + preserveHtmlContent, + restoreHtmlContent, +} from "./htmlUtils"; +import { generateBrowserFingerprint, selectProxy } from "./proxyManager"; +import { checkCombinedRateLimit } from "./rateLimit"; +import { isRetryableError, RetryOptions, retryWithBackoff } from "./retryLogic"; +import { + Config, + createStandardResponse, + RawResponseParams, + RequestParams, + ResponseParams, +} from "./types"; + +/** + * Ensure language code is in uppercase format for consistent output + * @param langCode - Language code to convert + * @returns Language code in uppercase + * @private + */ +function ensureUppercaseLanguageCode(langCode: string): string { + if (!langCode || langCode.toLowerCase() === "auto") { + return "auto"; + } + return langCode.toUpperCase(); +} + +/** + * Normalize language code to ensure compatibility with DeepL API + * Accepts both lowercase and uppercase input, always returns uppercase + * @param langCode - Language code to normalize + * @returns Normalized language code in uppercase format for DeepL API + * @private + */ +function normalizeLanguageCode(langCode: string): string { + if (!langCode || langCode.toLowerCase() === "auto") { + return "auto"; + } + + const normalized = langCode.toLowerCase(); + + // Map common language variations to DeepL-supported codes + const languageMap: Record = { + chinese: "ZH", + english: "EN", + spanish: "ES", + french: "FR", + german: "DE", + italian: "IT", + japanese: "JA", + portuguese: "PT", + russian: "RU", + dutch: "NL", + polish: "PL", + swedish: "SV", + danish: "DA", + norwegian: "NB", + finnish: "FI", + czech: "CS", + slovak: "SK", + slovenian: "SL", + estonian: "ET", + latvian: "LV", + lithuanian: "LT", + hungarian: "HU", + romanian: "RO", + bulgarian: "BG", + greek: "EL", + turkish: "TR", + ukrainian: "UK", + korean: "KO", + indonesian: "ID", + }; + + // Check if it's a mapped language name + if (languageMap[normalized]) { + return languageMap[normalized]; // Return uppercase for DeepL API + } + + // Convert to uppercase for DeepL API + return normalized.toUpperCase(); +} + +/** + * Build request parameters for DeepL API + * Creates the structured request object with language settings + * @param sourceLang - Source language code (defaults to "auto") + * @param targetLang - Target language code (defaults to "en") + * @returns Structured request parameters object + * @private + */ + +function buildRequestParams(sourceLang = "auto", targetLang = "en") { + // Generate a more reliable ID using a simpler approach + const timestamp = Date.now(); + const randomComponent = Math.floor(Math.random() * 1000000); + // Ensure ID is within a safe range (8-9 digits) + const requestId = Math.floor( + (timestamp % 100000000) + (randomComponent % 100000000) + ); + + // Normalize language codes + const normalizedSourceLang = normalizeLanguageCode(sourceLang || "auto"); + const normalizedTargetLang = normalizeLanguageCode(targetLang || "en"); + + return { + jsonrpc: "2.0", + method: "LMT_handle_texts", + id: requestId, + params: { + texts: [{ text: "", requestAlternatives: REQUEST_ALTERNATIVES }], + timestamp: 0, + splitting: "newlines", + lang: { + source_lang_user_selected: normalizedSourceLang, + target_lang: normalizedTargetLang, + }, + }, + }; +} + +/** + * Count occurrences of letter 'i' in text + * Used for generating request timestamps + * @param translateText - The text to analyze + * @returns Number of 'i' characters in the text + * @private + */ +function countLetterI(translateText: string) { + return (translateText || "").split("i").length - 1; +} + +/** + * Generate timestamp based on letter count with improved stability + * Creates a modified timestamp for request uniqueness that follows DeepL's expected format + * @param letterCount Count of specific letters in text + * @returns Modified timestamp value + * @private + */ +function getTimestamp(letterCount: number) { + const timestamp = Date.now(); + + // Ensure we have a valid letter count + const safeLetterCount = Math.max(0, letterCount || 0); + + // If no letter count, return current timestamp + if (safeLetterCount === 0) { + return timestamp; + } + + // Apply DeepL's timestamp modification formula more carefully + const modValue = safeLetterCount + 1; + + // Ensure modValue is reasonable to prevent overflow + if (modValue <= 0 || modValue > 1000) { + return timestamp; + } + + try { + const modifiedTimestamp = timestamp - (timestamp % modValue) + modValue; + + // Validate the result is reasonable + if ( + modifiedTimestamp > 0 && + modifiedTimestamp <= Number.MAX_SAFE_INTEGER && + modifiedTimestamp >= timestamp - 1000 + ) { + // Should be close to original timestamp + return modifiedTimestamp; + } + } catch (error) { + // If any calculation fails, return original timestamp + console.warn("Timestamp calculation failed, using original:", error); + } + + return timestamp; +} + +/** + * Build complete request body for DeepL API with enhanced validation + * Assembles the full request with text, parameters, and timestamp + * @param data Request parameters containing text and languages + * @returns JSON string ready for API submission + * @private + */ +function buildRequestBody(data: RequestParams) { + // Validate input parameters + if (!data || !data.text || typeof data.text !== "string") { + throw new Error( + "Invalid request parameters: text is required and must be a string" + ); + } + + // Ensure text is not empty + const trimmedText = data.text; + if (!trimmedText) { + throw new Error("Invalid request parameters: text cannot be empty"); + } + + // Apply more conservative text length limits to prevent 413 errors + const maxTextLength = PAYLOAD_LIMITS.MAX_TEXT_LENGTH; + if (trimmedText.length > maxTextLength) { + throw new Error( + `Text too long. Maximum length is ${maxTextLength} characters to prevent payload size errors.` + ); + } + + // Validate and normalize language codes before building request + const sourceLang = data.source_lang || "auto"; + const targetLang = data.target_lang || "en"; + + // Log language normalization for debugging (only in debug mode) + if (typeof globalThis !== "undefined" && globalThis.DEBUG_MODE) { + if (sourceLang !== "auto") { + console.debug(`Source language normalized: ${sourceLang}`); + } + console.debug(`Target language normalized: ${targetLang}`); + } + + const requestData = buildRequestParams(sourceLang, targetLang); + requestData.params.texts = [ + { text: trimmedText, requestAlternatives: REQUEST_ALTERNATIVES }, + ]; + + const letterICount = countLetterI(trimmedText); + const timestamp = getTimestamp(letterICount); + + // Validate timestamp before assigning + if (timestamp <= 0 || !Number.isFinite(timestamp)) { + throw new Error("Invalid timestamp generated"); + } + + requestData.params.timestamp = timestamp; + + // Build the request string with proper formatting + let requestString = JSON.stringify(requestData); + + // Apply DeepL's specific formatting requirements + const requestId = requestData.id; + + // Use DeepL's method spacing logic - simplified and more reliable + if ((requestId + 5) % 29 === 0 || (requestId + 3) % 13 === 0) { + requestString = requestString.replace('"method":"', '"method" : "'); + } else { + requestString = requestString.replace('"method":"', '"method": "'); + } + + // Validate final payload size to prevent 413 errors + const payloadSize = new TextEncoder().encode(requestString).length; + if (payloadSize > PAYLOAD_LIMITS.MAX_REQUEST_SIZE) { + throw new Error( + `Request payload too large (${payloadSize} bytes). Maximum allowed is ${PAYLOAD_LIMITS.MAX_REQUEST_SIZE} bytes. Please reduce text length.` + ); + } + + return requestString; +} + +/** + * Main translation query function + * Handles the complete translation workflow with retry logic, rate limiting, and error handling + * @param params Translation request parameters (text, source language, target language) + * @param config Optional configuration including proxy settings and environment + * @returns Promise - Standardized response with translation result + */ +async function query( + params: RequestParams, + config?: Config & { env?: Env } +): Promise { + if (!params?.text) { + return createStandardResponse( + 400, + null, + undefined, + normalizeLanguageCode(params?.source_lang || "auto"), + normalizeLanguageCode(params?.target_lang || "en") + ); + } + + // Check if text contains HTML content and preserve it + const hasHtmlContent = containsHtmlContent(params.text); + let processedParams = params; + let htmlPreservation: any = null; + + if (hasHtmlContent) { + const preservation = preserveHtmlContent(params.text); + htmlPreservation = preservation.preserved; + processedParams = { + ...params, + text: preservation.processedText, + }; + } + + const retryOptions: RetryOptions = { + ...DEFAULT_RETRY_CONFIG, + isRetryable: isRetryableError, + }; + + try { + return await retryWithBackoff(async () => { + const proxy = config?.env ? await selectProxy(config.env) : null; + const endpoint = config?.proxyEndpoint ?? proxy?.url ?? API_URL; + + // Use comprehensive rate limit check - includes client and proxy backend limits + if (config?.env) { + const clientIP = config?.clientIP || "unknown"; + const rateLimitResult = await checkCombinedRateLimit( + clientIP, + endpoint, + config.env + ); + if (!rateLimitResult.allowed) { + const error = new Error( + rateLimitResult.reason || "Rate limit exceeded" + ); + (error as any).code = 429; + throw error; + } + } + + const fingerprint = generateBrowserFingerprint(); + + const makeRequest = async () => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); + + try { + const requestBody = buildRequestBody(processedParams); + + const response = await fetch(endpoint, { + headers: { + "Content-Type": "application/json; charset=utf-8", + ...fingerprint, + ...config?.customHeader, + }, + method: "POST", + body: requestBody, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + // If we get a 400 error, log the request body for debugging + if (!response.ok && response.status === 400) { + console.error(`400 error received. Request body was:`, requestBody); + } + + return response; + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === "AbortError") { + const timeoutError = new Error( + `Request timeout after ${ + REQUEST_TIMEOUT / 1000 + } seconds to ${endpoint}` + ); + (timeoutError as any).status = 408; + throw timeoutError; + } + throw error; + } + }; + + const response = await makeRequest(); + + if (!response.ok) { + let errorMessage = `Request failed with status ${response.status}`; + try { + const errorText = await response.text(); + if (errorText) { + errorMessage += `: ${errorText}`; + } + } catch { + // Ignore error when reading response body + } + + const error = new Error(errorMessage); + (error as any).status = response.status; + throw error; + } + + let result: RawResponseParams; + try { + result = (await response.json()) as RawResponseParams; + } catch (parseError) { + const error = new Error( + `Failed to parse JSON response from ${endpoint}: ${ + parseError instanceof Error + ? parseError.message + : String(parseError) + }` + ); + (error as any).code = 500; + throw error; + } + if ("error" in result && result.error) { + const errorCode = (result.error as any).code; + const errorMessage = + (result.error as any).message || "Unknown DeepL API error"; + + // Handle specific DeepL error codes + let enhancedMessage = errorMessage; + + switch (errorCode) { + case 1156049: + enhancedMessage = + "Invalid request format detected. This may be caused by: " + + "1) Incorrect JSON-RPC structure, " + + "2) Invalid request ID format, " + + "3) Malformed timestamp, or " + + "4) Unsupported language codes. " + + "Please verify your request parameters and try again."; + break; + case 1042912: + enhancedMessage = "Too many requests. Please try again later."; + break; + case 1042513: + enhancedMessage = "Request quota exceeded. Please try again later."; + break; + case 1042003: + enhancedMessage = + "Invalid authentication. Please check your API configuration."; + break; + default: + enhancedMessage = `DeepL API error: ${errorMessage} (Code: ${errorCode})`; + } + + const error = new Error(enhancedMessage); + (error as any).code = errorCode; + (error as any).originalMessage = errorMessage; + throw error; + } + + if ( + !result.result || + !result.result.texts || + !result.result.texts.length + ) { + const error = new Error("Invalid response structure from DeepL API"); + (error as any).code = 500; + throw error; + } + + let translatedText = result.result.texts[0].text; + + // Restore HTML content if it was preserved + if (hasHtmlContent && htmlPreservation) { + translatedText = restoreHtmlContent(translatedText, htmlPreservation); + } + + return createStandardResponse( + 200, + translatedText, + result.id, + (result.result.lang as string).toUpperCase(), + normalizeLanguageCode(params.target_lang) + ); + }, retryOptions); + } catch (error: any) { + const errorDetails = createErrorResponse(error, { + endpoint: config?.proxyEndpoint, + }); + + return createStandardResponse( + errorDetails.httpStatus, + null, + undefined, + normalizeLanguageCode(params.source_lang || "auto"), + normalizeLanguageCode(params.target_lang || "en") + ); + } +} + +export { buildRequestBody, normalizeLanguageCode, query }; diff --git a/src/lib/rateLimit.ts b/src/lib/rateLimit.ts new file mode 100644 index 0000000..477dc22 --- /dev/null +++ b/src/lib/rateLimit.ts @@ -0,0 +1,325 @@ +/** + * Token bucket rate limiting implementation for client IP and proxy management + * Provides comprehensive rate limiting with two-level caching for optimal performance + */ + +import { RATE_LIMIT_CONFIG, calculateDynamicRateLimits } from "./config"; +import { getProxyEndpoints } from "./proxyManager"; + +// Import types from worker-configuration for consistency +interface Env { + CACHE_KV: KVNamespace; + RATE_LIMIT_KV: KVNamespace; + ANALYTICS: AnalyticsEngineDataset; + PROXY_URLS?: string; + PROXY_WEIGHTS?: string; +} + +interface RateLimitEntry { + tokens: number; + lastRefill: number; +} + +/** + * Rate limiting result interface + */ +export interface RateLimitResult { + allowed: boolean; + reason?: string; +} + +/** + * Get dynamic rate limits based on available proxy endpoints + * @param env Environment bindings containing proxy configuration + * @returns Object containing calculated rate limits + */ +function getDynamicRateLimits(env: Env) { + const proxyEndpoints = getProxyEndpoints(env); + return calculateDynamicRateLimits(proxyEndpoints.length); +} + +/** + * Proxy backend rate limiting configuration constants + */ +const PROXY_TOKENS_PER_SECOND = RATE_LIMIT_CONFIG.PROXY_TOKENS_PER_SECOND; +const PROXY_MAX_TOKENS = RATE_LIMIT_CONFIG.PROXY_MAX_TOKENS; +const PROXY_REFILL_RATE = PROXY_TOKENS_PER_SECOND; + +/** + * In-memory cache for performance optimization + * Uses Map for fast lookups and TTL-based cleanup + */ +const rateLimitCache = new Map< + string, + { tokens: number; lastRefill: number; lastUpdate: number } +>(); +const CACHE_TTL = 5000; // 5 seconds TTL for cache entries + +/** + * Extract client IP address from request headers + * Supports Cloudflare and standard forwarded headers + * @param request The incoming request object + * @returns The client IP address or "unknown" if not found + */ +export function getClientIP(request: Request): string { + return ( + request.headers.get("CF-Connecting-IP") || + request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim() || + "unknown" + ); +} + +/** + * Check rate limit for a specific client IP using token bucket algorithm + * Implements two-level caching: in-memory cache and Cloudflare KV storage + * @param clientIP The client IP address to check + * @param env Environment bindings containing KV namespaces + * @returns Promise - True if request is allowed, false if rate limited + */ +async function checkRateLimit(clientIP: string, env: Env): Promise { + const key = `rate_limit:${clientIP}`; + const now = Date.now(); + + try { + const rateLimits = getDynamicRateLimits(env); + const maxTokens = rateLimits.TOKENS_PER_MINUTE; + + // Check in-memory cache + const cached = rateLimitCache.get(key); + let tokens = maxTokens; + let lastRefill = now; + + if (cached && now - cached.lastUpdate < CACHE_TTL) { + // Use cached data + const timePassed = (now - cached.lastRefill) / 1000; + tokens = Math.min( + maxTokens, + cached.tokens + timePassed * rateLimits.REFILL_RATE + ); + lastRefill = cached.lastRefill; + } else { + // Get data from KV + try { + const existing = (await env.RATE_LIMIT_KV.get( + key, + "json" + )) as RateLimitEntry | null; + if (existing) { + const timePassed = (now - existing.lastRefill) / 1000; + tokens = Math.min( + maxTokens, + existing.tokens + timePassed * rateLimits.REFILL_RATE + ); + lastRefill = existing.lastRefill; + } + } catch (kvError) { + // KV read failed, continue with in-memory cache only + // Error is silently handled to maintain service availability + } + } + + if (tokens < 1) { + // Update cache + rateLimitCache.set(key, { + tokens, + lastRefill: now, + lastUpdate: now, + }); + + // Async KV update + env.RATE_LIMIT_KV.put(key, JSON.stringify({ tokens, lastRefill: now }), { + expirationTtl: 3600, + }).catch(() => { + // KV update failed, continue silently + }); + + return false; + } + + // Consume one token + tokens -= 1; + + // Update cache + rateLimitCache.set(key, { + tokens, + lastRefill: now, + lastUpdate: now, + }); + + // Async KV update + env.RATE_LIMIT_KV.put(key, JSON.stringify({ tokens, lastRefill: now }), { + expirationTtl: 3600, + }).catch(() => { + // KV update failed, continue silently + }); + + return true; + } catch (error) { + // Rate limit check failed, allow request to maintain service availability + return true; + } +} + +/** + * Delay execution for specified number of seconds + * Used for implementing backoff strategies + * @param seconds - Number of seconds to delay + * @returns Promise that resolves after the specified delay + */ +export async function delayRequest(seconds: number): Promise { + return new Promise((resolve) => { + // Use a simple delay implementation for Workers environment + const start = Date.now(); + const checkTime = () => { + if (Date.now() - start >= seconds * 1000) { + resolve(); + } else { + // Use minimal delay to prevent blocking + Promise.resolve().then(checkTime); + } + }; + checkTime(); + }); +} + +/** + * Clean up expired cache entries to prevent memory leaks + * Performs on-demand cleanup when cache grows beyond threshold + * @private + */ +function cleanupCacheIfNeeded(): void { + // Only clean up when cache has more than 100 items to avoid frequent operations + if (rateLimitCache.size > 100) { + const now = Date.now(); + for (const [key, entry] of rateLimitCache.entries()) { + if (now - entry.lastUpdate > CACHE_TTL * 2) { + rateLimitCache.delete(key); + } + } + } +} + +/** + * Combined rate limit check for both client and proxy backend limits + * Provides comprehensive rate limiting across multiple dimensions + * @param clientIP - The client IP address to check + * @param endpoint - The endpoint URL to check (null for direct requests) + * @param env - Environment bindings containing KV namespaces + * @returns Promise - Object indicating if request is allowed and reason if not + */ +export async function checkCombinedRateLimit( + clientIP: string, + endpoint: string, + env: Env +): Promise { + // Clean up cache on demand + cleanupCacheIfNeeded(); + + // First check client rate limit + const clientAllowed = await checkRateLimit(clientIP, env); + if (!clientAllowed) { + return { allowed: false, reason: "Client rate limit exceeded" }; + } + + // If endpoint exists, check proxy rate limit + if (endpoint && endpoint !== "https://www2.deepl.com/jsonrpc") { + const proxyAllowed = await checkProxyRateLimit(endpoint, env); + if (!proxyAllowed) { + return { allowed: false, reason: "Proxy rate limit exceeded" }; + } + } + + return { allowed: true }; +} + +/** + * Efficient proxy backend rate limiting using token bucket algorithm + * Implements two-level caching to reduce KV latency and improve performance + * @param proxyUrl - The proxy URL to rate limit + * @param env - Environment bindings containing KV namespaces + * @returns Promise - True if request is allowed, false if rate limited + */ +export async function checkProxyRateLimit( + proxyUrl: string, + env: Env +): Promise { + const key = `proxy_rate_limit:${proxyUrl}`; + const now = Date.now(); + + try { + // First check in-memory cache for optimal performance + const cached = rateLimitCache.get(key); + let tokens = PROXY_MAX_TOKENS; + let lastRefill = now; + + if (cached && now - cached.lastUpdate < CACHE_TTL) { + // Use cached data if still valid + const timePassed = (now - cached.lastRefill) / 1000; + tokens = Math.min( + PROXY_MAX_TOKENS, + cached.tokens + timePassed * PROXY_REFILL_RATE + ); + lastRefill = cached.lastRefill; + } else { + // Fallback to KV storage, but don't block request on failure + try { + const existing = (await env.RATE_LIMIT_KV.get( + key, + "json" + )) as RateLimitEntry | null; + if (existing) { + const timePassed = (now - existing.lastRefill) / 1000; + tokens = Math.min( + PROXY_MAX_TOKENS, + existing.tokens + timePassed * PROXY_REFILL_RATE + ); + lastRefill = existing.lastRefill; + } + } catch (kvError) { + // KV read failed, continue using default or cached values + // Error is silently handled to maintain service availability + } + } + + // Check if request should be rate limited + if (tokens < 1) { + // Update cache with current state + rateLimitCache.set(key, { + tokens, + lastRefill: now, + lastUpdate: now, + }); + + // Async KV update without blocking response + env.RATE_LIMIT_KV.put(key, JSON.stringify({ tokens, lastRefill: now }), { + expirationTtl: 3600, + }).catch(() => { + // KV update failed, continue silently + }); + + return false; + } + + // Consume one token + tokens -= 1; + + // Update cache with new token count + rateLimitCache.set(key, { + tokens, + lastRefill: now, + lastUpdate: now, + }); + + // Async KV update without blocking response + env.RATE_LIMIT_KV.put(key, JSON.stringify({ tokens, lastRefill: now }), { + expirationTtl: 3600, + }).catch(() => { + // KV update failed, continue silently + }); + + return true; + } catch (error) { + // Proxy rate limit check failed, allow request to maintain service availability + return true; + } +} diff --git a/src/lib/retryLogic.ts b/src/lib/retryLogic.ts new file mode 100644 index 0000000..26fe9ee --- /dev/null +++ b/src/lib/retryLogic.ts @@ -0,0 +1,138 @@ +/** + * Exponential backoff retry logic for DeepLX operations + * Provides robust error handling with configurable retry strategies + */ + +import { delayRequest } from "./rateLimit"; + +/** + * Retry options configuration interface + */ +export interface RetryOptions { + maxRetries: number; + initialDelay: number; + backoffFactor: number; + isRetryable: (error: any) => boolean; +} + +/** + * Execute operation with exponential backoff retry strategy + * Implements intelligent retry logic with configurable backoff timing + * @param operation The async operation to retry + * @param options Retry configuration options + * @returns Promise - Result of the successful operation + * @throws Will throw the last error if all retries are exhausted + */ +export async function retryWithBackoff( + operation: () => Promise, + options: RetryOptions +): Promise { + const { maxRetries, initialDelay, backoffFactor, isRetryable } = options; + let lastError: Error | undefined; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await operation(); + } catch (error: any) { + lastError = error; + if (isRetryable(error)) { + const delay = initialDelay * Math.pow(backoffFactor, attempt); + // Use delayRequest for Cloudflare Workers compatibility + await delayRequest(delay / 1000); // Convert to seconds + } else { + // Non-retryable error, fail immediately + throw error; + } + } + } + + // All retries exhausted, throw the last error + throw lastError; +} + +/** + * Enhanced retry logic specifically for DeepL API rate limiting + * Implements intelligent backoff with exponential delays for 429 errors + * @param operation The operation to retry + * @param maxRetries Maximum number of retry attempts (default: 3) + * @returns Promise - Result of the successful operation + */ +export async function retryWithRateLimit( + operation: () => Promise, + maxRetries: number = 3 +): Promise { + const retryOptions: RetryOptions = { + maxRetries, + initialDelay: 2000, // Start with 2 seconds for rate limit errors + backoffFactor: 2.5, // More aggressive backoff for rate limits + isRetryable: (error: any) => { + // Specifically retry on rate limit (429) and server errors + if (error?.status === 429 || error?.code === 429) { + return true; + } + if (error?.status >= 500 && error?.status < 600) { + return true; + } + // Network errors + if ( + error?.name === "AbortError" || + (error?.name === "TypeError" && error?.message?.includes("fetch")) + ) { + return true; + } + return false; + }, + }; + + return retryWithBackoff(operation, retryOptions); +} + +/** + * Smart delay calculation for rate limit scenarios + * Implements recommended delays based on DeepL API documentation + * @param attempt - Current retry attempt number (0-based) + * @param isRateLimit - Whether this is specifically a rate limit error + * @returns number - Delay in milliseconds + */ +export function calculateSmartDelay( + attempt: number, + isRateLimit: boolean = false +): number { + if (isRateLimit) { + // For rate limits, use longer delays as recommended in documentation + // 60 seconds base delay with exponential backoff + return Math.min(60000 * Math.pow(1.5, attempt), 300000); // Max 5 minutes + } else { + // For other errors, use standard exponential backoff + return Math.min(1000 * Math.pow(2, attempt), 30000); // Max 30 seconds + } +} + +/** + * Determine if an error is worth retrying + * Analyzes error types and status codes to make retry decisions + * @param error - The error object to analyze + * @returns boolean - True if the error is retryable + */ +export function isRetryableError(error: any): boolean { + // Network or timeout errors that are typically transient + if ( + error?.name === "AbortError" || + (error?.name === "TypeError" && error?.message?.includes("fetch")) + ) { + return true; + } + + // HTTP status codes that indicate temporary failures + if (error?.status) { + // Retry on rate limiting, server errors, and timeouts + return [408, 429, 500, 502, 503, 504].includes(error.status); + } + + // DeepL API specific error codes that might be temporary + if (error?.code) { + return [429, 500, 502, 503, 504].includes(error.code); + } + + return false; +} diff --git a/src/lib/security.ts b/src/lib/security.ts new file mode 100644 index 0000000..40faafd --- /dev/null +++ b/src/lib/security.ts @@ -0,0 +1,122 @@ +/** + * Security middleware for DeepLX API + * Provides security headers, CORS configuration, and request sanitization + */ + +/** + * Security headers configuration + */ +const SECURITY_HEADERS = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "1; mode=block", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "geolocation=(), microphone=(), camera=()", + "Content-Security-Policy": + "default-src 'self'; script-src 'none'; object-src 'none';", +}; + +/** + * CORS configuration + */ +const CORS_CONFIG = { + "Access-Control-Allow-Origin": "*", // Consider restricting in production + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", +}; + +/** + * Add security headers to response + * @param response The response object to modify + * @returns Modified response with security headers + */ +export function addSecurityHeaders(response: any): any { + const newHeaders = new Map(response.headers); + + // Add security headers + Object.entries(SECURITY_HEADERS).forEach(([key, value]) => { + newHeaders.set(key, value); + }); + + // Add CORS headers for API endpoints + Object.entries(CORS_CONFIG).forEach(([key, value]) => { + newHeaders.set(key, value); + }); + + return { + ...response, + headers: newHeaders, + }; +} + +/** + * Handle CORS preflight requests + * @param c Hono context + * @returns CORS preflight response + */ +export function handleCORSPreflight(c: any) { + return c.text("", 200, CORS_CONFIG); +} + +/** + * Validate language codes + * @param langCode The language code to validate + * @returns Original language code if valid, or null if invalid + */ +export function validateLanguageCode(langCode: string): string | null { + if (typeof langCode !== "string") { + return null; + } + + // Check if language code matches expected pattern (alphanumeric and hyphens only) + const normalized = langCode.toLowerCase().trim(); + + if (normalized.length < 2 || normalized.length > 5) { + return null; + } + + // Only allow valid language code characters + if (!/^[a-z0-9-]+$/.test(normalized)) { + return null; + } + + return normalized; +} + +/** + * Rate limit by IP with enhanced security + * @param request The incoming request + * @returns Validated client IP or null if suspicious + */ +export function getSecureClientIP(request: any): string | null { + // Get IP from Cloudflare headers first (most trusted) + const cfIP = request.headers.get("CF-Connecting-IP"); + if (cfIP && isValidIP(cfIP)) { + return cfIP; + } + + // Fallback to X-Forwarded-For (less trusted) + const forwardedFor = request.headers.get("X-Forwarded-For"); + if (forwardedFor) { + const firstIP = forwardedFor.split(",")[0]?.trim(); + if (firstIP && isValidIP(firstIP)) { + return firstIP; + } + } + + return null; // Return null for suspicious requests +} + +/** + * Basic IP address validation + * @param ip The IP address to validate + * @returns True if IP appears valid + */ +function isValidIP(ip: string): boolean { + // Basic IPv4 and IPv6 validation + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; + const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/; + + return ipv4Regex.test(ip) || ipv6Regex.test(ip); +} diff --git a/src/lib/securityConfig.ts b/src/lib/securityConfig.ts new file mode 100644 index 0000000..0c0edb5 --- /dev/null +++ b/src/lib/securityConfig.ts @@ -0,0 +1,61 @@ +/** + * Security configuration for DeepLX API + * Centralizes all security-related settings and policies + */ + +/** + * Security policy configuration + */ +export const SECURITY_CONFIG = { + // Input validation limits + MAX_INPUT_LENGTH: 30000, + + // Rate limiting + STRICT_RATE_LIMITING: true, + FAIL_SAFE_ON_ERROR: false, // Changed to false for better security + + // CORS settings + CORS_ORIGINS: ["*"], // Restrict in production + CORS_METHODS: ["GET", "POST", "OPTIONS"], + CORS_HEADERS: ["Content-Type", "Authorization"], + + // Debug settings + ENABLE_DEBUG_IN_PRODUCTION: false, + SHOW_STACK_TRACES: false, + LOG_CLIENT_IPS: true, + + // Headers + SECURITY_HEADERS: { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "1; mode=block", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "geolocation=(), microphone=(), camera=()", + "Content-Security-Policy": + "default-src 'self'; script-src 'none'; object-src 'none';", + }, +}; + +/** + * Validation patterns for different input types + */ +export const VALIDATION_PATTERNS = { + // Language code validation (ISO 639-1/639-2) + LANGUAGE_CODE: /^[a-z]{2,3}(-[A-Z]{2})?$/, + + // IP address validation + IPV4: /^(\d{1,3}\.){3}\d{1,3}$/, + IPV6: /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/, +}; + +/** + * Error messages (generic to avoid information disclosure) + */ +export const SECURITY_ERRORS = { + INVALID_INPUT: "Invalid input format", + INPUT_TOO_LONG: "Input exceeds maximum length", + RATE_LIMITED: "Rate limit exceeded", + UNAUTHORIZED: "Unauthorized access", + FORBIDDEN: "Access forbidden", + VALIDATION_FAILED: "Input validation failed", +}; diff --git a/src/lib/textUtils.ts b/src/lib/textUtils.ts new file mode 100644 index 0000000..c563abc --- /dev/null +++ b/src/lib/textUtils.ts @@ -0,0 +1,86 @@ +/** + * Text processing utilities for handling large payloads + * Provides text chunking and validation to prevent 413 errors + */ + +import { PAYLOAD_LIMITS } from "./config"; + +/** + * Estimate the payload size for a given text + * @param text The text to estimate + * @param sourceLang Source language + * @param targetLang Target language + * @returns Estimated payload size in bytes + */ +export function estimatePayloadSize( + text: string, + sourceLang: string = "auto", + targetLang: string = "en" +): number { + // Create a sample request structure to estimate size + const sampleRequest = { + jsonrpc: "2.0", + method: "LMT_handle_texts", + id: 123456789, + params: { + texts: [{ text, requestAlternatives: 0 }], + timestamp: Date.now(), + splitting: "newlines", + lang: { + source_lang_user_selected: sourceLang, + target_lang: targetLang, + }, + }, + }; + + // Use a simple byte estimation (roughly accurate for UTF-8) + const jsonString = JSON.stringify(sampleRequest); + return jsonString.length * 1.2; // Add 20% buffer for formatting variations +} + +/** + * Check if text would create a payload that's too large + * @param text The text to check + * @param sourceLang Source language + * @param targetLang Target language + * @returns true if payload would be too large + */ +export function isPayloadTooLarge( + text: string, + sourceLang: string = "auto", + targetLang: string = "en" +): boolean { + const estimatedSize = estimatePayloadSize(text, sourceLang, targetLang); + return estimatedSize > PAYLOAD_LIMITS.MAX_REQUEST_SIZE; +} + +/** + * Validate text length and suggest appropriate action + * @param text The text to validate + * @returns Validation result with suggestions + */ +export function validateTextLength(text: string): { + isValid: boolean; + suggestedAction: string; +} { + const trimmedText = text; + + if (trimmedText.length === 0) { + return { + isValid: false, + suggestedAction: "Text cannot be empty", + }; + } + + if (trimmedText.length <= PAYLOAD_LIMITS.MAX_TEXT_LENGTH) { + return { + isValid: true, + suggestedAction: "Text is within limits", + }; + } + + return { + isValid: false, + suggestedAction: "Text is too long. Please reduce text length.", + }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..0449eb4 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,107 @@ +/** + * Type definitions for DeepLX translation service + * Provides comprehensive type safety for API operations and data structures + */ + +/** + * Source language type (includes auto-detection) + */ +export type SourceLang = string | "auto"; + +/** + * Target language type (excludes auto-detection) + */ +export type TargetLang = string; + +/** + * Raw response parameters from DeepL API + * Represents the exact structure returned by the translation service + */ +export type RawResponseParams = { + jsonrpc: string; + id: number; + result: { + texts: { + text: string; + }[]; + lang: string; + lang_is_confident: boolean; + detectedLanguages: { unsupported: number } & Record; + }; +}; + +/** + * Request parameters for translation operations + */ +export type RequestParams = { + text: string; + source_lang: SourceLang; + target_lang: TargetLang; +}; + +/** + * Standardized response parameters for DeepLX API + * Note: Language codes in responses are always returned in uppercase when code is 200 + * When code is not 200, source_lang and target_lang are null + */ +export type ResponseParams = { + code: number; + data: string | null; + id: number; + source_lang: string | null; // Always uppercase in responses when code is 200, null otherwise + target_lang: string | null; // Always uppercase in responses when code is 200, null otherwise +}; + +/** + * Configuration options for translation operations + */ +/** + * Configuration options for translation operations + */ +export type Config = { + proxyEndpoint?: string; + customHeader?: Record; + clientIP?: string; +}; + +/** + * Helper function to create standardized response format + * Ensures all responses follow the same structure + * When HTTP status code is not 200, everything is null except the unique request identifier + */ +export function createStandardResponse( + code: number, + data: string | null, + id?: number, + source_lang?: string, + target_lang?: string +): ResponseParams { + const responseId = id || Math.floor(Math.random() * 10000000000); + + // If status code is not 200, set everything to null except id + if (code !== 200) { + return { + code, + data: null, + id: responseId, + source_lang: null, + target_lang: null, + }; + } + + // For successful responses (code 200), return with actual values + return { + code, + data, + id: responseId, + source_lang: (source_lang || "AUTO").toUpperCase(), + target_lang: (target_lang || "EN").toUpperCase(), + }; +} + +/** + * Note: The following interfaces are defined globally in worker-configuration.d.ts: + * - ProxyEndpoint: Proxy endpoint configuration + * - CacheEntry: Cache entry structure + * - RateLimitEntry: Rate limiting data structure + */ diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..c09f5b8 --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,81 @@ +/** + * Input validation and sanitization utilities for DeepLX API + * Provides comprehensive validation for translation requests + */ + +/** + * Configuration constants for validation + */ +const MAX_TEXT_LENGTH = 50000; // 50KB maximum text length + +/** + * Result interface for validation operations + */ +export interface ValidationResult { + isValid: boolean; + errors: string[]; + sanitizedInput?: any; +} + +/** + * Validate a single translation request + * Performs comprehensive validation on request structure and parameters + * @param input The request input to validate + * @returns ValidationResult - Validation result with errors and sanitized input + */ + +export function validateTranslationRequest(input: any): ValidationResult { + const errors: string[] = []; + + // Check if input is an object + if (!input || typeof input !== "object") { + return { + isValid: false, + errors: ["Request body must be a valid JSON object"], + }; + } + + // Validate text field + if (!input.text) { + errors.push("Text field is required"); + } else if (typeof input.text !== "string") { + errors.push("Text field must be a string"); + } else if (input.text.length === 0) { + errors.push("Text field cannot be empty"); + } else if (input.text.length > MAX_TEXT_LENGTH) { + errors.push( + `Text field exceeds maximum length of ${MAX_TEXT_LENGTH} characters` + ); + } + + // Validate source_lang (basic format validation) + if (input.source_lang && typeof input.source_lang !== "string") { + errors.push("Source language must be a string"); + } else if (input.source_lang && input.source_lang.length < 2) { + errors.push("Source language code is too short"); + } + + // Validate target_lang (required and basic format validation) + if (!input.target_lang) { + errors.push("Target language is required"); + } else if (typeof input.target_lang !== "string") { + errors.push("Target language must be a string"); + } else if (input.target_lang.length < 2) { + errors.push("Target language code is too short"); + } else if (input.target_lang.toLowerCase() === "auto") { + errors.push("Target language cannot be 'auto'"); + } + + // Create sanitized input object + const sanitizedInput = { + text: typeof input.text === "string" ? input.text : "", + source_lang: input.source_lang ? input.source_lang.toLowerCase() : "auto", + target_lang: input.target_lang ? input.target_lang.toLowerCase() : "en", + }; + + return { + isValid: errors.length === 0, + errors, + sanitizedInput: errors.length === 0 ? sanitizedInput : undefined, + }; +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts new file mode 100644 index 0000000..263dc63 --- /dev/null +++ b/src/types/global.d.ts @@ -0,0 +1,58 @@ +// Global type definitions for DeepLX + +declare global { + interface Env { + CACHE_KV: KVNamespace; + RATE_LIMIT_KV: KVNamespace; + ANALYTICS: AnalyticsEngineDataset; + PROXY_URLS?: string; + DEBUG_MODE?: string; // Added for debug endpoint control + } + + interface ScheduledEvent { + readonly cron: string; + readonly scheduledTime: number; + readonly type: "scheduled"; + waitUntil(promise: Promise): void; + } + + interface ExecutionContext { + waitUntil(promise: Promise): void; + passThroughOnException(): void; + } +} + +// Additional Worker types that might be missing +declare const AbortController: { + prototype: AbortController; + new (): AbortController; +}; + +declare interface AbortController { + readonly signal: AbortSignal; + abort(): void; +} + +declare const AbortSignal: { + prototype: AbortSignal; + new (): AbortSignal; + abort(): AbortSignal; + timeout(milliseconds: number): AbortSignal; +}; + +declare interface AbortSignal extends EventTarget { + readonly aborted: boolean; + readonly reason: any; + addEventListener( + type: "abort", + listener: (this: AbortSignal, ev: Event) => any, + options?: boolean | AddEventListenerOptions + ): void; + removeEventListener( + type: "abort", + listener: (this: AbortSignal, ev: Event) => any, + options?: boolean | EventListenerOptions + ): void; +} + +export {}; diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..41c479d --- /dev/null +++ b/tests/README.md @@ -0,0 +1,254 @@ +# DeepLX Test Suite + +This directory contains comprehensive tests for DeepLX. The test suite is designed to ensure reliability, performance, and security of the translation service. + +## Test Structure + +``` +tests/ +├── lib/ # Unit tests for library modules +│ ├── query.test.ts # Core translation functionality +│ ├── cache.test.ts # Caching system tests +│ ├── rateLimit.test.ts # Rate limiting tests +│ ├── proxyManager.test.ts # Proxy management tests +│ ├── circuitBreaker.test.ts # Circuit breaker tests +│ ├── retryLogic.test.ts # Retry mechanism tests +│ ├── security.test.ts # Security middleware tests +│ ├── validation.test.ts # Input validation tests +│ ├── textUtils.test.ts # Text processing utilities +│ ├── types.test.ts # Type definitions and utilities +│ └── errorHandler.test.ts # Error handling tests +├── integration/ # Integration tests +│ └── translation.test.ts # End-to-end translation workflows +├── performance/ # Performance and load tests +│ └── load.test.ts # Load testing and benchmarks +├── utils/ # Test utilities and helpers +│ └── testHelpers.ts # Common test utilities +├── setup.ts # Jest setup configuration +└── README.md # This file +``` + +## Test Categories + +### Unit Tests (`tests/lib/`) + +Unit tests focus on individual modules and functions in isolation: + +- **Query Module**: Tests core translation functionality, request building, and API communication +- **Cache Module**: Tests translation caching, cache key generation, and cache invalidation +- **Rate Limiting**: Tests token bucket algorithm, IP-based limiting, and rate limit recovery +- **Proxy Management**: Tests proxy selection, and failover logic +- **Circuit Breaker**: Tests failure detection, circuit states, and recovery mechanisms +- **Retry Logic**: Tests exponential backoff, retry conditions, and failure handling +- **Security**: Tests input sanitization, CORS handling, and IP validation +- **Validation**: Tests request validation, parameter sanitization, and error reporting +- **Text Utils**: Tests text chunking, payload estimation, and length validation +- **Error Handling**: Tests error response formatting and error categorization + +### Integration Tests (`tests/integration/`) + +Integration tests verify complete workflows and component interactions: + +- **End-to-end Translation**: Complete translation workflows with caching and rate limiting +- **Proxy Failover**: Proxy selection and automatic failover scenarios +- **Security Integration**: Input validation and sanitization in real workflows +- **Performance Integration**: Response time and resource utilization under load + +### Performance Tests (`tests/performance/`) + +Performance tests ensure the service meets performance requirements: + +- **Response Time Benchmarks**: Measure translation response times +- **Memory Usage**: Monitor memory consumption and leak detection +- **Concurrent Requests**: Test handling of simultaneous requests +- **Load Testing**: Stress testing with high request volumes +- **Resource Utilization**: CPU and memory usage under various loads + +## Running Tests + +### All Tests + +```bash +npm test +``` + +### Test Categories + +```bash +# Unit tests only +npm run test:unit + +# Integration tests only +npm run test:integration + +# Performance tests only +npm run test:performance + +# With coverage report +npm run test:coverage + +# Continuous integration mode +npm run test:ci +``` + +### Development Mode + +```bash +# Watch mode for development +npm run test:watch + +# Verbose output for debugging +npm run test:verbose + +# Debug mode with detailed output +npm run test:debug +``` + +## Test Configuration + +### Jest Configuration (`jest.config.js`) + +The test suite uses Jest with the following key configurations: + +- **Environment**: Miniflare for Cloudflare Workers simulation +- **TypeScript**: ts-jest for TypeScript support +- **Coverage**: Comprehensive coverage reporting +- **Mocking**: Extensive mocking of external dependencies + +### Environment Setup (`tests/setup.ts`) + +Global test setup includes: + +- Mock environment creation +- Global utilities and matchers +- Console output management +- Request/Response mocking + +## Writing Tests + +### Test Structure + +Follow this structure for new tests: + +```typescript +describe('Module Name', () => { + let mockEnv: Env; + + beforeEach(() => { + mockEnv = createMockEnv(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('function name', () => { + it('should handle normal case', () => { + // Test implementation + }); + + it('should handle error case', () => { + // Error test implementation + }); + }); +}); +``` + +### Best Practices + +1. **Isolation**: Each test should be independent and not rely on other tests +2. **Mocking**: Mock external dependencies and focus on the unit under test +3. **Coverage**: Aim for high code coverage but focus on meaningful tests +4. **Error Cases**: Test both success and failure scenarios +5. **Edge Cases**: Include boundary conditions and edge cases +6. **Performance**: Include performance assertions where relevant + +### Custom Matchers + +The test suite includes custom Jest matchers: + +```typescript +expect(response).toBeValidTranslationResponse(); +expect(response).toBeValidErrorResponse(); +``` + +### Test Utilities + +Use the provided test utilities for common operations: + +```typescript +import { + createMockTranslationResponse, + createMockErrorResponse, + createTestEnvironment, + expectValidTranslationResponse +} from './utils/testHelpers'; +``` + +## Continuous Integration + +The test suite runs automatically on: + +- **Push to main/develop branches** +- **Pull requests to main branch** +- **Multiple Node.js versions** (18.x, 20.x) + +### CI Pipeline + +1. **Lint**: TypeScript type checking +2. **Unit Tests**: All library module tests +3. **Integration Tests**: End-to-end workflow tests +4. **Performance Tests**: Load and performance benchmarks +5. **Coverage**: Code coverage reporting + +## Coverage Requirements + +The test suite aims for: + +- **Line Coverage**: > 90% +- **Function Coverage**: > 95% +- **Branch Coverage**: > 85% +- **Statement Coverage**: > 90% + +## Debugging Tests + +### Common Issues + +1. **Async/Await**: Ensure all async operations are properly awaited +2. **Mocking**: Verify mocks are properly reset between tests +3. **Timeouts**: Increase timeout for slow operations +4. **Memory**: Clear references to prevent memory leaks + +### Debug Commands + +```bash +# Run specific test file +npm test -- query.test.ts + +# Run with debug output +npm run test:debug + +# Run single test +npm test -- --testNamePattern="should handle successful translation" +``` + +## Contributing + +When adding new features: + +1. **Write tests first** (TDD approach recommended) +2. **Update existing tests** if behavior changes +3. **Add integration tests** for new workflows +4. **Include performance tests** for performance-critical features +5. **Update documentation** including this README + +## Monitoring and Alerts + +The test suite includes monitoring for: + +- **Test execution time trends** +- **Flaky test detection** +- **Coverage regression alerts** +- **Performance regression detection** + +For questions or issues with the test suite, please refer to the main repository documentation or create an issue in the repository. diff --git a/tests/index.test.ts b/tests/index.test.ts new file mode 100644 index 0000000..040f30f --- /dev/null +++ b/tests/index.test.ts @@ -0,0 +1,333 @@ +/** + * Tests for main application endpoints + */ + +// Mock the main application +jest.mock("../src/lib", () => ({ + clearMemoryCache: jest.fn(), + generateCacheKey: jest.fn().mockReturnValue("cache:test-key"), + getCachedTranslation: jest.fn(), + setCachedTranslation: jest.fn(), + query: jest.fn(), +})); + +jest.mock("../src/lib/security", () => ({ + getSecureClientIP: jest.fn().mockReturnValue("192.168.1.1"), + handleCORSPreflight: jest.fn().mockReturnValue("CORS response"), + validateLanguageCode: jest + .fn() + .mockImplementation((code) => code?.toLowerCase()), +})); + +jest.mock("../src/lib/env", () => ({ + validateEnvironment: jest.fn().mockReturnValue([]), +})); + +jest.mock("../src/lib/performance", () => ({ + getPerformanceStats: jest + .fn() + .mockReturnValue({ requests: 100, avgResponseTime: 250 }), +})); + +describe("Main Application", () => { + let app: any; + let mockEnv: Env; + + beforeEach(() => { + mockEnv = createMockEnv(); + + // Reset all mocks + jest.clearAllMocks(); + + // Import the app after mocks are set up + delete require.cache[require.resolve("../src/index")]; + const indexModule = require("../src/index"); + app = indexModule.default; + }); + + describe("GET /", () => { + it("should redirect to GitHub repository", async () => { + const request = new Request("http://localhost/", { method: "GET" }); + const response = await app.fetch(request, mockEnv); + + expect(response.status).toBe(302); + }); + }); + + describe("GET /translate", () => { + it("should return message for GET requests", async () => { + const request = new Request("http://localhost/translate", { + method: "GET", + }); + const response = await app.fetch(request, mockEnv); + + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toContain("Please use POST method"); + }); + }); + + describe("POST /debug", () => { + it("should return 404 when debug mode is disabled", async () => { + mockEnv.DEBUG_MODE = "false"; + + const request = new Request("http://localhost/debug", { + method: "POST", + body: JSON.stringify({ text: "Hello world" }), + }); + const response = await app.fetch(request, mockEnv); + + expect(response.status).toBe(404); + }); + + it("should provide debug information when enabled", async () => { + mockEnv.DEBUG_MODE = "true"; + + const request = new Request("http://localhost/debug", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }), + }); + const response = await app.fetch(request, mockEnv); + + expect(response.status).toBe(200); + const result = await response.json(); + expect(result.code).toBe(200); + + const debugInfo = JSON.parse(result.data); + expect(debugInfo.status).toBe("Request format is valid"); + expect(debugInfo.client_ip).toBe("192.168.1.1"); + expect(debugInfo.generated_request).toBeDefined(); + }); + + it("should handle missing text parameter", async () => { + mockEnv.DEBUG_MODE = "true"; + + const request = new Request("http://localhost/debug", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const response = await app.fetch(request, mockEnv); + + expect(response.status).toBe(400); + }); + + it("should handle invalid JSON", async () => { + mockEnv.DEBUG_MODE = "true"; + + const request = new Request("http://localhost/debug", { + method: "POST", + body: "invalid json", + }); + const response = await app.fetch(request, mockEnv); + + expect(response.status).toBe(400); + }); + }); + + describe("POST /translate", () => { + it("should handle successful translation", async () => { + const { query, getCachedTranslation } = require("../src/lib"); + + getCachedTranslation.mockResolvedValueOnce(null); + query.mockResolvedValueOnce({ + code: 200, + data: "你好世界", + id: 12345, + source_lang: "EN", + target_lang: "ZH", + }); + + const request = new Request("http://localhost/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }), + }); + const response = await app.fetch(request, mockEnv); + + expect(response.status).toBe(200); + const result = await response.json(); + expect(result.code).toBe(200); + expect(result.data).toBe("你好世界"); + }); + + it("should return cached translation when available", async () => { + const { getCachedTranslation } = require("../src/lib"); + + getCachedTranslation.mockResolvedValueOnce({ + data: "你好世界", + timestamp: Date.now(), + source_lang: "EN", + target_lang: "ZH", + id: 12345, + }); + + const request = new Request("http://localhost/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }), + }); + const response = await app.fetch(request, mockEnv); + + expect(response.status).toBe(200); + const result = await response.json(); + expect(result.code).toBe(200); + expect(result.data).toBe("你好世界"); + }); + + it("should handle missing text parameter", async () => { + const request = new Request("http://localhost/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const response = await app.fetch(request, mockEnv); + + expect(response.status).toBe(400); + }); + + it("should handle invalid JSON", async () => { + const request = new Request("http://localhost/translate", { + method: "POST", + body: "invalid json", + }); + const response = await app.fetch(request, mockEnv); + + expect(response.status).toBe(400); + }); + + it("should handle empty text", async () => { + const request = new Request("http://localhost/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: " ", + target_lang: "zh", + }), + }); + const response = await app.fetch(request, mockEnv); + + expect(response.status).toBe(400); + }); + + it("should handle text that is too long", async () => { + const request = new Request("http://localhost/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: "a".repeat(100000), + target_lang: "zh", + }), + }); + const response = await app.fetch(request, mockEnv); + + expect(response.status).toBe(200); // Should truncate, not fail + }); + + it("should handle translation errors", async () => { + const { query, getCachedTranslation } = require("../src/lib"); + + getCachedTranslation.mockResolvedValueOnce(null); + query.mockResolvedValueOnce({ + code: 500, + data: null, + }); + + const request = new Request("http://localhost/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: "Hello world", + target_lang: "zh", + }), + }); + const response = await app.fetch(request, mockEnv); + + expect(response.status).toBe(500); + }); + + it("should use default language codes", async () => { + const { query, getCachedTranslation } = require("../src/lib"); + + getCachedTranslation.mockResolvedValueOnce(null); + query.mockResolvedValueOnce({ + code: 200, + data: "Hello world", + id: 12345, + source_lang: "AUTO", + target_lang: "EN", + }); + + const request = new Request("http://localhost/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: "Hello world", + }), + }); + const response = await app.fetch(request, mockEnv); + + expect(response.status).toBe(200); + }); + }); + + describe("OPTIONS requests", () => { + it("should handle CORS preflight requests", async () => { + const request = new Request("http://localhost/translate", { + method: "OPTIONS", + }); + const response = await app.fetch(request, mockEnv); + + expect(response.status).toBe(200); + }); + }); + + describe("Scheduled events", () => { + it("should handle scheduled maintenance", async () => { + const { clearMemoryCache } = require("../src/lib"); + const indexModule = require("../src/index"); + + const mockEvent = { scheduledTime: Date.now() } as ScheduledEvent; + const mockContext = { + waitUntil: jest.fn(), + passThroughOnException: jest.fn(), + } as ExecutionContext; + + await indexModule.scheduled(mockEvent, mockEnv, mockContext); + + expect(mockContext.waitUntil).toHaveBeenCalled(); + }); + }); + + describe("Error handling", () => { + it("should handle unexpected errors gracefully", async () => { + const { query } = require("../src/lib"); + query.mockRejectedValueOnce(new Error("Unexpected error")); + + const request = new Request("http://localhost/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: "Hello world", + target_lang: "zh", + }), + }); + const response = await app.fetch(request, mockEnv); + + expect(response.status).toBeGreaterThanOrEqual(400); + }); + }); +}); diff --git a/tests/integration/translation.test.ts b/tests/integration/translation.test.ts new file mode 100644 index 0000000..c9e3a4d --- /dev/null +++ b/tests/integration/translation.test.ts @@ -0,0 +1,283 @@ +/** + * Integration tests for translation functionality + */ + +describe("Translation Integration Tests", () => { + let mockEnv: Env; + + beforeEach(() => { + mockEnv = createMockEnv(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("End-to-end translation flow", () => { + it("should complete full translation workflow", async () => { + // Mock successful API response + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + result: { + texts: [{ text: "你好世界" }], + lang: "ZH", + }, + id: 12345, + }), + }); + + const { query } = await import("../../src/lib/query"); + + const result = await query( + { + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }, + { env: mockEnv } + ); + + expect(result.code).toBe(200); + expect(result.data).toBe("你好世界"); + expect(result.source_lang).toBe("ZH"); + expect(result.target_lang).toBe("ZH"); + }); + + it("should handle rate limiting in translation flow", async () => { + // Mock rate limit exceeded + (mockEnv.RATE_LIMIT_KV.get as jest.Mock).mockResolvedValue( + JSON.stringify({ + tokens: 0, + lastRefill: Date.now(), + }) + ); + + const { query } = await import("../../src/lib/query"); + + const result = await query( + { + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }, + { env: mockEnv, clientIP: "192.168.1.1" } + ); + + expect(result.code).toBe(429); + }); + + it("should use cache when available", async () => { + const cachedEntry = { + data: "你好世界", + timestamp: Date.now(), + source_lang: "EN", + target_lang: "ZH", + id: 12345, + }; + + (mockEnv.CACHE_KV.get as jest.Mock).mockResolvedValue( + JSON.stringify(cachedEntry) + ); + + const { getCachedTranslation, generateCacheKey } = await import( + "../../src/lib/cache" + ); + + const cacheKey = generateCacheKey("Hello world", "EN", "ZH"); + const result = await getCachedTranslation(cacheKey, mockEnv); + + expect(result).toEqual(cachedEntry); + }); + + it("should handle proxy selection and failover", async () => { + // Mock first proxy failure, second proxy success + global.fetch = jest + .fn() + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + result: { + texts: [{ text: "你好世界" }], + lang: "ZH", + }, + id: 12345, + }), + }); + + const { query } = await import("../../src/lib/query"); + + const result = await query( + { + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }, + { env: mockEnv } + ); + + expect(result.code).toBe(200); + expect(result.data).toBe("你好世界"); + }); + }); + + describe("Error handling integration", () => { + it("should handle API errors gracefully", async () => { + // Mock API error response + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + error: { + code: 1156049, + message: "Invalid request format", + }, + }), + }); + + const { query } = await import("../../src/lib/query"); + + const result = await query( + { + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }, + { env: mockEnv } + ); + + expect(result.code).toBeGreaterThanOrEqual(400); + expect(result.data).toBeNull(); + }); + + it("should handle network failures with retry", async () => { + // Mock network failure then success + global.fetch = jest + .fn() + .mockRejectedValueOnce(new Error("Network error")) + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + result: { + texts: [{ text: "你好世界" }], + lang: "ZH", + }, + id: 12345, + }), + }); + + const { query } = await import("../../src/lib/query"); + + const result = await query( + { + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }, + { env: mockEnv } + ); + + expect(result.code).toBe(200); + expect(result.data).toBe("你好世界"); + }); + }); + + describe("Performance and reliability", () => { + it("should handle concurrent requests", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + result: { + texts: [{ text: "你好世界" }], + lang: "ZH", + }, + id: 12345, + }), + }); + + const { query } = await import("../../src/lib/query"); + + const promises = Array.from({ length: 10 }, (_, i) => + query( + { + text: `Hello world ${i}`, + source_lang: "en", + target_lang: "zh", + }, + { env: mockEnv } + ) + ); + + const results = await Promise.all(promises); + + results.forEach((result) => { + expect(result.code).toBe(200); + expect(result.data).toBe("你好世界"); + }); + }); + + it("should handle timeout scenarios", async () => { + // Mock timeout + global.fetch = jest + .fn() + .mockImplementation( + () => + new Promise((_, reject) => + setTimeout(() => reject(new Error("Request timeout")), 100) + ) + ); + + const { query } = await import("../../src/lib/query"); + + const result = await query( + { + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }, + { env: mockEnv } + ); + + expect(result.code).toBeGreaterThanOrEqual(400); + }); + }); + + describe("Security integration", () => { + it("should validate and sanitize input", async () => { + const { validateTranslationRequest } = await import( + "../../src/lib/validation" + ); + + const maliciousInput = { + text: 'Hello world', + source_lang: "en", + target_lang: "zh", + }; + + const result = validateTranslationRequest(maliciousInput); + + expect(result.isValid).toBe(true); + expect(result.sanitizedInput?.text).toBe( + 'Hello world' + ); + }); + + it("should handle rate limiting across multiple IPs", async () => { + const { checkRateLimit } = await import("../../src/lib/rateLimit"); + + // Test multiple IPs + const ips = ["192.168.1.1", "192.168.1.2", "192.168.1.3"]; + + for (const ip of ips) { + const result = await checkRateLimit(ip, mockEnv); + expect(result.allowed).toBe(true); + } + }); + }); +}); diff --git a/tests/lib/cache.test.ts b/tests/lib/cache.test.ts new file mode 100644 index 0000000..708a2ec --- /dev/null +++ b/tests/lib/cache.test.ts @@ -0,0 +1,158 @@ +/** + * Tests for cache module - translation caching functionality + */ + +import { + clearMemoryCache, + generateCacheKey, + getCachedTranslation, + setCachedTranslation, +} from "../../src/lib/cache"; + +describe("Cache Module", () => { + let mockEnv: Env; + + beforeEach(() => { + mockEnv = createMockEnv(); + clearMemoryCache(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("generateCacheKey", () => { + it("should generate consistent cache keys", () => { + const key1 = generateCacheKey("Hello world", "EN", "ZH"); + const key2 = generateCacheKey("Hello world", "EN", "ZH"); + + expect(key1).toBe(key2); + expect(key1).toMatch(/^cache:/); + }); + + it("should generate different keys for different inputs", () => { + const key1 = generateCacheKey("Hello world", "EN", "ZH"); + const key2 = generateCacheKey("Hello world", "EN", "ES"); + const key3 = generateCacheKey("Goodbye world", "EN", "ZH"); + + expect(key1).not.toBe(key2); + expect(key1).not.toBe(key3); + expect(key2).not.toBe(key3); + }); + + it("should handle special characters in text", () => { + const key = generateCacheKey("Hello, 世界! @#$%", "EN", "ZH"); + expect(key).toMatch(/^cache:/); + }); + }); + + describe("setCachedTranslation", () => { + it("should store translation in KV", async () => { + const cacheEntry = { + data: "你好世界", + timestamp: Date.now(), + source_lang: "EN", + target_lang: "ZH", + id: 12345, + }; + + await setCachedTranslation("test-key", cacheEntry, mockEnv); + + expect(mockEnv.CACHE_KV.put).toHaveBeenCalledWith( + "test-key", + JSON.stringify(cacheEntry), + expect.objectContaining({ + expirationTtl: expect.any(Number), + }) + ); + }); + + it("should handle KV storage errors gracefully", async () => { + (mockEnv.CACHE_KV.put as jest.Mock).mockRejectedValueOnce( + new Error("KV error") + ); + + const cacheEntry = { + data: "你好世界", + timestamp: Date.now(), + source_lang: "EN", + target_lang: "ZH", + }; + + // Should not throw + await expect( + setCachedTranslation("test-key", cacheEntry, mockEnv) + ).resolves.not.toThrow(); + }); + }); + + describe("getCachedTranslation", () => { + it("should retrieve cached translation from KV", async () => { + const cacheEntry = { + data: "你好世界", + timestamp: Date.now(), + source_lang: "EN", + target_lang: "ZH", + id: 12345, + }; + + (mockEnv.CACHE_KV.get as jest.Mock).mockResolvedValueOnce( + JSON.stringify(cacheEntry) + ); + + const result = await getCachedTranslation("test-key", mockEnv); + + expect(result).toEqual(cacheEntry); + expect(mockEnv.CACHE_KV.get).toHaveBeenCalledWith("test-key"); + }); + + it("should return null for cache miss", async () => { + (mockEnv.CACHE_KV.get as jest.Mock).mockResolvedValueOnce(null); + + const result = await getCachedTranslation("test-key", mockEnv); + + expect(result).toBeNull(); + }); + + it("should handle expired cache entries", async () => { + const expiredEntry = { + data: "你好世界", + timestamp: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago + source_lang: "EN", + target_lang: "ZH", + }; + + (mockEnv.CACHE_KV.get as jest.Mock).mockResolvedValueOnce( + JSON.stringify(expiredEntry) + ); + + const result = await getCachedTranslation("test-key", mockEnv); + + expect(result).toBeNull(); + }); + + it("should handle invalid JSON in cache", async () => { + (mockEnv.CACHE_KV.get as jest.Mock).mockResolvedValueOnce("invalid json"); + + const result = await getCachedTranslation("test-key", mockEnv); + + expect(result).toBeNull(); + }); + + it("should handle KV retrieval errors gracefully", async () => { + (mockEnv.CACHE_KV.get as jest.Mock).mockRejectedValueOnce( + new Error("KV error") + ); + + const result = await getCachedTranslation("test-key", mockEnv); + + expect(result).toBeNull(); + }); + }); + + describe("clearMemoryCache", () => { + it("should clear memory cache without errors", () => { + expect(() => clearMemoryCache()).not.toThrow(); + }); + }); +}); diff --git a/tests/lib/circuitBreaker.test.ts b/tests/lib/circuitBreaker.test.ts new file mode 100644 index 0000000..bcf4fdb --- /dev/null +++ b/tests/lib/circuitBreaker.test.ts @@ -0,0 +1,153 @@ +/** + * Tests for circuit breaker functionality + */ + +import { + CircuitBreaker, + getCircuitBreaker, +} from "../../src/lib/circuitBreaker"; + +describe("Circuit Breaker Module", () => { + let circuitBreaker: CircuitBreaker; + + beforeEach(() => { + circuitBreaker = new CircuitBreaker(); + }); + + describe("CircuitBreaker", () => { + it("should start in CLOSED state", () => { + const state = circuitBreaker.getState(); + expect(state.state).toBe("CLOSED"); + expect(state.failureCount).toBe(0); + expect(state.successCount).toBe(0); + }); + + it("should execute successful operations", async () => { + const mockOperation = jest.fn().mockResolvedValue("success"); + + const result = await circuitBreaker.execute(mockOperation); + + expect(result).toBe("success"); + expect(mockOperation).toHaveBeenCalledTimes(1); + + const state = circuitBreaker.getState(); + expect(state.successCount).toBe(1); + }); + + it("should handle failed operations", async () => { + const mockOperation = jest.fn().mockRejectedValue(new Error("failure")); + + await expect(circuitBreaker.execute(mockOperation)).rejects.toThrow( + "failure" + ); + + const state = circuitBreaker.getState(); + expect(state.failureCount).toBe(1); + }); + + it("should open circuit after failure threshold", async () => { + const mockOperation = jest.fn().mockRejectedValue(new Error("failure")); + + // Trigger multiple failures to open circuit + for (let i = 0; i < 5; i++) { + try { + await circuitBreaker.execute(mockOperation); + } catch (e) { + // Expected to fail + } + } + + const state = circuitBreaker.getState(); + expect(state.state).toBe("OPEN"); + }); + + it("should reject requests when circuit is OPEN", async () => { + const mockOperation = jest.fn().mockRejectedValue(new Error("failure")); + + // Open the circuit + for (let i = 0; i < 5; i++) { + try { + await circuitBreaker.execute(mockOperation); + } catch (e) { + // Expected to fail + } + } + + // Now circuit should be open and reject immediately + const mockNewOperation = jest.fn().mockResolvedValue("success"); + + await expect(circuitBreaker.execute(mockNewOperation)).rejects.toThrow( + "Circuit breaker is OPEN" + ); + expect(mockNewOperation).not.toHaveBeenCalled(); + }); + + it("should transition to HALF_OPEN after timeout", async () => { + const mockOperation = jest.fn().mockRejectedValue(new Error("failure")); + + // Open the circuit + for (let i = 0; i < 5; i++) { + try { + await circuitBreaker.execute(mockOperation); + } catch (e) { + // Expected to fail + } + } + + // Manually set last failure time to simulate timeout + (circuitBreaker as any).lastFailureTime = Date.now() - 61000; // 61 seconds ago + + const mockNewOperation = jest.fn().mockResolvedValue("success"); + const result = await circuitBreaker.execute(mockNewOperation); + + expect(result).toBe("success"); + const state = circuitBreaker.getState(); + expect(state.state).toBe("CLOSED"); // Should close after successful operation + }); + + it("should reset failure count on success", async () => { + const mockFailOperation = jest + .fn() + .mockRejectedValue(new Error("failure")); + const mockSuccessOperation = jest.fn().mockResolvedValue("success"); + + // Cause some failures + try { + await circuitBreaker.execute(mockFailOperation); + } catch (e) { + // Expected + } + + let state = circuitBreaker.getState(); + expect(state.failureCount).toBe(1); + + // Now succeed + await circuitBreaker.execute(mockSuccessOperation); + + state = circuitBreaker.getState(); + expect(state.failureCount).toBe(0); + expect(state.successCount).toBe(1); + }); + }); + + describe("getCircuitBreaker", () => { + it("should return same instance for same proxy URL", () => { + const cb1 = getCircuitBreaker("https://proxy1.com"); + const cb2 = getCircuitBreaker("https://proxy1.com"); + + expect(cb1).toBe(cb2); + }); + + it("should return different instances for different proxy URLs", () => { + const cb1 = getCircuitBreaker("https://proxy1.com"); + const cb2 = getCircuitBreaker("https://proxy2.com"); + + expect(cb1).not.toBe(cb2); + }); + + it("should handle invalid URLs", () => { + const cb = getCircuitBreaker("invalid-url"); + expect(cb).toBeInstanceOf(CircuitBreaker); + }); + }); +}); diff --git a/tests/lib/errorHandler.test.ts b/tests/lib/errorHandler.test.ts new file mode 100644 index 0000000..914862a --- /dev/null +++ b/tests/lib/errorHandler.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for error handling functionality + */ + +import { createErrorResponse } from "../../src/lib/errorHandler"; + +describe("Error Handler Module", () => { + describe("createErrorResponse", () => { + it("should handle generic errors", () => { + const error = new Error("Generic error message"); + const result = createErrorResponse(error); + + expect(result.response.code).toBeGreaterThanOrEqual(400); + expect(result.response.data).toBeNull(); + expect(result.httpStatus).toBeGreaterThanOrEqual(400); + }); + + it("should handle errors with status codes", () => { + const error = new Error("Bad request"); + (error as any).status = 400; + + const result = createErrorResponse(error); + + expect(result.response.code).toBe(400); + expect(result.httpStatus).toBe(400); + }); + + it("should handle errors with code property", () => { + const error = new Error("Rate limit exceeded"); + (error as any).code = 429; + + const result = createErrorResponse(error); + + expect(result.response.code).toBe(429); + expect(result.httpStatus).toBe(429); + }); + + it("should handle timeout errors", () => { + const error = new Error("Request timeout after 30 seconds"); + (error as any).status = 408; + + const result = createErrorResponse(error); + + expect(result.response.code).toBe(408); + expect(result.httpStatus).toBe(408); + }); + + it("should handle network errors", () => { + const error = new Error("Network error"); + + const result = createErrorResponse(error); + + expect(result.response.code).toBeGreaterThanOrEqual(500); + expect(result.httpStatus).toBeGreaterThanOrEqual(500); + }); + + it("should handle DeepL API errors", () => { + const error = new Error( + "DeepL API error: Invalid request format (Code: 1156049)" + ); + (error as any).code = 1156049; + + const result = createErrorResponse(error); + + expect(result.response.code).toBe(1156049); + }); + + it("should include context information", () => { + const error = new Error("Test error"); + const context = { + endpoint: "/translate", + clientIP: "192.168.1.1", + }; + + const result = createErrorResponse(error, context); + + expect(result.response.code).toBeGreaterThanOrEqual(400); + expect(result.response.data).toBeNull(); + }); + + it("should handle non-Error objects", () => { + const result1 = createErrorResponse("String error"); + expect(result1.response.code).toBeGreaterThanOrEqual(400); + + const result2 = createErrorResponse({ message: "Object error" }); + expect(result2.response.code).toBeGreaterThanOrEqual(400); + + const result3 = createErrorResponse(null); + expect(result3.response.code).toBeGreaterThanOrEqual(400); + }); + + it("should handle rate limiting errors specifically", () => { + const error = new Error("Rate limit exceeded"); + (error as any).code = 429; + + const result = createErrorResponse(error); + + expect(result.response.code).toBe(429); + expect(result.httpStatus).toBe(429); + }); + + it("should handle server errors", () => { + const error = new Error("Internal server error"); + (error as any).status = 500; + + const result = createErrorResponse(error); + + expect(result.response.code).toBe(500); + expect(result.httpStatus).toBe(500); + }); + + it("should handle authentication errors", () => { + const error = new Error("Unauthorized"); + (error as any).status = 401; + + const result = createErrorResponse(error); + + expect(result.response.code).toBe(401); + expect(result.httpStatus).toBe(401); + }); + + it("should handle payload too large errors", () => { + const error = new Error("Request payload too large"); + (error as any).status = 413; + + const result = createErrorResponse(error); + + expect(result.response.code).toBe(413); + expect(result.httpStatus).toBe(413); + }); + + it("should provide default error when no message available", () => { + const error = new Error(); + + const result = createErrorResponse(error); + + expect(result.response.code).toBeGreaterThanOrEqual(400); + expect(result.response.data).toBeNull(); + }); + }); +}); diff --git a/tests/lib/htmlUtils.test.ts b/tests/lib/htmlUtils.test.ts new file mode 100644 index 0000000..0a2a083 --- /dev/null +++ b/tests/lib/htmlUtils.test.ts @@ -0,0 +1,113 @@ +/** + * Tests for HTML utilities - HTML content preservation functionality + */ + +import { + containsHtmlContent, + preserveHtmlContent, + restoreHtmlContent, + sanitizeHtmlContent, +} from "../../src/lib/htmlUtils"; + +describe("HTML Utils", () => { + describe("containsHtmlContent", () => { + it("should detect HTML tags", () => { + expect(containsHtmlContent("Hello")).toBe(true); + expect(containsHtmlContent("test")).toBe(true); + expect(containsHtmlContent("Hello world")).toBe(false); + }); + + it("should detect HTML entities", () => { + expect(containsHtmlContent("<test>")).toBe(true); + expect(containsHtmlContent("&")).toBe(true); + expect(containsHtmlContent("Hello world")).toBe(false); + }); + }); + + describe("preserveHtmlContent and restoreHtmlContent", () => { + it("should preserve and restore HTML tags", () => { + const originalText = "A: B"; + const { processedText, preserved } = preserveHtmlContent(originalText); + + // The processed text should not contain HTML tags + expect(processedText).not.toContain(""); + expect(processedText).not.toContain(""); + expect(processedText).not.toContain(""); + expect(processedText).not.toContain(""); + + // Should contain placeholders + expect(processedText).toContain("__DEEPLX_PRESERVE_"); + + // Restore should bring back original content + const restoredText = restoreHtmlContent(processedText, preserved); + expect(restoredText).toBe(originalText); + }); + + it("should preserve colons to prevent Chinese colon conversion", () => { + const originalText = "Hello: World"; + const { processedText, preserved } = preserveHtmlContent(originalText); + + // The processed text should not contain colons + expect(processedText).not.toContain(":"); + expect(processedText).toContain("__DEEPLX_PRESERVE_"); + + // Restore should bring back original colon + const restoredText = restoreHtmlContent(processedText, preserved); + expect(restoredText).toBe(originalText); + }); + + it("should handle complex HTML with multiple tags", () => { + const originalText = + "Bold and italic: code here"; + const { processedText, preserved } = preserveHtmlContent(originalText); + + // Should not contain any HTML tags + expect(processedText).not.toMatch(/<[^>]+>/); + expect(processedText).not.toContain(":"); + + // Should contain placeholders + expect(processedText).toContain("__DEEPLX_PRESERVE_"); + + // Restore should bring back all original content + const restoredText = restoreHtmlContent(processedText, preserved); + expect(restoredText).toBe(originalText); + }); + + it("should fix Chinese colons in translated text", () => { + const originalText = "Hello: World"; + const { processedText, preserved } = preserveHtmlContent(originalText); + + // Simulate translation that introduces Chinese colon + const translatedWithChineseColon = processedText.replace( + "World", + "世界:extra" + ); + + const restoredText = restoreHtmlContent( + translatedWithChineseColon, + preserved + ); + + // Should restore original colon and fix any Chinese colons + expect(restoredText).toContain("Hello: 世界:extra"); + expect(restoredText).not.toContain(":"); + }); + }); + + describe("sanitizeHtmlContent", () => { + it("should keep safe HTML tags", () => { + const safeHtml = + "Bold code italic"; + const sanitized = sanitizeHtmlContent(safeHtml); + expect(sanitized).toBe(safeHtml); + }); + + it("should remove unsafe HTML tags", () => { + const unsafeHtml = "Safe"; + const sanitized = sanitizeHtmlContent(unsafeHtml); + expect(sanitized).not.toContain(""); + expect(sanitized).toContain("Safe"); + }); + }); +}); diff --git a/tests/lib/proxyManager.test.ts b/tests/lib/proxyManager.test.ts new file mode 100644 index 0000000..fb5ef33 --- /dev/null +++ b/tests/lib/proxyManager.test.ts @@ -0,0 +1,156 @@ +/** + * Tests for proxy management functionality + */ + +import { + generateBrowserFingerprint, + getProxyEndpoints, + hashString, + selectProxy, +} from "../../src/lib/proxyManager"; + +describe("Proxy Manager Module", () => { + let mockEnv: Env; + + beforeEach(() => { + mockEnv = createMockEnv(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("hashString", () => { + it("should generate consistent hash for same input", () => { + const hash1 = hashString("test-string"); + const hash2 = hashString("test-string"); + + expect(hash1).toBe(hash2); + expect(typeof hash1).toBe("number"); + }); + + it("should generate different hashes for different inputs", () => { + const hash1 = hashString("test-string-1"); + const hash2 = hashString("test-string-2"); + + expect(hash1).not.toBe(hash2); + }); + + it("should handle empty strings", () => { + const hash = hashString(""); + expect(typeof hash).toBe("number"); + }); + + it("should handle special characters", () => { + const hash = hashString("test@#$%^&*()"); + expect(typeof hash).toBe("number"); + }); + }); + + describe("generateBrowserFingerprint", () => { + it("should generate browser-like headers", () => { + const fingerprint = generateBrowserFingerprint("192.168.1.1"); + + expect(fingerprint).toHaveProperty("User-Agent"); + expect(fingerprint).toHaveProperty("Accept"); + expect(fingerprint).toHaveProperty("Accept-Language"); + expect(fingerprint).toHaveProperty("Accept-Encoding"); + expect(fingerprint).toHaveProperty("DNT"); + expect(fingerprint).toHaveProperty("Connection"); + expect(fingerprint).toHaveProperty("Upgrade-Insecure-Requests"); + }); + + it("should generate consistent fingerprint for same IP", () => { + const fp1 = generateBrowserFingerprint("192.168.1.1"); + const fp2 = generateBrowserFingerprint("192.168.1.1"); + + expect(fp1).toEqual(fp2); + }); + + it("should generate different fingerprints for different IPs", () => { + const fp1 = generateBrowserFingerprint("192.168.1.1"); + const fp2 = generateBrowserFingerprint("192.168.1.2"); + + expect(fp1).not.toEqual(fp2); + }); + + it("should handle default IP when none provided", () => { + const fingerprint = generateBrowserFingerprint(); + + expect(fingerprint).toHaveProperty("User-Agent"); + expect(typeof fingerprint["User-Agent"]).toBe("string"); + }); + }); + + describe("getProxyEndpoints", () => { + it("should parse proxy URLs from environment", () => { + const endpoints = getProxyEndpoints(mockEnv); + + expect(Array.isArray(endpoints)).toBe(true); + expect(endpoints.length).toBeGreaterThan(0); + expect(endpoints[0]).toHaveProperty("url"); + }); + + it("should handle missing proxy URLs", () => { + const envWithoutProxies = { ...mockEnv, PROXY_URLS: undefined }; + const endpoints = getProxyEndpoints(envWithoutProxies); + + expect(Array.isArray(endpoints)).toBe(true); + expect(endpoints.length).toBe(0); + }); + + it("should handle empty proxy URLs", () => { + const envWithEmptyProxies = { ...mockEnv, PROXY_URLS: "" }; + const endpoints = getProxyEndpoints(envWithEmptyProxies); + + expect(Array.isArray(endpoints)).toBe(true); + expect(endpoints.length).toBe(0); + }); + + it("should filter out invalid URLs", () => { + const envWithInvalidUrls = { + ...mockEnv, + PROXY_URLS: "https://valid.com,invalid-url,https://another-valid.com", + }; + const endpoints = getProxyEndpoints(envWithInvalidUrls); + + expect(endpoints.length).toBe(2); + expect(endpoints.every((ep) => ep.url.startsWith("https://"))).toBe(true); + }); + }); + + describe("selectProxy", () => { + it("should select a proxy from available endpoints", async () => { + const proxy = await selectProxy(mockEnv, "192.168.1.1"); + + if (proxy) { + expect(proxy).toHaveProperty("url"); + expect(typeof proxy.url).toBe("string"); + } + }); + + it("should return null when no proxies available", async () => { + const envWithoutProxies = { ...mockEnv, PROXY_URLS: undefined }; + const proxy = await selectProxy(envWithoutProxies, "192.168.1.1"); + + expect(proxy).toBeNull(); + }); + + it("should select different proxies for different IPs", async () => { + const proxy1 = await selectProxy(mockEnv, "192.168.1.1"); + const proxy2 = await selectProxy(mockEnv, "192.168.1.2"); + + // With multiple proxies, different IPs might get different proxies + // This test verifies the selection mechanism works + expect(proxy1).toBeTruthy(); + expect(proxy2).toBeTruthy(); + }); + + it("should handle client IP consistently", async () => { + const proxy1 = await selectProxy(mockEnv, "192.168.1.1"); + const proxy2 = await selectProxy(mockEnv, "192.168.1.1"); + + expect(proxy1).toEqual(proxy2); + }); + }); +}); diff --git a/tests/lib/query.test.ts b/tests/lib/query.test.ts new file mode 100644 index 0000000..ab6f385 --- /dev/null +++ b/tests/lib/query.test.ts @@ -0,0 +1,206 @@ +/** + * Tests for query module - core translation functionality + */ + +import { + buildRequestBody, + normalizeLanguageCode, + query, +} from "../../src/lib/query"; + +describe("Query Module", () => { + let mockEnv: Env; + let mockFetch: jest.MockedFunction; + + beforeEach(() => { + mockEnv = createMockEnv(); + mockFetch = jest.fn(); + global.fetch = mockFetch; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("normalizeLanguageCode", () => { + it("should normalize language codes to uppercase", () => { + expect(normalizeLanguageCode("en")).toBe("EN"); + expect(normalizeLanguageCode("zh")).toBe("ZH"); + expect(normalizeLanguageCode("auto")).toBe("auto"); + }); + + it("should handle language name mappings", () => { + expect(normalizeLanguageCode("english")).toBe("EN"); + expect(normalizeLanguageCode("chinese")).toBe("ZH"); + expect(normalizeLanguageCode("spanish")).toBe("ES"); + }); + + it("should handle edge cases", () => { + expect(normalizeLanguageCode("")).toBe("auto"); + expect(normalizeLanguageCode("AUTO")).toBe("auto"); + expect(normalizeLanguageCode("unknown")).toBe("UNKNOWN"); + }); + }); + + describe("buildRequestBody", () => { + it("should build valid request body", () => { + const params = { + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }; + + const body = buildRequestBody(params); + const parsed = JSON.parse(body); + + expect(parsed.jsonrpc).toBe("2.0"); + expect(parsed.method).toBe("LMT_handle_texts"); + expect(parsed.params.texts[0].text).toBe("Hello world"); + expect(parsed.params.lang.source_lang_user_selected).toBe("EN"); + expect(parsed.params.lang.target_lang).toBe("ZH"); + }); + + it("should handle method spacing based on ID", () => { + const params = { + text: "Test text", + source_lang: "en", + target_lang: "zh", + }; + + const body = buildRequestBody(params); + + // Should contain either "method": or "method" : + expect(body).toMatch(/"method"\s*:\s*"/); + }); + + it("should throw error for invalid parameters", () => { + expect(() => buildRequestBody({} as any)).toThrow(); + expect(() => buildRequestBody({ text: "" } as any)).toThrow(); + expect(() => buildRequestBody({ text: " " } as any)).toThrow(); + }); + + it("should handle text length limits", () => { + const longText = "a".repeat(10000); + expect(() => + buildRequestBody({ + text: longText, + source_lang: "en", + target_lang: "zh", + }) + ).toThrow(/Text too long/); + }); + }); + + describe("query function", () => { + it("should return error for missing text", async () => { + const result = await query({} as any); + expect(result).toBeValidErrorResponse(); + expect(result.code).toBe(400); + }); + + it("should handle successful translation", async () => { + const mockResponse = { + result: { + texts: [{ text: "你好世界" }], + lang: "ZH", + }, + id: 12345, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as any); + + const result = await query({ + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }); + + expect(result).toBeValidTranslationResponse(); + expect(result.code).toBe(200); + expect(result.data).toBe("你好世界"); + }); + + it("should handle API errors", async () => { + const mockErrorResponse = { + error: { + code: 1156049, + message: "Invalid request format", + }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockErrorResponse), + } as any); + + const result = await query({ + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }); + + expect(result).toBeValidErrorResponse(); + expect(result.code).toBeGreaterThanOrEqual(400); + }); + + it("should handle network errors", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + const result = await query({ + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }); + + expect(result).toBeValidErrorResponse(); + }); + + it("should handle timeout errors", async () => { + mockFetch.mockImplementationOnce( + () => new Promise((resolve) => setTimeout(resolve, 35000)) + ); + + const result = await query({ + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }); + + expect(result).toBeValidErrorResponse(); + }); + + it("should use proxy when provided", async () => { + const mockResponse = { + result: { + texts: [{ text: "你好世界" }], + lang: "ZH", + }, + id: 12345, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as any); + + await query( + { + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }, + { + proxyEndpoint: "https://custom-proxy.com/api", + } + ); + + expect(mockFetch).toHaveBeenCalledWith( + "https://custom-proxy.com/api", + expect.any(Object) + ); + }); + }); +}); diff --git a/tests/lib/rateLimit.test.ts b/tests/lib/rateLimit.test.ts new file mode 100644 index 0000000..3193b6d --- /dev/null +++ b/tests/lib/rateLimit.test.ts @@ -0,0 +1,154 @@ +/** + * Tests for rate limiting functionality + */ + +import { + checkRateLimit, + delayRequest, + getClientIP, +} from "../../src/lib/rateLimit"; + +describe("Rate Limit Module", () => { + let mockEnv: Env; + + beforeEach(() => { + mockEnv = createMockEnv(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("getClientIP", () => { + it("should extract IP from CF-Connecting-IP header", () => { + const mockRequest = { + headers: new Map([["CF-Connecting-IP", "192.168.1.1"]]), + } as any; + + const ip = getClientIP(mockRequest); + expect(ip).toBe("192.168.1.1"); + }); + + it("should extract IP from X-Forwarded-For header", () => { + const mockRequest = { + headers: new Map([["X-Forwarded-For", "192.168.1.1, 10.0.0.1"]]), + } as any; + + const ip = getClientIP(mockRequest); + expect(ip).toBe("192.168.1.1"); + }); + + it("should extract IP from X-Real-IP header", () => { + const mockRequest = { + headers: new Map([["X-Real-IP", "192.168.1.1"]]), + } as any; + + const ip = getClientIP(mockRequest); + expect(ip).toBe("192.168.1.1"); + }); + + it("should return null when no IP headers present", () => { + const mockRequest = { + headers: new Map(), + } as any; + + const ip = getClientIP(mockRequest); + expect(ip).toBeNull(); + }); + + it("should prioritize CF-Connecting-IP over other headers", () => { + const mockRequest = { + headers: new Map([ + ["CF-Connecting-IP", "192.168.1.1"], + ["X-Forwarded-For", "10.0.0.1"], + ["X-Real-IP", "172.16.0.1"], + ]), + } as any; + + const ip = getClientIP(mockRequest); + expect(ip).toBe("192.168.1.1"); + }); + }); + + describe("checkRateLimit", () => { + it("should allow requests within rate limit", async () => { + (mockEnv.RATE_LIMIT_KV.get as jest.Mock).mockResolvedValueOnce(null); + + const result = await checkRateLimit("192.168.1.1", mockEnv); + + expect(result.allowed).toBe(true); + expect(result.remainingTokens).toBeGreaterThan(0); + }); + + it("should deny requests exceeding rate limit", async () => { + const rateLimitEntry = { + tokens: 0, + lastRefill: Date.now(), + }; + + (mockEnv.RATE_LIMIT_KV.get as jest.Mock).mockResolvedValueOnce( + JSON.stringify(rateLimitEntry) + ); + + const result = await checkRateLimit("192.168.1.1", mockEnv); + + expect(result.allowed).toBe(false); + expect(result.remainingTokens).toBe(0); + }); + + it("should refill tokens over time", async () => { + const rateLimitEntry = { + tokens: 0, + lastRefill: Date.now() - 60000, // 1 minute ago + }; + + (mockEnv.RATE_LIMIT_KV.get as jest.Mock).mockResolvedValueOnce( + JSON.stringify(rateLimitEntry) + ); + + const result = await checkRateLimit("192.168.1.1", mockEnv); + + expect(result.allowed).toBe(true); + expect(result.remainingTokens).toBeGreaterThan(0); + }); + + it("should handle KV errors gracefully", async () => { + (mockEnv.RATE_LIMIT_KV.get as jest.Mock).mockRejectedValueOnce( + new Error("KV error") + ); + + const result = await checkRateLimit("192.168.1.1", mockEnv); + + // Should allow request when KV is unavailable + expect(result.allowed).toBe(true); + }); + + it("should handle invalid JSON in KV", async () => { + (mockEnv.RATE_LIMIT_KV.get as jest.Mock).mockResolvedValueOnce( + "invalid json" + ); + + const result = await checkRateLimit("192.168.1.1", mockEnv); + + expect(result.allowed).toBe(true); + }); + }); + + describe("delayRequest", () => { + it("should delay for specified seconds", async () => { + const startTime = Date.now(); + await delayRequest(0.1); // 100ms + const endTime = Date.now(); + + expect(endTime - startTime).toBeGreaterThanOrEqual(90); + }); + + it("should handle zero delay", async () => { + const startTime = Date.now(); + await delayRequest(0); + const endTime = Date.now(); + + expect(endTime - startTime).toBeLessThan(10); + }); + }); +}); diff --git a/tests/lib/retryLogic.test.ts b/tests/lib/retryLogic.test.ts new file mode 100644 index 0000000..a61ad86 --- /dev/null +++ b/tests/lib/retryLogic.test.ts @@ -0,0 +1,181 @@ +/** + * Tests for retry logic functionality + */ + +import { isRetryableError, retryWithBackoff } from "../../src/lib/retryLogic"; + +describe("Retry Logic Module", () => { + describe("isRetryableError", () => { + it("should identify retryable network errors", () => { + const networkError = new Error("Network error"); + expect(isRetryableError(networkError)).toBe(true); + }); + + it("should identify retryable timeout errors", () => { + const timeoutError = new Error("Request timeout"); + expect(isRetryableError(timeoutError)).toBe(true); + }); + + it("should identify retryable 5xx status codes", () => { + const serverError = new Error("Server error"); + (serverError as any).status = 500; + expect(isRetryableError(serverError)).toBe(true); + + const badGateway = new Error("Bad gateway"); + (badGateway as any).status = 502; + expect(isRetryableError(badGateway)).toBe(true); + }); + + it("should identify retryable 429 status code", () => { + const rateLimitError = new Error("Too many requests"); + (rateLimitError as any).status = 429; + expect(isRetryableError(rateLimitError)).toBe(true); + }); + + it("should not retry 4xx client errors (except 429)", () => { + const badRequest = new Error("Bad request"); + (badRequest as any).status = 400; + expect(isRetryableError(badRequest)).toBe(false); + + const unauthorized = new Error("Unauthorized"); + (unauthorized as any).status = 401; + expect(isRetryableError(unauthorized)).toBe(false); + + const notFound = new Error("Not found"); + (notFound as any).status = 404; + expect(isRetryableError(notFound)).toBe(false); + }); + + it("should not retry 2xx success codes", () => { + const success = new Error("Success"); + (success as any).status = 200; + expect(isRetryableError(success)).toBe(false); + }); + + it("should handle errors without status codes", () => { + const genericError = new Error("Generic error"); + expect(isRetryableError(genericError)).toBe(true); + }); + + it("should handle non-Error objects", () => { + expect(isRetryableError("string error")).toBe(true); + expect(isRetryableError({ message: "object error" })).toBe(true); + expect(isRetryableError(null)).toBe(true); + }); + }); + + describe("retryWithBackoff", () => { + it("should succeed on first try", async () => { + const mockOperation = jest.fn().mockResolvedValue("success"); + + const result = await retryWithBackoff(mockOperation); + + expect(result).toBe("success"); + expect(mockOperation).toHaveBeenCalledTimes(1); + }); + + it("should retry on retryable errors", async () => { + const mockOperation = jest + .fn() + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce("success"); + + const result = await retryWithBackoff(mockOperation, 3); + + expect(result).toBe("success"); + expect(mockOperation).toHaveBeenCalledTimes(2); + }); + + it("should not retry on non-retryable errors", async () => { + const nonRetryableError = new Error("Bad request"); + (nonRetryableError as any).status = 400; + + const mockOperation = jest.fn().mockRejectedValue(nonRetryableError); + + await expect( + retryWithBackoff(mockOperation, 3, 100, 1000, { + isRetryable: isRetryableError, + }) + ).rejects.toThrow("Bad request"); + + expect(mockOperation).toHaveBeenCalledTimes(1); + }); + + it("should exhaust all retries", async () => { + const mockOperation = jest + .fn() + .mockRejectedValue(new Error("Network error")); + + await expect(retryWithBackoff(mockOperation, 3, 10, 100)).rejects.toThrow( + "Network error" + ); + + expect(mockOperation).toHaveBeenCalledTimes(4); // Initial + 3 retries + }); + + it("should apply exponential backoff", async () => { + const mockOperation = jest + .fn() + .mockRejectedValueOnce(new Error("Network error")) + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce("success"); + + const startTime = Date.now(); + const result = await retryWithBackoff(mockOperation, 3, 50, 1000); + const endTime = Date.now(); + + expect(result).toBe("success"); + expect(mockOperation).toHaveBeenCalledTimes(3); + // Should have some delay due to backoff + expect(endTime - startTime).toBeGreaterThan(50); + }); + + it("should respect max delay", async () => { + const mockOperation = jest + .fn() + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce("success"); + + const startTime = Date.now(); + await retryWithBackoff(mockOperation, 3, 1000, 100); // maxDelay < baseDelay + const endTime = Date.now(); + + // Should not exceed maxDelay + expect(endTime - startTime).toBeLessThan(200); + }); + + it("should use custom retry predicate", async () => { + const customError = new Error("Custom error"); + const mockOperation = jest.fn().mockRejectedValue(customError); + + const customIsRetryable = jest.fn().mockReturnValue(false); + + await expect( + retryWithBackoff(mockOperation, 3, 10, 100, { + isRetryable: customIsRetryable, + }) + ).rejects.toThrow("Custom error"); + + expect(mockOperation).toHaveBeenCalledTimes(1); + expect(customIsRetryable).toHaveBeenCalledWith(customError); + }); + + it("should handle zero retries", async () => { + const mockOperation = jest.fn().mockRejectedValue(new Error("Error")); + + await expect(retryWithBackoff(mockOperation, 0)).rejects.toThrow("Error"); + + expect(mockOperation).toHaveBeenCalledTimes(1); + }); + + it("should handle negative retry count", async () => { + const mockOperation = jest.fn().mockRejectedValue(new Error("Error")); + + await expect(retryWithBackoff(mockOperation, -1)).rejects.toThrow( + "Error" + ); + + expect(mockOperation).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/lib/security.test.ts b/tests/lib/security.test.ts new file mode 100644 index 0000000..4acbd88 --- /dev/null +++ b/tests/lib/security.test.ts @@ -0,0 +1,195 @@ +/** + * Tests for security functionality + */ + +import { + addSecurityHeaders, + getSecureClientIP, + handleCORSPreflight, + validateLanguageCode, +} from "../../src/lib/security"; + +describe("Security Module", () => { + describe("addSecurityHeaders", () => { + it("should add security headers to response", () => { + const mockResponse = { + headers: new Map(), + }; + + const result = addSecurityHeaders(mockResponse); + + expect(result.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(result.headers.get("X-Frame-Options")).toBe("DENY"); + expect(result.headers.get("X-XSS-Protection")).toBe("1; mode=block"); + expect(result.headers.get("Referrer-Policy")).toBe( + "strict-origin-when-cross-origin" + ); + expect(result.headers.get("Content-Security-Policy")).toContain( + "default-src 'self'" + ); + }); + + it("should add CORS headers to response", () => { + const mockResponse = { + headers: new Map(), + }; + + const result = addSecurityHeaders(mockResponse); + + expect(result.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(result.headers.get("Access-Control-Allow-Methods")).toBe( + "GET, POST, OPTIONS" + ); + expect(result.headers.get("Access-Control-Allow-Headers")).toBe( + "Content-Type, Authorization" + ); + expect(result.headers.get("Access-Control-Max-Age")).toBe("86400"); + }); + + it("should preserve existing headers", () => { + const mockResponse = { + headers: new Map([["Custom-Header", "custom-value"]]), + }; + + const result = addSecurityHeaders(mockResponse); + + expect(result.headers.get("Custom-Header")).toBe("custom-value"); + expect(result.headers.get("X-Content-Type-Options")).toBe("nosniff"); + }); + }); + + describe("handleCORSPreflight", () => { + it("should return CORS preflight response", () => { + const mockContext = { + text: jest.fn().mockReturnValue("preflight response"), + }; + + const result = handleCORSPreflight(mockContext); + + expect(mockContext.text).toHaveBeenCalledWith( + "", + 200, + expect.objectContaining({ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + }) + ); + }); + }); + + describe("validateLanguageCode", () => { + it("should validate correct language codes", () => { + expect(validateLanguageCode("en")).toBe("en"); + expect(validateLanguageCode("zh")).toBe("zh"); + expect(validateLanguageCode("EN")).toBe("en"); + expect(validateLanguageCode("zh-CN")).toBe("zh-cn"); + expect(validateLanguageCode("pt-BR")).toBe("pt-br"); + }); + + it("should reject invalid language codes", () => { + expect(validateLanguageCode("")).toBeNull(); + expect(validateLanguageCode("a")).toBeNull(); + expect(validateLanguageCode("toolong")).toBeNull(); + expect(validateLanguageCode("en@")).toBeNull(); + expect(validateLanguageCode("en_US")).toBeNull(); + expect(validateLanguageCode("123")).toBe("123"); // Numbers are allowed + }); + + it("should handle non-string inputs", () => { + expect(validateLanguageCode(null as any)).toBeNull(); + expect(validateLanguageCode(undefined as any)).toBeNull(); + expect(validateLanguageCode(123 as any)).toBeNull(); + expect(validateLanguageCode({} as any)).toBeNull(); + }); + + it("should trim whitespace", () => { + expect(validateLanguageCode(" en ")).toBe("en"); + expect(validateLanguageCode("\ten\n")).toBe("en"); + }); + }); + + describe("getSecureClientIP", () => { + it("should extract IP from CF-Connecting-IP header", () => { + const mockRequest = { + headers: { + get: jest.fn().mockImplementation((header) => { + if (header === "CF-Connecting-IP") return "192.168.1.1"; + return null; + }), + }, + }; + + const ip = getSecureClientIP(mockRequest); + expect(ip).toBe("192.168.1.1"); + }); + + it("should extract IP from X-Forwarded-For header", () => { + const mockRequest = { + headers: { + get: jest.fn().mockImplementation((header) => { + if (header === "X-Forwarded-For") return "192.168.1.1, 10.0.0.1"; + return null; + }), + }, + }; + + const ip = getSecureClientIP(mockRequest); + expect(ip).toBe("192.168.1.1"); + }); + + it("should prioritize CF-Connecting-IP over X-Forwarded-For", () => { + const mockRequest = { + headers: { + get: jest.fn().mockImplementation((header) => { + if (header === "CF-Connecting-IP") return "192.168.1.1"; + if (header === "X-Forwarded-For") return "10.0.0.1"; + return null; + }), + }, + }; + + const ip = getSecureClientIP(mockRequest); + expect(ip).toBe("192.168.1.1"); + }); + + it("should return null for invalid IPs", () => { + const mockRequest = { + headers: { + get: jest.fn().mockImplementation((header) => { + if (header === "CF-Connecting-IP") return "invalid-ip"; + return null; + }), + }, + }; + + const ip = getSecureClientIP(mockRequest); + expect(ip).toBeNull(); + }); + + it("should return null when no IP headers present", () => { + const mockRequest = { + headers: { + get: jest.fn().mockReturnValue(null), + }, + }; + + const ip = getSecureClientIP(mockRequest); + expect(ip).toBeNull(); + }); + + it("should handle IPv6 addresses", () => { + const mockRequest = { + headers: { + get: jest.fn().mockImplementation((header) => { + if (header === "CF-Connecting-IP") + return "2001:0db8:85a3:0000:0000:8a2e:0370:7334"; + return null; + }), + }, + }; + + const ip = getSecureClientIP(mockRequest); + expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334"); + }); + }); +}); diff --git a/tests/lib/textUtils.test.ts b/tests/lib/textUtils.test.ts new file mode 100644 index 0000000..aee66c1 --- /dev/null +++ b/tests/lib/textUtils.test.ts @@ -0,0 +1,118 @@ +/** + * Tests for text utilities functionality + */ + +import { + estimatePayloadSize, + isPayloadTooLarge, + validateTextLength, +} from "../../src/lib/textUtils"; + +describe("Text Utils Module", () => { + describe("estimatePayloadSize", () => { + it("should estimate payload size for simple text", () => { + const size = estimatePayloadSize("Hello world", "en", "zh"); + + expect(size).toBeGreaterThan(0); + expect(typeof size).toBe("number"); + }); + + it("should return larger size for longer text", () => { + const shortSize = estimatePayloadSize("Hello", "en", "zh"); + const longSize = estimatePayloadSize( + "Hello world this is a much longer text", + "en", + "zh" + ); + + expect(longSize).toBeGreaterThan(shortSize); + }); + + it("should handle empty text", () => { + const size = estimatePayloadSize("", "en", "zh"); + + expect(size).toBeGreaterThan(0); // Still has request structure overhead + }); + + it("should use default language codes", () => { + const size1 = estimatePayloadSize("Hello world"); + const size2 = estimatePayloadSize("Hello world", "auto", "en"); + + expect(size1).toBe(size2); + }); + + it("should handle special characters", () => { + const size = estimatePayloadSize("Hello 世界 🌍", "en", "zh"); + + expect(size).toBeGreaterThan(0); + expect(typeof size).toBe("number"); + }); + }); + + describe("isPayloadTooLarge", () => { + it("should return false for normal text", () => { + const result = isPayloadTooLarge("Hello world", "en", "zh"); + + expect(result).toBe(false); + }); + + it("should return true for very large text", () => { + const largeText = "a".repeat(100000); + const result = isPayloadTooLarge(largeText, "en", "zh"); + + expect(result).toBe(true); + }); + + it("should handle empty text", () => { + const result = isPayloadTooLarge("", "en", "zh"); + + expect(result).toBe(false); + }); + + it("should use default language codes", () => { + const result = isPayloadTooLarge("Hello world"); + + expect(result).toBe(false); + }); + }); + + describe("validateTextLength", () => { + it("should validate normal text", () => { + const result = validateTextLength("Hello world"); + + expect(result.isValid).toBe(true); + expect(result.suggestedAction).toBe("Text is within limits"); + }); + + it("should reject empty text", () => { + const result = validateTextLength(""); + + expect(result.isValid).toBe(false); + expect(result.suggestedAction).toBe("Text cannot be empty"); + }); + + it("should reject whitespace-only text", () => { + const result = validateTextLength(" "); + + expect(result.isValid).toBe(false); + expect(result.suggestedAction).toBe("Text cannot be empty"); + }); + + it("should reject long text", () => { + const longText = "a".repeat(60000); // Exceeds MAX_TEXT_LENGTH + const result = validateTextLength(longText); + + expect(result.isValid).toBe(false); + expect(result.suggestedAction).toBe( + "Text is too long. Please reduce text length." + ); + }); + + it("should handle text at the boundary", () => { + const boundaryText = "a".repeat(50000); // Right at MAX_TEXT_LENGTH + const result = validateTextLength(boundaryText); + + expect(result.isValid).toBe(true); + }); + }); +}); diff --git a/tests/lib/types.test.ts b/tests/lib/types.test.ts new file mode 100644 index 0000000..8a53cda --- /dev/null +++ b/tests/lib/types.test.ts @@ -0,0 +1,94 @@ +/** + * Tests for types and response utilities + */ + +import { createStandardResponse } from "../../src/lib/types"; + +describe("Types Module", () => { + describe("createStandardResponse", () => { + it("should create successful response", () => { + const response = createStandardResponse( + 200, + "Hello world", + 12345, + "EN", + "ZH" + ); + + expect(response.code).toBe(200); + expect(response.data).toBe("Hello world"); + expect(response.id).toBe(12345); + expect(response.source_lang).toBe("EN"); + expect(response.target_lang).toBe("ZH"); + }); + + it("should create error response", () => { + const response = createStandardResponse(400, null); + + expect(response.code).toBe(400); + expect(response.data).toBeNull(); + expect(response.id).toBeGreaterThan(0); + expect(response.source_lang).toBe(""); + expect(response.target_lang).toBe(""); + }); + + it("should generate random ID when not provided", () => { + const response1 = createStandardResponse(200, "test"); + const response2 = createStandardResponse(200, "test"); + + expect(response1.id).toBeGreaterThan(0); + expect(response2.id).toBeGreaterThan(0); + expect(response1.id).not.toBe(response2.id); + }); + + it("should handle missing language parameters", () => { + const response = createStandardResponse(200, "test", 12345); + + expect(response.source_lang).toBe(""); + expect(response.target_lang).toBe(""); + }); + + it("should handle all parameters", () => { + const response = createStandardResponse( + 200, + "translated text", + 98765, + "AUTO", + "FR" + ); + + expect(response.code).toBe(200); + expect(response.data).toBe("translated text"); + expect(response.id).toBe(98765); + expect(response.source_lang).toBe("AUTO"); + expect(response.target_lang).toBe("FR"); + }); + + it("should handle zero ID", () => { + const response = createStandardResponse(200, "test", 0); + + expect(response.id).toBe(0); + }); + + it("should handle negative status codes", () => { + const response = createStandardResponse(-1, null); + + expect(response.code).toBe(-1); + expect(response.data).toBeNull(); + }); + + it("should handle empty string data", () => { + const response = createStandardResponse(200, ""); + + expect(response.code).toBe(200); + expect(response.data).toBe(""); + }); + + it("should handle undefined data", () => { + const response = createStandardResponse(200, undefined as any); + + expect(response.code).toBe(200); + expect(response.data).toBeUndefined(); + }); + }); +}); diff --git a/tests/lib/validation.test.ts b/tests/lib/validation.test.ts new file mode 100644 index 0000000..454a0ed --- /dev/null +++ b/tests/lib/validation.test.ts @@ -0,0 +1,173 @@ +/** + * Tests for validation functionality + */ + +import { validateTranslationRequest } from "../../src/lib/validation"; + +describe("Validation Module", () => { + describe("validateTranslationRequest", () => { + it("should validate correct translation request", () => { + const input = { + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }; + + const result = validateTranslationRequest(input); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.sanitizedInput).toEqual({ + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }); + }); + + it("should handle missing text field", () => { + const input = { + target_lang: "zh", + }; + + const result = validateTranslationRequest(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Text field is required"); + }); + + it("should handle empty text field", () => { + const input = { + text: " ", + target_lang: "zh", + }; + + const result = validateTranslationRequest(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Text field cannot be empty"); + }); + + it("should handle text that is too long", () => { + const input = { + text: "a".repeat(60000), // Exceeds MAX_TEXT_LENGTH + target_lang: "zh", + }; + + const result = validateTranslationRequest(input); + + expect(result.isValid).toBe(false); + expect( + result.errors.some((error) => error.includes("exceeds maximum length")) + ).toBe(true); + }); + + it("should handle non-string text field", () => { + const input = { + text: 123, + target_lang: "zh", + }; + + const result = validateTranslationRequest(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Text field must be a string"); + }); + + it("should handle missing target language", () => { + const input = { + text: "Hello world", + }; + + const result = validateTranslationRequest(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Target language is required"); + }); + + it("should handle auto as target language", () => { + const input = { + text: "Hello world", + target_lang: "auto", + }; + + const result = validateTranslationRequest(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Target language cannot be 'auto'"); + }); + + it("should handle invalid source language", () => { + const input = { + text: "Hello world", + source_lang: "x", + target_lang: "zh", + }; + + const result = validateTranslationRequest(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Source language code is too short"); + }); + + it("should handle non-string language codes", () => { + const input = { + text: "Hello world", + source_lang: 123, + target_lang: 456, + }; + + const result = validateTranslationRequest(input); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Source language must be a string"); + expect(result.errors).toContain("Target language must be a string"); + }); + + it("should handle null input", () => { + const result = validateTranslationRequest(null); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Request body must be a valid JSON object" + ); + }); + + it("should handle non-object input", () => { + const result = validateTranslationRequest("string input"); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Request body must be a valid JSON object" + ); + }); + + it("should sanitize input correctly", () => { + const input = { + text: " Hello world ", + source_lang: "EN", + target_lang: "ZH", + }; + + const result = validateTranslationRequest(input); + + expect(result.isValid).toBe(true); + expect(result.sanitizedInput).toEqual({ + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }); + }); + + it("should use defaults for missing optional fields", () => { + const input = { + text: "Hello world", + target_lang: "zh", + }; + + const result = validateTranslationRequest(input); + + expect(result.isValid).toBe(true); + expect(result.sanitizedInput?.source_lang).toBe("auto"); + }); + }); +}); diff --git a/tests/performance/load.test.ts b/tests/performance/load.test.ts new file mode 100644 index 0000000..ee8b987 --- /dev/null +++ b/tests/performance/load.test.ts @@ -0,0 +1,278 @@ +/** + * Performance and load tests + */ + +describe("Performance Tests", () => { + let mockEnv: Env; + + beforeEach(() => { + mockEnv = createMockEnv(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("Response time benchmarks", () => { + it("should respond to translation requests within acceptable time", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + result: { + texts: [{ text: "你好世界" }], + lang: "ZH", + }, + id: 12345, + }), + }); + + const { query } = await import("../../src/lib/query"); + + const startTime = Date.now(); + + await query( + { + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }, + { env: mockEnv } + ); + + const endTime = Date.now(); + const responseTime = endTime - startTime; + + // Should respond within 5 seconds for normal requests + expect(responseTime).toBeLessThan(5000); + }); + + it("should handle cache lookups efficiently", async () => { + const cachedEntry = { + data: "你好世界", + timestamp: Date.now(), + source_lang: "EN", + target_lang: "ZH", + id: 12345, + }; + + (mockEnv.CACHE_KV.get as jest.Mock).mockResolvedValue( + JSON.stringify(cachedEntry) + ); + + const { getCachedTranslation, generateCacheKey } = await import( + "../../src/lib/cache" + ); + + const startTime = Date.now(); + + const cacheKey = generateCacheKey("Hello world", "EN", "ZH"); + await getCachedTranslation(cacheKey, mockEnv); + + const endTime = Date.now(); + const responseTime = endTime - startTime; + + // Cache lookups should be very fast + expect(responseTime).toBeLessThan(100); + }); + }); + + describe("Memory usage tests", () => { + it("should not leak memory during repeated operations", async () => { + const { clearMemoryCache } = await import("../../src/lib/cache"); + + // Simulate repeated cache operations + for (let i = 0; i < 100; i++) { + const { generateCacheKey } = await import("../../src/lib/cache"); + generateCacheKey(`test text ${i}`, "EN", "ZH"); + } + + // Clear cache should not throw + expect(() => clearMemoryCache()).not.toThrow(); + }); + + it("should handle large text inputs efficiently", async () => { + const { validateTextLength } = await import("../../src/lib/textUtils"); + + const largeText = "This is a test sentence. ".repeat(1000); + + const startTime = Date.now(); + const validation = validateTextLength(largeText); + const endTime = Date.now(); + + expect(validation.isValid).toBe(false); + expect(endTime - startTime).toBeLessThan(1000); // Should complete within 1 second + }); + }); + + describe("Concurrent request handling", () => { + it("should handle multiple simultaneous requests", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + result: { + texts: [{ text: "你好世界" }], + lang: "ZH", + }, + id: 12345, + }), + }); + + const { query } = await import("../../src/lib/query"); + + const concurrentRequests = 50; + const startTime = Date.now(); + + const promises = Array.from({ length: concurrentRequests }, (_, i) => + query( + { + text: `Hello world ${i}`, + source_lang: "en", + target_lang: "zh", + }, + { env: mockEnv } + ) + ); + + const results = await Promise.all(promises); + const endTime = Date.now(); + + // All requests should succeed + results.forEach((result) => { + expect(result.code).toBe(200); + }); + + // Should handle concurrent requests efficiently + const avgTimePerRequest = (endTime - startTime) / concurrentRequests; + expect(avgTimePerRequest).toBeLessThan(1000); + }); + + it("should handle rate limiting under load", async () => { + const { checkRateLimit } = await import("../../src/lib/rateLimit"); + + // Simulate rapid requests from same IP + const results = []; + for (let i = 0; i < 20; i++) { + const result = await checkRateLimit("192.168.1.1", mockEnv); + results.push(result); + } + + // Should eventually hit rate limits + const allowedCount = results.filter((r) => r.allowed).length; + const deniedCount = results.filter((r) => !r.allowed).length; + + expect(allowedCount).toBeGreaterThan(0); + // Depending on rate limit configuration, some might be denied + }); + }); + + describe("Resource utilization", () => { + it("should handle proxy failover efficiently", async () => { + // Mock multiple proxy failures then success + global.fetch = jest + .fn() + .mockRejectedValueOnce(new Error("Proxy 1 failed")) + .mockRejectedValueOnce(new Error("Proxy 2 failed")) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + result: { + texts: [{ text: "你好世界" }], + lang: "ZH", + }, + id: 12345, + }), + }); + + const { query } = await import("../../src/lib/query"); + + const startTime = Date.now(); + + const result = await query( + { + text: "Hello world", + source_lang: "en", + target_lang: "zh", + }, + { env: mockEnv } + ); + + const endTime = Date.now(); + + expect(result.code).toBe(200); + + // Failover should not take too long + const failoverTime = endTime - startTime; + expect(failoverTime).toBeLessThan(10000); + }); + }); + + describe("Stress tests", () => { + it("should handle rapid sequential requests", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + result: { + texts: [{ text: "你好世界" }], + lang: "ZH", + }, + id: 12345, + }), + }); + + const { query } = await import("../../src/lib/query"); + + const requestCount = 100; + const results = []; + + const startTime = Date.now(); + + for (let i = 0; i < requestCount; i++) { + const result = await query( + { + text: `Hello world ${i}`, + source_lang: "en", + target_lang: "zh", + }, + { env: mockEnv } + ); + results.push(result); + } + + const endTime = Date.now(); + + // All requests should succeed + results.forEach((result) => { + expect(result.code).toBe(200); + }); + + const totalTime = endTime - startTime; + const avgTimePerRequest = totalTime / requestCount; + + // Should maintain reasonable performance under load + expect(avgTimePerRequest).toBeLessThan(500); + }); + + it("should handle large payload processing", async () => { + const { validateTextLength } = await import("../../src/lib/textUtils"); + + const largeText = + "This is a very long text that needs to be processed efficiently. ".repeat( + 500 + ); + + const startTime = Date.now(); + + const validation = validateTextLength(largeText); + expect(validation.isValid).toBe(false); + + const endTime = Date.now(); + + // Large text processing should complete quickly + expect(endTime - startTime).toBeLessThan(1000); + }); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..ff3aa68 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,118 @@ +/** + * Jest test setup file + * Global test configuration and utilities + */ + +// Mock global fetch if not available +if (!global.fetch) { + global.fetch = jest.fn(); +} + +// Mock console methods to reduce noise in tests +global.console = { + ...console, + log: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}; + +// Global test utilities +global.createMockEnv = (): Env => ({ + CACHE_KV: { + get: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + list: jest.fn(), + } as any, + RATE_LIMIT_KV: { + get: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + list: jest.fn(), + } as any, + + ANALYTICS: { + writeDataPoint: jest.fn(), + } as any, + PROXY_URLS: + "https://test1.example.com/jsonrpc,https://test2.example.com/jsonrpc", + PROXY_WEIGHTS: "1,1", + DEBUG_MODE: "true", +}); + +// Mock Request and Response for Cloudflare Workers environment +global.Request = + global.Request || + class MockRequest { + constructor(public url: string, public init?: RequestInit) {} + json() { + return Promise.resolve({}); + } + text() { + return Promise.resolve(""); + } + headers = new Map(); + }; + +global.Response = + global.Response || + class MockResponse { + constructor(public body?: any, public init?: ResponseInit) {} + json() { + return Promise.resolve(this.body); + } + text() { + return Promise.resolve(String(this.body)); + } + ok = true; + status = 200; + headers = new Map(); + }; + +// Extend Jest matchers +declare global { + namespace jest { + interface Matchers { + toBeValidTranslationResponse(): R; + toBeValidErrorResponse(): R; + } + } + + function createMockEnv(): Env; +} + +expect.extend({ + toBeValidTranslationResponse(received) { + const pass = + received && + typeof received.code === "number" && + (received.data !== undefined || received.code !== 200) && + typeof received.id === "number" && + typeof received.source_lang === "string" && + typeof received.target_lang === "string"; + + return { + message: () => + `expected ${JSON.stringify( + received + )} to be a valid translation response`, + pass, + }; + }, + + toBeValidErrorResponse(received) { + const pass = + received && + typeof received.code === "number" && + received.code >= 400 && + received.data === null; + + return { + message: () => + `expected ${JSON.stringify(received)} to be a valid error response`, + pass, + }; + }, +}); diff --git a/tests/utils/testHelpers.ts b/tests/utils/testHelpers.ts new file mode 100644 index 0000000..881dfbc --- /dev/null +++ b/tests/utils/testHelpers.ts @@ -0,0 +1,259 @@ +/** + * Test utility functions and helpers + */ + +/** + * Create a mock translation response + */ +export function createMockTranslationResponse( + text: string = "你好世界", + sourceLang: string = "EN", + targetLang: string = "ZH", + id: number = 12345 +) { + return { + result: { + texts: [{ text }], + lang: sourceLang, + }, + id, + }; +} + +/** + * Create a mock error response + */ +export function createMockErrorResponse( + code: number = 1156049, + message: string = "Invalid request format" +) { + return { + error: { + code, + message, + }, + }; +} + +/** + * Create a mock fetch response + */ +export function createMockFetchResponse( + data: any, + options: { ok?: boolean; status?: number } = {} +) { + return { + ok: options.ok ?? true, + status: options.status ?? 200, + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + }; +} + +/** + * Create a mock request object + */ +export function createMockRequest( + url: string, + options: RequestInit & { body?: any } = {} +) { + return { + url, + method: options.method || "GET", + headers: new Map(Object.entries(options.headers || {})), + json: () => Promise.resolve(options.body || {}), + text: () => + Promise.resolve( + typeof options.body === "string" + ? options.body + : JSON.stringify(options.body || {}) + ), + }; +} + +/** + * Create a mock Hono context + */ +export function createMockHonoContext( + request: any, + env: Env, + options: any = {} +) { + return { + req: { + raw: request, + json: () => request.json(), + text: () => request.text(), + }, + env, + json: jest.fn().mockImplementation((data, status) => ({ + data, + status: status || 200, + })), + text: jest.fn().mockImplementation((text, status, headers) => ({ + text, + status: status || 200, + headers, + })), + redirect: jest.fn().mockImplementation((url) => ({ + redirect: url, + status: 302, + })), + ...options, + }; +} + +/** + * Wait for a specified amount of time + */ +export function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Generate random text for testing + */ +export function generateRandomText(length: number = 100): string { + const chars = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 "; + let result = ""; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +/** + * Mock KV namespace with common operations + */ +export function createMockKV() { + const storage = new Map(); + + return { + get: jest.fn().mockImplementation((key: string) => { + return Promise.resolve(storage.get(key) || null); + }), + put: jest.fn().mockImplementation((key: string, value: string) => { + storage.set(key, value); + return Promise.resolve(); + }), + delete: jest.fn().mockImplementation((key: string) => { + storage.delete(key); + return Promise.resolve(); + }), + list: jest.fn().mockImplementation(() => { + return Promise.resolve({ + keys: Array.from(storage.keys()).map((key) => ({ name: key })), + }); + }), + // Internal storage for testing + _storage: storage, + }; +} + +/** + * Assert that a response matches the expected translation format + */ +export function expectValidTranslationResponse(response: any) { + expect(response).toHaveProperty("code"); + expect(response).toHaveProperty("data"); + expect(response).toHaveProperty("id"); + expect(response).toHaveProperty("source_lang"); + expect(response).toHaveProperty("target_lang"); + expect(typeof response.code).toBe("number"); + expect(typeof response.id).toBe("number"); + expect(typeof response.source_lang).toBe("string"); + expect(typeof response.target_lang).toBe("string"); +} + +/** + * Assert that a response matches the expected error format + */ +export function expectValidErrorResponse(response: any) { + expect(response).toHaveProperty("code"); + expect(response).toHaveProperty("data"); + expect(response.code).toBeGreaterThanOrEqual(400); + expect(response.data).toBeNull(); +} + +/** + * Create a comprehensive test environment + */ +export function createTestEnvironment() { + const cacheKV = createMockKV(); + const rateLimitKV = createMockKV(); + + const env: Env = { + CACHE_KV: cacheKV as any, + RATE_LIMIT_KV: rateLimitKV as any, + + { status: 200 } + ) + ), + }), + } as any, + ANALYTICS: { + writeDataPoint: jest.fn(), + } as any, + PROXY_URLS: + "https://test1.example.com/jsonrpc,https://test2.example.com/jsonrpc", + PROXY_WEIGHTS: "1,1", + DEBUG_MODE: "true", + }; + + return { + env, + cacheKV, + rateLimitKV, + cleanup: () => { + cacheKV._storage.clear(); + rateLimitKV._storage.clear(); + jest.clearAllMocks(); + }, + }; +} + +/** + * Performance measurement utility + */ +export class PerformanceTimer { + private startTime: number = 0; + private endTime: number = 0; + + start(): void { + this.startTime = Date.now(); + } + + stop(): number { + this.endTime = Date.now(); + return this.endTime - this.startTime; + } + + get duration(): number { + return this.endTime - this.startTime; + } +} + +/** + * Retry utility for flaky tests + */ +export async function retryTest( + testFn: () => Promise, + maxRetries: number = 3, + delay: number = 100 +): Promise { + let lastError: Error; + + for (let i = 0; i <= maxRetries; i++) { + try { + return await testFn(); + } catch (error) { + lastError = error as Error; + if (i < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + + throw lastError!; +} diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts new file mode 100644 index 0000000..63abec2 --- /dev/null +++ b/worker-configuration.d.ts @@ -0,0 +1,59 @@ +/** + * Cloudflare Workers Environment Bindings + * Defines all required and optional environment bindings for DeepLX + */ +interface Env { + /** KV namespace for translation caching */ + CACHE_KV: KVNamespace; + + /** KV namespace for rate limiting data */ + RATE_LIMIT_KV: KVNamespace; + + /** Analytics Engine dataset for metrics collection */ + ANALYTICS: AnalyticsEngineDataset; + + /** Comma-separated list of proxy URLs (optional) */ + PROXY_URLS?: string; + + /** Comma-separated list of proxy weights (optional) */ + PROXY_WEIGHTS?: string; +} + +/** + * Cache entry structure for translation storage + */ +interface CacheEntry { + /** Translated text content */ + data: string; + + /** Timestamp when translation was cached */ + timestamp: number; + + /** Source language code (uppercase) */ + source_lang: string; + + /** Target language code (uppercase) */ + target_lang: string; + + /** Optional unique request identifier */ + id?: number; +} + +/** + * Rate limiting entry structure for token bucket algorithm + */ +interface RateLimitEntry { + /** Current number of available tokens */ + tokens: number; + + /** Timestamp of last token refill */ + lastRefill: number; +} + +/** + * Proxy endpoint configuration + */ +interface ProxyEndpoint { + /** Proxy URL endpoint */ + url: string; +} diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..183c21d --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,38 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "account_id": "07c56e0dc4b3b1597495aa78fe7ebbf8", + "analytics_engine_datasets": [ + { + "binding": "ANALYTICS" + } + ], + "compatibility_date": "2025-07-19", + "kv_namespaces": [ + { + "binding": "CACHE_KV", + "id": "4d5895d8716247f486493b93edbc5fe9", + "preview_id": "e9e499fe006e4879a0668e792fe2dd15" + }, + { + "binding": "RATE_LIMIT_KV", + "id": "07147ca64a4c48fe952f62afe469bb44", + "preview_id": "bbed039364e5488e8ed58701c3ee5da5" + } + ], + "main": "src/index.ts", + "name": "deeplx", + "observability": { + "enabled": true + }, + "placement": { + "mode": "smart" + }, + "triggers": { + "crons": ["*/5 * * * *"] + }, + "vars": { + "DEBUG_MODE": "false", + "PROXY_URLS": "https://0.xdpl.xixu.dpdns.org/jsonrpc,https://1.xdpl.xixu.dpdns.org/jsonrpc,https://2.xdpl.xixu.dpdns.org/jsonrpc,https://3.xdpl.xixu.dpdns.org/jsonrpc,https://4.xdpl.xixu.dpdns.org/jsonrpc,https://5.xdpl.xixu.dpdns.org/jsonrpc,https://6.xdpl.xixu.dpdns.org/jsonrpc,https://7.xdpl.xixu.dpdns.org/jsonrpc,https://8.xdpl.xixu.dpdns.org/jsonrpc,https://9.xdpl.xixu.dpdns.org/jsonrpc" + }, + "workers_dev": false +}