├── .commitlintrc.ts ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── conventional-pr.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .husky └── _ │ ├── commit-msg │ ├── pre-commit │ └── prepare-commit-msg ├── .npmignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── biome.json ├── bun.lockb ├── lefthook.yml ├── package.json ├── packages ├── wobe-benchmark │ ├── README.md │ ├── index.ts │ ├── package.json │ ├── pathExtract │ │ └── benchmark.ts │ ├── router │ │ ├── benchmark.ts │ │ ├── findMyWay.ts │ │ ├── hono.ts │ │ ├── koaRouter.ts │ │ ├── radix3.ts │ │ ├── tools.ts │ │ └── wobe.ts │ ├── startup │ │ ├── benchmark.ts │ │ ├── elysia.ts │ │ └── wobe.ts │ └── tsconfig.json ├── wobe-documentation │ ├── .gitignore │ ├── .vitepress │ │ └── config.ts │ ├── README.md │ ├── doc │ │ ├── concepts │ │ │ ├── context.md │ │ │ ├── route.md │ │ │ ├── websocket.md │ │ │ └── wobe.md │ │ ├── ecosystem │ │ │ ├── hooks │ │ │ │ ├── bearer-auth.md │ │ │ │ ├── body-limit.md │ │ │ │ ├── cors.md │ │ │ │ ├── csrf.md │ │ │ │ ├── index.md │ │ │ │ ├── logger.md │ │ │ │ ├── rate-limit.md │ │ │ │ ├── secure-headers.md │ │ │ │ ├── upload-directory.md │ │ │ │ └── validator.md │ │ │ └── plugins │ │ │ │ ├── graphql-apollo-server.md │ │ │ │ ├── graphql-yoga.md │ │ │ │ └── index.md │ │ └── wobe │ │ │ ├── benchmark.md │ │ │ └── motivations.md │ ├── index.md │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ └── logo.png │ └── tsconfig.json ├── wobe-graphql-apollo │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.test.ts │ │ └── index.ts │ └── tsconfig.json ├── wobe-graphql-yoga │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.test.ts │ │ └── index.ts │ └── tsconfig.json ├── wobe-validator │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.test.ts │ │ └── index.ts │ └── tsconfig.json └── wobe │ ├── README.md │ ├── dev │ └── index.ts │ ├── fixtures │ ├── avatar.jpg │ ├── cert.pem │ ├── key.pem │ └── testFile.html │ ├── package.json │ ├── src │ ├── Context.test.ts │ ├── Context.ts │ ├── HttpException.ts │ ├── Wobe.test.ts │ ├── Wobe.ts │ ├── WobeResponse.test.ts │ ├── WobeResponse.ts │ ├── adapters │ │ ├── bun │ │ │ ├── bun.test.ts │ │ │ ├── bun.ts │ │ │ ├── index.ts │ │ │ ├── websocket.test.ts │ │ │ └── websocket.ts │ │ ├── index.ts │ │ └── node │ │ │ ├── index.ts │ │ │ ├── node.test.ts │ │ │ └── node.ts │ ├── hooks │ │ ├── bearerAuth.test.ts │ │ ├── bearerAuth.ts │ │ ├── bodyLimit.test.ts │ │ ├── bodyLimit.ts │ │ ├── cors.test.ts │ │ ├── cors.ts │ │ ├── csrf.test.ts │ │ ├── csrf.ts │ │ ├── index.ts │ │ ├── logger.test.ts │ │ ├── logger.ts │ │ ├── rateLimit.test.ts │ │ ├── rateLimit.ts │ │ ├── secureHeaders.test.ts │ │ ├── secureHeaders.ts │ │ ├── uploadDirectory.test.ts │ │ └── uploadDirectory.ts │ ├── index.ts │ ├── router │ │ ├── RadixTree.test.ts │ │ ├── RadixTree.ts │ │ └── index.ts │ ├── tools │ │ ├── WobeStore.test.ts │ │ ├── WobeStore.ts │ │ └── index.ts │ ├── utils.test.ts │ └── utils.ts │ └── tsconfig.json └── tsconfig.json /.commitlintrc.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfig } from '@commitlint/types' 2 | 3 | const Configuration: UserConfig = { 4 | extends: ['@commitlint/config-conventional'], 5 | } 6 | 7 | export default Configuration 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Additional context** 20 | Add any other context about the problem here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here (code example). 18 | -------------------------------------------------------------------------------- /.github/workflows/conventional-pr.yml: -------------------------------------------------------------------------------- 1 | name: 'Conventional PR' 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | permissions: 11 | pull-requests: read 12 | 13 | jobs: 14 | main: 15 | name: Validate PR title 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: amannn/action-semantic-pull-request@v5 19 | with: 20 | requireScope: true 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | package: 7 | description: 'Package to release' 8 | required: true 9 | type: choice 10 | options: 11 | - 'wobe' 12 | - 'wobe-graphql-yoga' 13 | - 'wobe-graphql-apollo' 14 | - 'wobe-validator' 15 | 16 | jobs: 17 | release: 18 | runs-on: ubuntu-latest 19 | name: 'Release ${{inputs.package}}' 20 | 21 | permissions: 22 | contents: write 23 | pull-requests: read 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | 30 | - uses: actions/setup-node@v4 31 | with: 32 | node-version: 22 33 | registry-url: 'https://registry.npmjs.org' 34 | 35 | - uses: oven-sh/setup-bun@v2 36 | with: 37 | bun-version: latest 38 | registry-url: 'https://registry.npmjs.org' 39 | 40 | - run: bun install 41 | - run: bun ci 42 | - run: bun --filter ./packages/${{inputs.package}} build 43 | 44 | - name: 'Get Previous tag' 45 | id: previousTag 46 | run: | 47 | echo "Fetching tags..." 48 | previous_tag=$(git tag -l "${{inputs.package}}-[0-9]*" --sort=-v:refname | head -n 1) 49 | echo "Found previous tag: $previous_tag" 50 | echo "tag=$previous_tag" >> $GITHUB_ENV 51 | 52 | - name: 'Get Next Version from package.json' 53 | id: nextVersion 54 | run: | 55 | package_json_path="./packages/${{inputs.package}}/package.json" 56 | if [ ! -f "$package_json_path" ]; then 57 | echo "Error: $package_json_path not found." 58 | exit 1 59 | fi 60 | next_version=$(jq -r .version < "$package_json_path") 61 | if [ -z "$next_version" ]; then 62 | echo "Error: Version not found in $package_json_path." 63 | exit 1 64 | fi 65 | echo "Next version: $next_version" 66 | echo "next_version=$next_version" >> $GITHUB_ENV 67 | 68 | # We publish on NPM before releasing on GitHub to avoid releasing a version that is not published 69 | - run: npm publish --access=public --workspace=${{inputs.package}} 70 | env: 71 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 72 | 73 | - name: Create tag 74 | uses: actions/github-script@v7 75 | with: 76 | script: | 77 | github.rest.git.createRef({ 78 | owner: context.repo.owner, 79 | repo: context.repo.repo, 80 | ref: 'refs/tags/${{inputs.package}}-${{env.next_version}}', 81 | sha: context.sha 82 | }) 83 | 84 | - name: Build Changelog 85 | id: github_release 86 | uses: mikepenz/release-changelog-builder-action@v4.2.2 87 | with: 88 | fromTag: ${{ env.tag }} 89 | toTag: ${{inputs.package}}-${{env.next_version}} 90 | configurationJson: | 91 | { 92 | "categories": [ 93 | { 94 | "title": "## 🚀 Features", 95 | "labels": ["feat"] 96 | }, 97 | { 98 | "title": "## 🐛 Fixes", 99 | "labels": ["fix", "bug"] 100 | }, 101 | { 102 | "key": "tests", 103 | "title": "## 🧪 Tests", 104 | "labels": ["test"] 105 | }, 106 | { 107 | "key": "doc", 108 | "title": "## 📚 Documentation", 109 | "labels": ["docs", "doc"] 110 | }, 111 | { 112 | "key": "misc", 113 | "title": "## 💬 Miscellaneous", 114 | "labels": ["ci", "chore", "perf", "refactor"] 115 | } 116 | ], 117 | "template": "#{{CHANGELOG}}\n\n", 118 | "pr_template": "- #{{TITLE}} (by @#{{AUTHOR}} in ##{{NUMBER}}) ", 119 | "label_extractor": [ 120 | { 121 | "pattern": "^(ci|chore|doc|docs|feat|fix|bug|perf|refactor|test|tests)\\(${{inputs.package}}\\):(.*)", 122 | "target": "$1", 123 | "on_property": "title" 124 | } 125 | ] 126 | } 127 | 128 | env: 129 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 130 | 131 | - name: Create Release 132 | uses: softprops/action-gh-release@v2 133 | with: 134 | tag_name: ${{inputs.package}}-${{env.next_version}} 135 | body: ${{steps.github_release.outputs.changelog}} 136 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: 7 | - '**' 8 | types: [opened, synchronize, reopened, unlabeled] 9 | push: 10 | branches: 11 | - 'main' 12 | paths-ignore: 13 | - 'docs/**' 14 | - 'examples/**' 15 | 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | test: 22 | timeout-minutes: 10 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: oven-sh/setup-bun@v2 27 | with: 28 | bun-version: latest 29 | - run: bun install 30 | - run: bun ci 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | .cache 3 | node_modules/ 4 | .npm 5 | .env 6 | .env.development.local 7 | .env.test.local 8 | .env.production.local 9 | .env.local 10 | 11 | cache 12 | build 13 | lib 14 | dist 15 | 16 | .DS_Store 17 | -------------------------------------------------------------------------------- /.husky/_/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then 4 | set -x 5 | fi 6 | 7 | if [ "$LEFTHOOK" = "0" ]; then 8 | exit 0 9 | fi 10 | 11 | call_lefthook() 12 | { 13 | dir="$(git rev-parse --show-toplevel)" 14 | osArch=$(uname | tr '[:upper:]' '[:lower:]') 15 | cpuArch=$(uname -m | sed 's/aarch64/arm64/') 16 | 17 | if test -n "$LEFTHOOK_BIN" 18 | then 19 | "$LEFTHOOK_BIN" "$@" 20 | elif lefthook -h >/dev/null 2>&1 21 | then 22 | lefthook "$@" 23 | elif test -f "$dir/node_modules/lefthook/bin/index.js" 24 | then 25 | "$dir/node_modules/lefthook/bin/index.js" "$@" 26 | elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook_${osArch}_${cpuArch}/lefthook" 27 | then 28 | "$dir/node_modules/@evilmartians/lefthook/bin/lefthook_${osArch}_${cpuArch}/lefthook" "$@" 29 | elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook_${osArch}_${cpuArch}/lefthook" 30 | then 31 | "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook_${osArch}_${cpuArch}/lefthook" "$@" 32 | 33 | elif bundle exec lefthook -h >/dev/null 2>&1 34 | then 35 | bundle exec lefthook "$@" 36 | elif yarn lefthook -h >/dev/null 2>&1 37 | then 38 | yarn lefthook "$@" 39 | elif pnpm lefthook -h >/dev/null 2>&1 40 | then 41 | pnpm lefthook "$@" 42 | elif swift package plugin lefthook >/dev/null 2>&1 43 | then 44 | swift package --disable-sandbox plugin lefthook "$@" 45 | elif command -v npx >/dev/null 2>&1 46 | then 47 | npx lefthook "$@" 48 | else 49 | echo "Can't find lefthook in PATH" 50 | fi 51 | } 52 | 53 | call_lefthook run "commit-msg" "$@" 54 | -------------------------------------------------------------------------------- /.husky/_/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then 4 | set -x 5 | fi 6 | 7 | if [ "$LEFTHOOK" = "0" ]; then 8 | exit 0 9 | fi 10 | 11 | call_lefthook() 12 | { 13 | dir="$(git rev-parse --show-toplevel)" 14 | osArch=$(uname | tr '[:upper:]' '[:lower:]') 15 | cpuArch=$(uname -m | sed 's/aarch64/arm64/') 16 | 17 | if test -n "$LEFTHOOK_BIN" 18 | then 19 | "$LEFTHOOK_BIN" "$@" 20 | elif lefthook -h >/dev/null 2>&1 21 | then 22 | lefthook "$@" 23 | elif test -f "$dir/node_modules/lefthook/bin/index.js" 24 | then 25 | "$dir/node_modules/lefthook/bin/index.js" "$@" 26 | elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook_${osArch}_${cpuArch}/lefthook" 27 | then 28 | "$dir/node_modules/@evilmartians/lefthook/bin/lefthook_${osArch}_${cpuArch}/lefthook" "$@" 29 | elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook_${osArch}_${cpuArch}/lefthook" 30 | then 31 | "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook_${osArch}_${cpuArch}/lefthook" "$@" 32 | 33 | elif bundle exec lefthook -h >/dev/null 2>&1 34 | then 35 | bundle exec lefthook "$@" 36 | elif yarn lefthook -h >/dev/null 2>&1 37 | then 38 | yarn lefthook "$@" 39 | elif pnpm lefthook -h >/dev/null 2>&1 40 | then 41 | pnpm lefthook "$@" 42 | elif swift package plugin lefthook >/dev/null 2>&1 43 | then 44 | swift package --disable-sandbox plugin lefthook "$@" 45 | elif command -v npx >/dev/null 2>&1 46 | then 47 | npx lefthook "$@" 48 | else 49 | echo "Can't find lefthook in PATH" 50 | fi 51 | } 52 | 53 | call_lefthook run "pre-commit" "$@" 54 | -------------------------------------------------------------------------------- /.husky/_/prepare-commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then 4 | set -x 5 | fi 6 | 7 | if [ "$LEFTHOOK" = "0" ]; then 8 | exit 0 9 | fi 10 | 11 | call_lefthook() 12 | { 13 | dir="$(git rev-parse --show-toplevel)" 14 | osArch=$(uname | tr '[:upper:]' '[:lower:]') 15 | cpuArch=$(uname -m | sed 's/aarch64/arm64/') 16 | 17 | if test -n "$LEFTHOOK_BIN" 18 | then 19 | "$LEFTHOOK_BIN" "$@" 20 | elif lefthook -h >/dev/null 2>&1 21 | then 22 | lefthook "$@" 23 | elif test -f "$dir/node_modules/lefthook/bin/index.js" 24 | then 25 | "$dir/node_modules/lefthook/bin/index.js" "$@" 26 | elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook_${osArch}_${cpuArch}/lefthook" 27 | then 28 | "$dir/node_modules/@evilmartians/lefthook/bin/lefthook_${osArch}_${cpuArch}/lefthook" "$@" 29 | elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook_${osArch}_${cpuArch}/lefthook" 30 | then 31 | "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook_${osArch}_${cpuArch}/lefthook" "$@" 32 | 33 | elif bundle exec lefthook -h >/dev/null 2>&1 34 | then 35 | bundle exec lefthook "$@" 36 | elif yarn lefthook -h >/dev/null 2>&1 37 | then 38 | yarn lefthook "$@" 39 | elif pnpm lefthook -h >/dev/null 2>&1 40 | then 41 | pnpm lefthook "$@" 42 | elif swift package plugin lefthook >/dev/null 2>&1 43 | then 44 | swift package --disable-sandbox plugin lefthook "$@" 45 | elif command -v npx >/dev/null 2>&1 46 | then 47 | npx lefthook "$@" 48 | else 49 | echo "Can't find lefthook in PATH" 50 | fi 51 | } 52 | 53 | call_lefthook run "prepare-commit-msg" "$@" 54 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | logs 2 | .cache 3 | node_modules/ 4 | .npm 5 | .env 6 | .env.development.local 7 | .env.test.local 8 | .env.production.local 9 | .env.local 10 | 11 | cache 12 | build 13 | lib 14 | src 15 | tests 16 | test 17 | CONTRIBUTING.md 18 | CODE_OF_CONDUCT.md 19 | CHANGELOG.md 20 | tsconfig.json 21 | .git 22 | bun.lockb 23 | dev 24 | fixtures 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | Contributions are welcome! Here's how you can help: 4 | 5 | - **Report a bug**: If you find a bug, please open an issue. 6 | - **Request a feature**: If you have an idea for a feature, please open an issue. 7 | - **Create a pull request**: If you can fix a bug or implement a feature, please create a pull request (I promise a quick review). 8 | - **Use Wobe**: The best way to contribute is to use Wobe in your application. 9 | 10 | Note: Each code contribution must be tested either by an existing test or a new one. 11 | 12 | ## Install 13 | 14 | Wobe uses Bun, so you need the latest version of Bun. You can see [here](https://bun.sh/docs/installation) if Bun is not installed on your machine. 15 | 16 | Wobe uses a monorepo organization, all the packages are under the `packages` directory. 17 | 18 | Once you have cloned the repository you can run the following command at the root of the project. 19 | 20 | ```sh 21 | bun install 22 | ``` 23 | 24 | You can run the tests in all packages by running the following commands at the root repository: 25 | 26 | ```sh 27 | bun test # Run test on all packages 28 | # or 29 | bun ci # Run test and lint on all packages 30 | ``` 31 | 32 | ## Pre-commit 33 | 34 | Before any commit a pre-commit command that will run on your machine to ensure that the code is correctly formatted and the lint is respected. If you have any error of formatting during the pre-commit you can simply run the following command (at the root of the repository): 35 | 36 | Wobe repository also uses the conventional commits to ensure a consistence and facilitate the release. Yours PR and yours commits need to follow this convention. You can see here to see more informations about [Conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). 37 | 38 | ```sh 39 | bun format 40 | ``` 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Lucas Coratger 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Logo 3 |

4 |

Wobe

5 | 6 |
7 | Documentation 8 |   •   9 | Discord 10 |
11 | 12 | ## What is Wobe? 13 | 14 | **Wobe** is a simple, fast, and lightweight web framework. Inspired by some web frameworks like Express, Hono, Elysia. It works on Node and Bun runtime. 15 | 16 | Wobe is very fast but not focused on performance; it focuses on simplicity and ease of use. It's very easy to create a web server with Wobe. 17 | 18 | ## Install 19 | 20 | ```sh 21 | bun install wobe # On bun 22 | npm install wobe # On npm 23 | yarn add wobe # On yarn 24 | ``` 25 | 26 | ## Basic example 27 | 28 | ```ts 29 | import { Wobe } from 'wobe' 30 | 31 | const app = new Wobe() 32 | .get('/hello', (context) => context.res.sendText('Hello world')) 33 | .get('/hello/:name', (context) => 34 | context.res.sendText(`Hello ${context.params.name}`), 35 | ) 36 | .listen(3000) 37 | ``` 38 | 39 | ## Features 40 | 41 | - **Simple & Easy to use**: Wobe respects the standard and provides a large ecosystem. 42 | - **Fast & Lightweight**: Wobe is one of the fastest web framework on Bun, and it has 0 dependencies (only 9,76 KB). 43 | - **Multi-runtime**: Wobe supports Node.js and Bun runtime. 44 | - **Easy to extend**: Wobe has an easy-to-use plugin system that allows extending for all your personal use cases. 45 | 46 | ## Benchmarks (on Bun runtime) 47 | 48 | Wobe is one of the fastest web framework based on the [benchmark](https://github.com/SaltyAom/bun-http-framework-benchmark) of SaltyAom. 49 | 50 | | Framework | Runtime | Average | Ping | Query | Body | 51 | | --------- | ------- | ---------- | ---------- | --------- | --------- | 52 | | bun | bun | 92,639.313 | 103,439.17 | 91,646.07 | 82,832.7 | 53 | | elysia | bun | 92,445.227 | 103,170.47 | 88,716.17 | 85,449.04 | 54 | | wobe | bun | 90,535.37 | 96,348.26 | 94,625.67 | 80,632.18 | 55 | | hono | bun | 81,832.787 | 89,200.82 | 81,096.3 | 75,201.24 | 56 | | fastify | bun | 49,648.977 | 62,511.85 | 58,904.51 | 27,530.57 | 57 | | express | bun | 31,370.06 | 39,775.79 | 36,605.68 | 17,728.71 | 58 | 59 | _Executed with 5 runs - 12/04/2024_ 60 | 61 | ## Contributing 62 | 63 | Contributions are always welcome! If you have an idea for something that should be added, modified, or removed, please don't hesitate to create a pull request (I promise a quick review). 64 | 65 | You can also create an issue to propose your ideas or report a bug. 66 | 67 | Of course, you can also use Wobe in your application; that is the better contribution at this day ❤️. 68 | 69 | If you like the project don't forget to share it. 70 | 71 | More informations on the [Contribution guide](https://github.com/palixir/wobe/blob/main/CONTRIBUTING) 72 | 73 | ## License 74 | 75 | Distributed under the MIT [License](https://github.com/palixir/wobe/blob/main/LICENSE). 76 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "files": { 4 | "include": ["**/*.ts", "**/*.js"], 5 | "ignore": [ 6 | "**/dist/**", 7 | "**/node_modules/**", 8 | ".vitepress/**", 9 | "build/**" 10 | ], 11 | "ignoreUnknown": true 12 | }, 13 | "organizeImports": { 14 | "enabled": false 15 | }, 16 | "linter": { 17 | "enabled": true, 18 | "rules": { 19 | "recommended": true, 20 | "suspicious": { 21 | "noExplicitAny": "off" 22 | }, 23 | "style": { 24 | "useTemplate": "off" 25 | } 26 | } 27 | }, 28 | "json": { 29 | "formatter": { 30 | "indentWidth": 4 31 | } 32 | }, 33 | "formatter": { 34 | "enabled": true, 35 | "indentWidth": 4, 36 | "indentStyle": "tab" 37 | }, 38 | "javascript": { 39 | "formatter": { 40 | "quoteStyle": "single", 41 | "semicolons": "asNeeded" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palixir/wobe/827a1812c417cb3be1bb63d3dd931e0d9679fcde/bun.lockb -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | commit-msg: 2 | commands: 3 | conventional: 4 | run: bun commitlint --edit $1 5 | 6 | pre-commit: 7 | parallel: true 8 | commands: 9 | check: 10 | glob: '*.{js,ts,jsx,tsx}' 11 | run: bun biome check --no-errors-on-unmatched --files-ignore-unknown=true {staged_files} 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "main", 3 | "version": "1.0.0", 4 | "workspaces": ["packages/*"], 5 | "devDependencies": { 6 | "@biomejs/biome": "1.9.4", 7 | "@commitlint/cli": "19.3.0", 8 | "@commitlint/config-conventional": "19.2.2", 9 | "@types/bun": "latest", 10 | "lefthook": "1.6.10", 11 | "typescript": "5.4.2" 12 | }, 13 | "scripts": { 14 | "build:wobe": "bun --filter './packages/wobe' build", 15 | "ci": "bun build:wobe && bun --filter './packages/*' ci", 16 | "format": "bun --filter './packages/*' format && biome format --write ./*.json", 17 | "lint": "bun --filter './packages/*' lint", 18 | "pre:commit": "biome lint ./packages --no-errors-on-unmatched && biome format --write ./packages", 19 | "squash": "base_branch=${1:-main} && git fetch origin $base_branch && branch=$(git branch --show-current) && git checkout $branch && git reset $(git merge-base origin/$base_branch $branch) && git add -A" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/wobe-benchmark/README.md: -------------------------------------------------------------------------------- 1 | # wobe-graphql-yoga 2 | 3 | To install dependencies: 4 | 5 | ```bash 6 | bun install 7 | ``` 8 | 9 | To run: 10 | 11 | ```bash 12 | bun run index.ts 13 | ``` 14 | 15 | This project was created using `bun init` in bun v1.0.33. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. 16 | -------------------------------------------------------------------------------- /packages/wobe-benchmark/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palixir/wobe/827a1812c417cb3be1bb63d3dd931e0d9679fcde/packages/wobe-benchmark/index.ts -------------------------------------------------------------------------------- /packages/wobe-benchmark/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wobe-benchmark", 3 | "version": "0.1.0", 4 | "main": "index.ts", 5 | "dependencies": { 6 | "hono": "4.1.4", 7 | "koa-router": "12.0.1", 8 | "radix3": "1.1.2", 9 | "wobe": "*" 10 | }, 11 | "devDependencies": { 12 | "elysia": "^1.0.16", 13 | "get-port": "^7.1.0", 14 | "mitata": "0.1.11" 15 | }, 16 | "scripts": { 17 | "bench:startup": "bun run startup/benchmark.ts", 18 | "bench:router": "bun run router/benchmark.ts", 19 | "bench:extracter": "bun run pathExtract/benchmark.ts", 20 | "bench:findHook": "bun run findHook/benchmark.ts" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/wobe-benchmark/pathExtract/benchmark.ts: -------------------------------------------------------------------------------- 1 | import { getPath, getQueryParams } from 'hono/utils/url' 2 | import { run, bench, group } from 'mitata' 3 | import { extractPathnameAndSearchParams } from 'wobe' 4 | 5 | interface RouteInterface { 6 | request: Request 7 | hasParams: boolean 8 | } 9 | 10 | const routes: Array = [ 11 | { 12 | request: new Request('https://localhost:3000/user/comments'), 13 | hasParams: false, 14 | }, 15 | { 16 | request: new Request('https://localhost:3000/user/lookup/username/hey'), 17 | hasParams: false, 18 | }, 19 | { 20 | request: new Request('https://localhost:3000/event/abcd1234/comments'), 21 | hasParams: false, 22 | }, 23 | { 24 | request: new Request('https://localhost:3000/event/abcd1234/comment'), 25 | hasParams: false, 26 | }, 27 | { 28 | request: new Request( 29 | 'https://localhost:3000/very/deeply/nested/route/hello/there', 30 | ), 31 | hasParams: false, 32 | }, 33 | { 34 | request: new Request('http://localhost:3000/test?name=John&age=30'), 35 | hasParams: true, 36 | }, 37 | ] 38 | 39 | const extracters = [ 40 | { 41 | name: 'Wobe', 42 | fn: ({ request }: RouteInterface) => { 43 | return extractPathnameAndSearchParams(request.url) 44 | }, 45 | }, 46 | { 47 | name: 'Hono', 48 | fn: ({ request, hasParams }: RouteInterface) => { 49 | const path = getPath(request) 50 | 51 | if (hasParams) { 52 | const queryString = getQueryParams(request.url) 53 | 54 | return { path: path, searchParams: queryString } 55 | } 56 | 57 | return path 58 | }, 59 | }, 60 | ] 61 | 62 | for (const route of routes) { 63 | group(route.request.url, () => { 64 | for (const { fn, name } of extracters) { 65 | bench(name, async () => { 66 | fn(route) 67 | }) 68 | } 69 | }) 70 | } 71 | 72 | group('all routes together', () => { 73 | for (const { fn, name } of extracters) { 74 | bench(name, async () => { 75 | for (const route of routes) { 76 | fn(route) 77 | } 78 | }) 79 | } 80 | }) 81 | 82 | await run() 83 | -------------------------------------------------------------------------------- /packages/wobe-benchmark/router/benchmark.ts: -------------------------------------------------------------------------------- 1 | import { run, bench, group } from 'mitata' 2 | import { routes, type RouterInterface } from './tools' 3 | import { smartRouter, trieRouter } from './hono' 4 | import { wobeRouter } from './wobe' 5 | import { findMyWayRouter } from './findMyWay' 6 | import { koaRouter } from './koaRouter' 7 | import { radix3Router } from './radix3' 8 | 9 | const routers: RouterInterface[] = [ 10 | smartRouter, 11 | trieRouter, 12 | findMyWayRouter, 13 | koaRouter, 14 | wobeRouter, 15 | radix3Router, 16 | ] 17 | 18 | for (const route of routes) { 19 | group(`${route.name} - ${route.method} ${route.path}`, () => { 20 | for (const router of routers) { 21 | bench(router.name, async () => { 22 | router.match(route) 23 | }) 24 | } 25 | }) 26 | } 27 | 28 | group('all together', () => { 29 | for (const router of routers) { 30 | bench(router.name, async () => { 31 | for (const route of routes) { 32 | router.match(route) 33 | } 34 | }) 35 | } 36 | }) 37 | 38 | await run() 39 | -------------------------------------------------------------------------------- /packages/wobe-benchmark/router/findMyWay.ts: -------------------------------------------------------------------------------- 1 | import type { HTTPMethod } from 'find-my-way' 2 | import findMyWay from 'find-my-way' 3 | import type { RouterInterface } from './tools' 4 | import { routes, handler } from './tools' 5 | 6 | const name = 'find-my-way' 7 | const router = findMyWay() 8 | 9 | for (const route of routes) { 10 | router.on(route.method as HTTPMethod, route.pathToCompile, handler) 11 | } 12 | 13 | export const findMyWayRouter: RouterInterface = { 14 | name, 15 | match: (route) => { 16 | return router.find(route.method as HTTPMethod, route.path) 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /packages/wobe-benchmark/router/hono.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from 'hono/router' 2 | import { handler, routes, type RouterInterface } from './tools' 3 | import { RegExpRouter } from 'hono/router/reg-exp-router' 4 | import { TrieRouter } from 'hono/router/trie-router' 5 | import { SmartRouter } from 'hono/router/smart-router' 6 | 7 | const createHonoRouter = ( 8 | name: string, 9 | router: Router, 10 | ): RouterInterface => { 11 | for (const route of routes) { 12 | router.add(route.method, route.pathToCompile, handler) 13 | } 14 | 15 | return { 16 | name: `Hono ${name}`, 17 | match: (route) => { 18 | return router.match(route.method, route.path) 19 | }, 20 | } 21 | } 22 | 23 | export const smartRouter = createHonoRouter( 24 | 'SmartRouter (RegExp + Trie)', 25 | new SmartRouter({ 26 | routers: [new RegExpRouter(), new TrieRouter()], 27 | }), 28 | ) 29 | export const trieRouter = createHonoRouter('TrieRouter', new TrieRouter()) 30 | -------------------------------------------------------------------------------- /packages/wobe-benchmark/router/koaRouter.ts: -------------------------------------------------------------------------------- 1 | import KoaRouter from 'koa-router' 2 | import type { RouterInterface } from './tools' 3 | import { routes, handler } from './tools' 4 | 5 | const name = 'koa-router' 6 | const router = new KoaRouter() 7 | 8 | for (const route of routes) { 9 | if (route.method === 'GET') { 10 | router.get(route.pathToCompile.replace('*', '(.*)'), handler) 11 | } else { 12 | router.post(route.pathToCompile, handler) 13 | } 14 | } 15 | 16 | export const koaRouter: RouterInterface = { 17 | name, 18 | match: (route) => { 19 | return router.match(route.path, route.method) // only matching 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /packages/wobe-benchmark/router/radix3.ts: -------------------------------------------------------------------------------- 1 | import { createRouter } from 'radix3' 2 | import { handler, routes, type RouterInterface } from './tools' 3 | 4 | const name = 'radix3' 5 | const router = createRouter() 6 | 7 | for (const route of routes) { 8 | router.insert(route.pathToCompile, handler) 9 | } 10 | 11 | export const radix3Router: RouterInterface = { 12 | name, 13 | match: (route) => { 14 | return router.lookup(route.path) 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /packages/wobe-benchmark/router/tools.ts: -------------------------------------------------------------------------------- 1 | export const handler = () => {} 2 | 3 | export type Route = { 4 | name: string 5 | method: 'GET' | 'POST' 6 | pathToCompile: string 7 | path: string 8 | handler: () => void 9 | } 10 | 11 | export interface RouterInterface { 12 | name: string 13 | match: (route: Route) => unknown 14 | } 15 | 16 | export const routes: Route[] = [ 17 | { 18 | name: 'short static', 19 | method: 'GET', 20 | pathToCompile: '/user', 21 | path: '/user', 22 | handler, 23 | }, 24 | { 25 | name: 'static with same radix', 26 | method: 'GET', 27 | pathToCompile: '/user/comments', 28 | path: '/user/comments', 29 | handler, 30 | }, 31 | { 32 | name: 'dynamic route', 33 | method: 'GET', 34 | pathToCompile: '/user/lookup/username/:username', 35 | path: '/user/lookup/username/hey', 36 | handler, 37 | }, 38 | { 39 | name: 'mixed static dynamic', 40 | method: 'GET', 41 | pathToCompile: '/event/:id/comments', 42 | path: '/event/abcd1234/comments', 43 | handler, 44 | }, 45 | { 46 | name: 'post', 47 | method: 'POST', 48 | pathToCompile: '/event/:id/comment', 49 | path: '/event/abcd1234/comment', 50 | handler, 51 | }, 52 | { 53 | name: 'long static', 54 | method: 'GET', 55 | pathToCompile: '/very/deeply/nested/route/hello/there', 56 | path: '/very/deeply/nested/route/hello/there', 57 | handler, 58 | }, 59 | { 60 | name: 'wildcard', 61 | method: 'GET', 62 | pathToCompile: '/static/*', 63 | path: '/static/index.html', 64 | handler, 65 | }, 66 | ] 67 | -------------------------------------------------------------------------------- /packages/wobe-benchmark/router/wobe.ts: -------------------------------------------------------------------------------- 1 | import { RadixTree } from 'wobe/src/router' 2 | import { routes, type Route } from './tools' 3 | 4 | const createWobeRouter = (name: string, radixTree: RadixTree) => { 5 | for (const route of routes) { 6 | radixTree.addRoute(route.method, route.pathToCompile, () => 7 | Promise.resolve(), 8 | ) 9 | } 10 | 11 | radixTree.optimizeTree() 12 | 13 | return { 14 | name: `Wobe ${name}`, 15 | match: (route: Route) => { 16 | return radixTree.findRoute(route.method, route.path) 17 | }, 18 | } 19 | } 20 | 21 | export const wobeRouter = createWobeRouter('Radix router', new RadixTree()) 22 | -------------------------------------------------------------------------------- /packages/wobe-benchmark/startup/benchmark.ts: -------------------------------------------------------------------------------- 1 | import { bench, run } from 'mitata' 2 | import { elysiaApp } from './elysia' 3 | import { wobeApp } from './wobe' 4 | 5 | const frameworks = [ 6 | { name: 'elysia', fn: elysiaApp }, 7 | { name: 'wobe', fn: wobeApp }, 8 | ] 9 | 10 | for (const framework of frameworks) { 11 | bench(framework.name, async () => { 12 | await framework.fn() 13 | }) 14 | } 15 | 16 | await run() 17 | -------------------------------------------------------------------------------- /packages/wobe-benchmark/startup/elysia.ts: -------------------------------------------------------------------------------- 1 | import { Elysia } from 'elysia' 2 | import getPort from 'get-port' 3 | 4 | export const elysiaApp = async () => { 5 | const port = await getPort() 6 | const elysia = new Elysia({ precompile: true }) 7 | .get('/', 'Hi') 8 | .post('/json', (c) => c.body, { 9 | type: 'json', 10 | }) 11 | .get('/id/:id', ({ set, params: { id }, query: { name } }) => { 12 | set.headers['x-powered-by'] = 'benchmark' 13 | 14 | return id + ' ' + name 15 | }) 16 | .listen(port) 17 | 18 | elysia.stop() 19 | } 20 | -------------------------------------------------------------------------------- /packages/wobe-benchmark/startup/wobe.ts: -------------------------------------------------------------------------------- 1 | import { Wobe } from 'wobe' 2 | import getPort from 'get-port' 3 | 4 | export const wobeApp = async () => { 5 | const port = await getPort() 6 | const wobe = new Wobe() 7 | .get('/', (ctx) => ctx.res.sendText('Hi')) 8 | .post('/json', async (ctx) => { 9 | return ctx.res.sendJson((await ctx.request.json()) as any) 10 | }) 11 | .get('/id/:id', (ctx) => { 12 | ctx.res.headers.set('x-powered-by', 'benchmark') 13 | 14 | return ctx.res.sendText(ctx.params.id + ' ' + ctx.query.name) 15 | }) 16 | .listen(port) 17 | 18 | wobe.stop() 19 | } 20 | -------------------------------------------------------------------------------- /packages/wobe-benchmark/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/wobe-documentation/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | .vercel 177 | -------------------------------------------------------------------------------- /packages/wobe-documentation/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | 3 | export default defineConfig({ 4 | title: 'Wobe', 5 | description: 'A Fast, lightweight simple web framework', 6 | lastUpdated: true, 7 | head: [ 8 | ['link', { rel: 'icon', href: '/favicon.ico' }], 9 | ['script', { src: '/_vercel/insights/script.js', defer: 'true' }], 10 | ], 11 | themeConfig: { 12 | search: { 13 | provider: 'local', 14 | }, 15 | sidebar: [ 16 | { 17 | text: 'Wobe', 18 | items: [ 19 | { text: 'Motivations', link: '/doc/wobe/motivations' }, 20 | { text: 'Benchmark', link: '/doc/wobe/benchmark' }, 21 | ], 22 | }, 23 | { 24 | text: 'Concepts', 25 | items: [ 26 | { text: 'Wobe', link: '/doc/concepts/wobe' }, 27 | { text: 'Routes', link: '/doc/concepts/route' }, 28 | { text: 'Context', link: '/doc/concepts/context' }, 29 | { text: 'Websocket', link: '/doc/concepts/websocket' }, 30 | ], 31 | }, 32 | { 33 | text: 'Ecosystem', 34 | items: [ 35 | { 36 | text: 'Plugins', 37 | link: '/doc/ecosystem/plugins/index', 38 | items: [ 39 | { 40 | text: 'GraphQL Yoga (official)', 41 | link: '/doc/ecosystem/plugins/graphql-yoga', 42 | }, 43 | { 44 | text: 'GraphQL Apollo Server (official)', 45 | link: '/doc/ecosystem/plugins/graphql-apollo-server', 46 | }, 47 | ], 48 | }, 49 | { 50 | text: 'Hooks', 51 | link: '/doc/ecosystem/hooks/index', 52 | items: [ 53 | { 54 | text: 'Cors', 55 | link: '/doc/ecosystem/hooks/cors', 56 | }, 57 | { 58 | text: 'CSRF', 59 | link: '/doc/ecosystem/hooks/csrf', 60 | }, 61 | { 62 | text: 'Bearer auth', 63 | link: '/doc/ecosystem/hooks/bearer-auth', 64 | }, 65 | { 66 | text: 'Body limit', 67 | link: '/doc/ecosystem/hooks/body-limit', 68 | }, 69 | { 70 | text: 'Logger', 71 | link: '/doc/ecosystem/hooks/logger', 72 | }, 73 | { 74 | text: 'Rate limit', 75 | link: '/doc/ecosystem/hooks/rate-limit', 76 | }, 77 | { 78 | text: 'Secure headers', 79 | link: '/doc/ecosystem/hooks/secure-headers', 80 | }, 81 | { 82 | text: 'Validator', 83 | link: '/doc/ecosystem/hooks/validator', 84 | }, 85 | { 86 | text: 'Upload directory', 87 | link: '/doc/ecosystem/hooks/upload-directory', 88 | }, 89 | ], 90 | }, 91 | ], 92 | }, 93 | ], 94 | footer: { 95 | message: 96 | 'Made with ❤️ by coratgerl', 97 | copyright: 'Copyright © 2024', 98 | }, 99 | socialLinks: [ 100 | { icon: 'github', link: 'https://github.com/coratgerl/wobe' }, 101 | { icon: 'discord', link: 'https://discord.gg/GVuyYXNvGg' }, 102 | ], 103 | }, 104 | }) 105 | -------------------------------------------------------------------------------- /packages/wobe-documentation/README.md: -------------------------------------------------------------------------------- 1 | # wobe-documentation 2 | 3 | To install dependencies: 4 | 5 | ```bash 6 | bun install 7 | ``` 8 | 9 | To run: 10 | 11 | ```bash 12 | bun run index.ts 13 | ``` 14 | 15 | This project was created using `bun init` in bun v1.1.3. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. 16 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/concepts/context.md: -------------------------------------------------------------------------------- 1 | # Wobe context 2 | 3 | ## Context object 4 | 5 | Each Wobe route handler receives a context object that contains some information about the request like the params, the ipAddress, the headers, the body, etc. It also contains the response object that you can use to send the response to the client. 6 | 7 | Here is the list of the properties of the context object: 8 | 9 | - `req`: The request object from the client. 10 | - `res`: The response object that you can use to send the response to the client. 11 | - `params`: The parameters of the route. 12 | - `query`: The query parameters of the route (all parameters after the ? in the url). 13 | - `getIpAddress`: A function that returns the ip address of the client. 14 | - `headers`: The headers of the request. 15 | - `body`: The body of the request. 16 | - `state`: The state of your position in the life cycle (beforeHandler or afterHandler). 17 | - `requestStartTimeInMs`: If you use the `logger` hook, this property will be set to the time in milliseconds when the request has been received. It's allow you to calculate the time spent in the handler. 18 | 19 | ## The response object 20 | 21 | The response object is used to send the response to the client. It has the following methods: 22 | 23 | - `sendText`: Send a text response. 24 | - `sendJson`: Send a JSON response. 25 | - `send`: Send a response. You can send a text, a JSON object. 26 | - `setCookie`: Set a cookie in the response. 27 | - `getCookie`: Get a cookie from the request. 28 | - `deleteCookie`: Delete a cookie from the response. 29 | 30 | You can also directly access to some properties of the response object: 31 | 32 | - `status`: Set or get the status code of the response. 33 | - `statusText`: Set or get the status text of the response. 34 | - `header`: Set or get a header of the response. 35 | 36 | ## Methods of the context object 37 | 38 | Context object contains multiple methods to allow you to interact with the request and the response. Here is the list of the methods: 39 | 40 | - `text`: Get the text of the body of the request. 41 | - `json`: Get the JSON object of the body of the request. 42 | - `redirect`: Redirect the client to another url. 43 | 44 | ## Examples 45 | 46 | Here is an example of a route handler that sends a text response: 47 | 48 | ```ts 49 | import { Wobe } from 'wobe' 50 | 51 | const app = new Wobe() 52 | .get('/hello', (context) => context.res.sendText('Hello world')) 53 | .listen(3000) 54 | ``` 55 | 56 | Here is an example of a route handler that set a cookie, change the status of the response and send a JSON response: 57 | 58 | ```ts 59 | import { Wobe } from 'wobe' 60 | 61 | const app = new Wobe() 62 | .get('/hello', (context) => { 63 | context.res.setCookie('myCookie', 'myValue', { httpOnly: true }) 64 | context.res.status = 201 65 | context.res.sendJson({ message: 'Hello world' }) 66 | }) 67 | .listen(3000) 68 | ``` 69 | 70 | Here is an example of a route handler that redirects the client to another url: 71 | 72 | ```ts 73 | import { Wobe } from 'wobe' 74 | 75 | const app = new Wobe() 76 | .get('/hello', (context) => { 77 | context.redirect('http://example.com') 78 | // You can also set the status of response by default it's 302 79 | context.redirect('http://example.com', 301) 80 | }) 81 | .listen(3000) 82 | ``` 83 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/concepts/route.md: -------------------------------------------------------------------------------- 1 | # Routes 2 | 3 | Wobe route system follows the standards so on this is very simple to use. 4 | 5 | ## Simplest example 6 | 7 | ```ts 8 | import { Wobe } from 'wobe' 9 | 10 | const app = new Wobe() 11 | // GET HTTP method 12 | .get('/hello', (context) => context.res.sendText('Hello GET world')) 13 | // POST HTTP method 14 | .post('/post/hello', (context) => context.res.sendText('Hello POST world')) 15 | // PUT HTTP method 16 | .put('/put/hello', (context) => context.res.sendText('Hello PUT world')) 17 | // DELETE HTTP method 18 | .delete('/delete/hello', (context) => 19 | context.res.sendText('Hello DELETE world'), 20 | ) 21 | .listen(3000) 22 | ``` 23 | 24 | ## Send response 25 | 26 | - You can send a text response using the `sendText` function. 27 | 28 | ```ts 29 | import { Wobe } from 'wobe' 30 | 31 | const app = new Wobe() 32 | .get('/hello', (context) => context.res.sendText('Hello world')) 33 | .listen(3000) 34 | ``` 35 | 36 | - If you want to send a JSON response you can use the `sendJson` function. 37 | 38 | ```ts 39 | import { Wobe } from 'wobe' 40 | 41 | const app = new Wobe() 42 | .get('/hello', (context) => 43 | context.res.sendJson({ message: 'Hello world' }), 44 | ) 45 | .listen(3000) 46 | ``` 47 | 48 | - If you don't know at the advance what type is your response you can simply use the `send` function. 49 | 50 | ```ts 51 | const app = new Wobe() 52 | .get('/hello', (context) => context.res.send('Hello world')) 53 | .get('/hello2', (context) => context.res.send({ message: 'Hello world' })) 54 | .listen(3000) 55 | ``` 56 | 57 | ## Route with parameters 58 | 59 | You could also have some routes with parameters that can be easily accessible through the `context.params` object. 60 | 61 | ```ts 62 | const app = new Wobe() 63 | .get('/hello/:name', (context) => 64 | context.sendText(`Hello ${context.params.name}`), 65 | ) 66 | .listen(3000) 67 | ``` 68 | 69 | ## Prelight requests 70 | 71 | You can enable prelight requests for cors like this : 72 | 73 | ```ts 74 | const app = new Wobe().options( 75 | '*', 76 | (ctx) => ctx.res.send(null), 77 | cors({ 78 | origin: 'http://localhost:3000', 79 | allowHeaders: ['content-type'], 80 | credentials: true, 81 | }), 82 | ) 83 | ``` 84 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/concepts/websocket.md: -------------------------------------------------------------------------------- 1 | # WebSocket 2 | 3 | WebSocket is a real-time protocol to communicate between a client and a server. 4 | 5 | Wobe proposes a simple way to create a WebSocket server. 6 | 7 | Note: Wobe WebSocket is only available on Bun for the moment. 8 | 9 | ## Create a simple WebSocket server 10 | 11 | You can easily add a WebSocket endpoint to your Wobe server by using the `useWebSocket` function. 12 | 13 | ```ts 14 | import { Wobe } from 'wobe' 15 | 16 | const wobe = new Wobe() 17 | 18 | wobe.useWebSocket({ 19 | path: '/ws', 20 | onOpen(ws) { 21 | ws.send('Hello new connection') 22 | }, 23 | onMessage: (ws, message) => { 24 | ws.send(`You said: ${message}`) 25 | }, 26 | onClose(ws) { 27 | ws.send('Goodbye') 28 | }, 29 | }) 30 | 31 | wobe.listen(3000) 32 | ``` 33 | 34 | In this example, we create a WebSocket server on the /ws path. When a client connects on this path, we send a message to the client. When the client sends a message, we send back the message. When the client disconnects, we send a goodbye message. 35 | 36 | ## WebSocket options 37 | 38 | The `useWebSocket` function takes an object with the following properties: 39 | 40 | - `path`: The path of the WebSocket endpoint. 41 | - `onOpen`: A function called when a new client connects. It takes the WebSocket object as argument. 42 | - `onMessage`: A function called when a client sends a message. It takes the WebSocket object and the message as arguments. 43 | - `onClose`: A function called when a client disconnects. It takes the WebSocket object, the close code and the close message as arguments. 44 | - `compression`: A boolean to enable or disable the compression. Default is 45 | `false`. 46 | - `backpressureLimit`: The maximum number of bytes that can be buffered before the server stops reading from the socket. Default is `1024 * 1024 = 1MB`. 47 | - `idleTimeout`: The maximum number of seconds that a connection can be idle before being closed. Default is `120 seconds`. 48 | - `closeOnBackpressureLimit`: A boolean to close the connection when the backpressure limit is reached. Default is `false`. 49 | - `maxPayloadLength`: The maximum length of the payload that the server will accept. Default is `16 * 1024 * 1024 = 16MB`. 50 | - `beforeWebSocketUpgrade`: An array of hook (same type as a Wobe hook) to execute before the WebSocket upgrade. Default is `[]`. 51 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/concepts/wobe.md: -------------------------------------------------------------------------------- 1 | # Wobe 2 | 3 | To create a web server you will need to instantiate a `Wobe` object. 4 | 5 | ```ts 6 | import { Wobe } from 'wobe' 7 | 8 | const wobe = new Wobe() 9 | 10 | wobe.listen(3000) 11 | ``` 12 | 13 | ## Options 14 | 15 | The `Wobe` constructor can have some options: 16 | 17 | - `hostname`: The hostname where the server will be listening. 18 | - `onError`: A function that will be called when an error occurs. 19 | - `onNotFound`: A function that will be called when a route is not found. 20 | - `tls`: An object with the key, the cert and the passphrase if exist to enable HTTPS. 21 | 22 | ```ts 23 | import { Wobe } from 'wobe' 24 | 25 | const wobe = new Wobe({ 26 | hostname: 'hostname', 27 | onError: (error) => console.error(error), 28 | onNotFound: (request) => console.error(`${request.url} not found`), 29 | tls: { 30 | key: 'keyContent', 31 | cert: 'certContent', 32 | passphrase: 'Your passphrase if exists', 33 | }, 34 | }) 35 | 36 | wobe.listen(3000, ({ hostname, port }) => { 37 | console.log(`Server running at https://${hostname}:${port}`) 38 | }) 39 | ``` 40 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/ecosystem/hooks/bearer-auth.md: -------------------------------------------------------------------------------- 1 | # Bearer auth 2 | 3 | Wobe has a `beforeHandler` hook to manage simple Bearer authentication. 4 | 5 | ## Example 6 | 7 | A simple example without hash function. 8 | 9 | ```ts 10 | import { Wobe, bearerAuth } from 'wobe' 11 | 12 | const app = new Wobe() 13 | .beforeHandler( 14 | bearerAuth({ 15 | token: 'token', 16 | }), 17 | ) 18 | .get('/protected', (req, res) => { 19 | res.send('Protected') 20 | }) 21 | .listen(3000) 22 | 23 | // A request like this will be accepted 24 | const request = new Request('http://localhost:3000/test', { 25 | headers: { 26 | Authorization: 'Bearer 123', 27 | }, 28 | }) 29 | ``` 30 | 31 | You can also add an hash function to compare the token with a hashed version. 32 | 33 | ```ts 34 | import { Wobe, bearerAuth } from 'wobe' 35 | 36 | const app = new Wobe() 37 | .beforeHandler( 38 | bearerAuth({ 39 | token: 'token', 40 | hashFunction: (token) => token, 41 | }), 42 | ) 43 | .get('/protected', (req, res) => { 44 | res.send('Protected') 45 | }) 46 | .listen(3000) 47 | 48 | // A request like this will be accepted 49 | const request = new Request('http://localhost:3000/test', { 50 | headers: { 51 | Authorization: 'Bearer SomeHashedToken', 52 | }, 53 | }) 54 | ``` 55 | 56 | ## Options 57 | 58 | - `token` (string) : The token to compare with the Authorization header. 59 | - `realm` (string) : The realm to send in the WWW-Authenticate header. 60 | - `hashFunction` ((token: string) => string) : A function to hash the token before comparing it. 61 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/ecosystem/hooks/body-limit.md: -------------------------------------------------------------------------------- 1 | # Body limit 2 | 3 | Wobe has a `beforeHandler` hook to put a limit to the body size of each requests. 4 | 5 | ## Example 6 | 7 | ```ts 8 | import { Wobe, bodyLimit } from 'wobe' 9 | 10 | const app = new Wobe() 11 | 12 | // 1000 bytes 13 | app.beforeHandler(bodyLimit(1000)) 14 | app.post('/test', (req, res) => { 15 | res.send('Hello World') 16 | }) 17 | app.listen(3000) 18 | ``` 19 | 20 | In this example, the body limit is set to 1000 bytes. If the body size is bigger than 1000 bytes, the server will respond with a `413 Payload Too Large` status code. 21 | 22 | ## Options 23 | 24 | - `maxSize` (number) : The maximum size of the body in bytes. 25 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/ecosystem/hooks/cors.md: -------------------------------------------------------------------------------- 1 | # Cors hook 2 | 3 | Wobe has a `beforeHandker` hook to manage CORS. 4 | 5 | ## Example 6 | 7 | You can only authorize some requests with the `origin` option 8 | 9 | First of all you will need to enable prelight requests for cors like this : 10 | 11 | ```ts 12 | const app = new Wobe().options( 13 | '*', 14 | (ctx) => ctx.res.send(null), 15 | cors({ 16 | origin: 'http://localhost:3000', 17 | allowHeaders: ['content-type'], 18 | credentials: true, 19 | }), 20 | ) 21 | ``` 22 | 23 | than: 24 | 25 | ```ts 26 | import { Wobe, cors } from 'wobe' 27 | 28 | const app = new Wobe() 29 | .beforeHandler(cors({ origin: 'http://localhost:3000' })) 30 | .get('/hello', (context) => context.res.sendText('Hello world')) 31 | .listen(3000) 32 | ``` 33 | 34 | With multiple origins. 35 | 36 | ```ts 37 | import { Wobe, cors } from 'wobe' 38 | 39 | const app = new Wobe() 40 | .beforeHandler( 41 | cors({ origin: ['http://localhost:3000', 'http://localhost:3001'] }), 42 | ) 43 | .get('/hello', (context) => context.res.sendText('Hello world')) 44 | .listen(3000) 45 | ``` 46 | 47 | Or with a function. 48 | 49 | ```ts 50 | import { Wobe, cors } from 'wobe' 51 | 52 | const app = new Wobe() 53 | .beforeHandler( 54 | cors({ origin: (origin) => origin === 'http://localhost:3000' }), 55 | ) 56 | .get('/hello', (context) => context.res.sendText('Hello world')) 57 | .listen(3000) 58 | ``` 59 | 60 | ## Options 61 | 62 | - `origin` (string | string[] | ((origin: string) => string | undefined | null)) : The origin(s) that are allowed to make requests. 63 | - `allowMethods` (string[]): The HTTP methods that are allowed to make requests. 64 | - `allowHeaders` (string[]): The HTTP headers that are allowed to make requests. 65 | - `maxAge` (number): The maximum amount of time that a preflight request can be cached. 66 | - `credentials` (boolean): Indicates whether or not the response to the request can be exposed when the credentials flag is true. 67 | - `exposeHeaders` (string[]): The headers that are allowed to be exposed to the web page. 68 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/ecosystem/hooks/csrf.md: -------------------------------------------------------------------------------- 1 | # CSRF Hook 2 | 3 | Wobe has a `beforeHandler` hook to manage CSRF. 4 | 5 | ## Example 6 | 7 | In this example all the requests without the origin equal to `http://localhost:3000` will be blocked. 8 | 9 | ```ts 10 | import { Wobe, csrf } from 'wobe' 11 | 12 | const app = new Wobe() 13 | .beforeHandler(csrf({ origin: 'http://localhost:3000' })) 14 | .get('/hello', (context) => context.res.sendText('Hello world')) 15 | .listen(3000) 16 | ``` 17 | 18 | You can also have multiple origins. 19 | 20 | ```ts 21 | import { Wobe, csrf } from 'wobe' 22 | 23 | const app = new Wobe() 24 | .beforeHandler( 25 | csrf({ origin: ['http://localhost:3000', 'http://localhost:3001'] }), 26 | ) 27 | .get('/hello', (context) => context.res.sendText('Hello world')) 28 | .listen(3000) 29 | ``` 30 | 31 | Or with a function. 32 | 33 | ```ts 34 | import { Wobe, csrf } from 'wobe' 35 | 36 | const app = new Wobe() 37 | .beforeHandler( 38 | csrf({ origin: (origin) => origin === 'http://localhost:3000' }), 39 | ) 40 | .get('/hello', (context) => context.res.sendText('Hello world')) 41 | .listen(3000) 42 | ``` 43 | 44 | ## Options 45 | 46 | - `origin` (string | string[] | ((origin: string) => string | undefined | null)) : The origin(s) that are allowed to make requests. 47 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/ecosystem/hooks/index.md: -------------------------------------------------------------------------------- 1 | # Hook 2 | 3 | Wobe has a hook system that allows you to execute some code before or after a route handler (or both). This is useful when you want to execute some code for every request, like logging, rate limiting, bearer authentication, secured headers, CSRF protection, etc. This concept is similar to the middleware concept in other frameworks, with the difference that a middleware is only executed before the route handler, while a hook can be executed before or after the route handler. 4 | 5 | ## Simplest example 6 | 7 | In this example, the logger hook will be call before each requests on the `/json` route. The message "After handler" will be displayed after each requests on any routes. The message "Before and after handler" will be displayed before and after each requests on any routes. 8 | 9 | ```ts 10 | import { Wobe, logger } from 'wobe' 11 | 12 | const app = new Wobe() 13 | .beforeHandler('/json', logger()) 14 | .afterHandler(() => console.log('After handler')) 15 | .beforeAndAfterHandler(() => console.log('Before and after handler')) 16 | .get('/hello', (context) => context.res.sendText('Hello world')) 17 | .listen(3000) 18 | ``` 19 | 20 | In this example, for a request on `/hello` the output will be: 21 | 22 | ``` 23 | 1 : Before and after handler 24 | 2 : GET /hello 25 | 3 : After handler 26 | 4 : Before and after handler 27 | ``` 28 | 29 | ## Stop the execution of the request in a beforeHandler hook 30 | 31 | An usual use case of a hook can be to check some authorizations or to validate a body request. If the validation fails, you can stop the execution of the request by throwing an HTTP error. 32 | 33 | ### Why throwing instead of just return a HTTP Response ? 34 | 35 | In wobe we choose to throw an error because throwing an error is usually use to stop the execution of the code. In this kind of case this is what we want to do. Specially in a web framework we don't stop the code with an error message but we send a specific HTTP Response to the client. This is why we throw an `HttpException` that will be catch by the framework and send the response to the client. 36 | 37 | ### Example 38 | 39 | In the example below we check if the user is an admin. If not, we throw an HTTP error with a status code 403. The client will receive the response that you pass into the constructor of the `HttpException`. 40 | 41 | ```ts 42 | export const authorizationHook = (schema: TSchema): WobeHandler => { 43 | return async (ctx: Context) => { 44 | const request = ctx.request 45 | 46 | // Some logic to get the user that execute the request 47 | const user = { 48 | name: 'John', 49 | age: 25, 50 | type: 'NotAnAdminUser', 51 | } 52 | 53 | if (user.type !== 'Admin') 54 | throw new HttpException( 55 | new Response('You are not authorized to access to this route', { 56 | status: 403, 57 | }), 58 | ) 59 | } 60 | } 61 | ``` 62 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/ecosystem/hooks/logger.md: -------------------------------------------------------------------------------- 1 | # Logger 2 | 3 | Wobe has a logger `beforeAndAfterHandler` that allows to log something before and after a function is called. For example, it can be useful to log the time taken by an handler to execute. 4 | 5 | ## Example 6 | 7 | By default the logger middleware will use `console.log`to log the message. 8 | 9 | ```ts 10 | import { Wobe, logger } from 'wobe' 11 | 12 | const app = new Wobe() 13 | 14 | app.beforeAndAfterHandler(logger()) 15 | app.get('/test', (req, res) => { 16 | res.send('Hello World') 17 | }) 18 | app.listen(3000) 19 | ``` 20 | 21 | You can also pass a custom function (see [Options sections](#options)) to the logger middleware. 22 | 23 | ```ts 24 | import { Wobe, logger } from 'wobe' 25 | 26 | const app = new Wobe() 27 | 28 | app.beforeAndAfterHandler( 29 | logger({loggerFunction : ({ beforeHandler, method, url, status, requestStartTimeInMs}) => { 30 | // Some log logic ... 31 | }) 32 | ) 33 | app.get('/test', (req, res) => { 34 | res.send('Hello World') 35 | }) 36 | app.listen(3000) 37 | ``` 38 | 39 | ## Options 40 | 41 | - `loggerFunction` (function) : the function that will be called to log the message. 42 | 43 | **Parameters of the function :** 44 | 45 | - `beforeHandler` (boolean) : true if the function is called before the handler, false otherwise. 46 | - `method` (string) : the HTTP method of the request. 47 | - `url` (string) : the URL of the request. 48 | - `status` (number ) : the status code of the response (only in afterHandler). 49 | - `requestStartTimeInMs` (number ) : the time in milliseconds when the request was received (only in afterHandler). 50 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/ecosystem/hooks/rate-limit.md: -------------------------------------------------------------------------------- 1 | # Rate limit 2 | 3 | Wobe has a rate limit `beforeHandler` hook that allows you to limit the number of requests on your server within a specified amount of time. 4 | 5 | ## Example 6 | 7 | In this example the server will limit the number of request to 2 every second. 8 | 9 | ```ts 10 | import { Wobe, rateLimit } from 'wobe' 11 | 12 | const app = new Wobe() 13 | 14 | app.beforeHandler(rateLimit({ numberOfRequests: 2, interval: 1000 })) 15 | app.get('/test', (req, res) => { 16 | res.send('Hello World') 17 | }) 18 | app.listen(3000) 19 | ``` 20 | 21 | ## Options 22 | 23 | - `interval` (number) : the time in milliseconds in which the number of requests is limited. 24 | - `numberOfRequests` (number) : the number of requests allowed in the interval. 25 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/ecosystem/hooks/secure-headers.md: -------------------------------------------------------------------------------- 1 | # Secure headers 2 | 3 | Wobe has a secure headers `beforeHandler` hook that allows you to set secure headers on your server. It can be considered as an equivalent of `helmet` for Express. 4 | 5 | ## Example 6 | 7 | ```ts 8 | import { Wobe, secureHeaders } from 'wobe' 9 | 10 | const app = new Wobe() 11 | 12 | app.beforeHandler( 13 | secureHeaders({ 14 | contentSecurityPolicy: { 15 | 'default-src': ["'self'"], 16 | 'report-to': 'endpoint-5', 17 | }, 18 | }), 19 | ) 20 | 21 | app.get('/', (req, res) => { 22 | res.send('Hello World!') 23 | }) 24 | 25 | app.listen(3000) 26 | ``` 27 | 28 | ## Options 29 | 30 | - `contentSecurityPolicy` : An object that contains the content security policy directives. [For more informations](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) 31 | - `crossOriginEmbedderPolicy` (string) : The Cross-Origin-Embedder-Policy header value. [For more informations](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Embedder-Policy) 32 | - `crossOriginOpenerPolicy` (string) : The Cross-Origin-Opener-Policy header value. [For more informations](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Opener-Policy) 33 | - `crossOriginResourcePolicy` (string) : The Cross-Origin-Resource-Policy header value. [For more informations](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cross-Origin_Resource_Policy) 34 | - `referrerPolicy` (string) : The Referrer-Policy header value. [For more informations](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy) 35 | - `strictTransportSecurity` (string[]) : The Strict-Transport-Security header value. [For more informations](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security) 36 | - `xContentTypeOptions` (string) : The X-Content-Type-Options header value. [For more informations](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options) 37 | - `xDownloadOptions` (string) : The X-Download-Options header value. 38 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/ecosystem/hooks/upload-directory.md: -------------------------------------------------------------------------------- 1 | # Upload Directory 2 | 3 | Wobe provides an `uploadDirectory` hook to easily serve files from a specified directory. This hook allows you to access files by providing a filename parameter in the route. 4 | 5 | ## Example 6 | 7 | A simple example to serve files from a directory. 8 | 9 | ```ts 10 | import { Wobe, uploadDirectory } from 'wobe'; 11 | 12 | const app = new Wobe() 13 | .get('/bucket/:filename', uploadDirectory({ 14 | directory: './bucket', 15 | })) 16 | .listen(3000); 17 | 18 | // A request like this will serve the file `example.jpg` from the `./bucket` directory 19 | const request = new Request('http://localhost:3000/bucket/example.jpg'); 20 | ``` 21 | 22 | ## Options 23 | 24 | - `directory` (string) : The directory path from which to serve files. This path should be relative to your project's root directory or an absolute path. 25 | - `isAuthorized` (boolean) : A boolean value indicating whether the hook should check if the request is authorized. If set to `true`, the hook will be authorized to serve files, otherwise, it will be unauthorized. The default value is `true`. Usefull for example to allow access files only in development mode (with for example S3 storage on production). 26 | 27 | ## Usage 28 | 29 | To use the uploadDirectory hook, define a route in your Wobe application and pass the directory path as an option. The hook will handle requests to this route by serving the specified file from the directory. 30 | 31 | ```ts 32 | import { Wobe, uploadDirectory } from 'wobe'; 33 | 34 | const app = new Wobe() 35 | .get('/bucket/:filename', uploadDirectory({ 36 | directory: './path/to/your/directory', 37 | })) 38 | .listen(3000); 39 | ``` 40 | 41 | ## Error Handling 42 | 43 | The `uploadDirectory` hook handles errors gracefully by providing appropriate HTTP responses for common issues: 44 | 45 | - **Missing Filename Parameter**: If the `filename` parameter is missing in the request, the hook will respond with a `400 Bad Request` status and the message "Filename is required". 46 | 47 | ```ts 48 | const response = await fetch('http://localhost:3000/bucket/'); 49 | console.log(response.status); // 400 50 | console.log(await response.text()); // "Filename is required" 51 | ``` 52 | 53 | - **File Not Found**: If the file specified by the `filename` parameter does not exist in the directory, the hook will respond with a `404 Not Found` status and the message "File not found". 54 | 55 | ```ts 56 | const response = await fetch('http://localhost:3000/bucket/non-existent-file.txt'); 57 | console.log(response.status); // 404 58 | console.log(await response.text()); // "File not found" 59 | ``` 60 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/ecosystem/hooks/validator.md: -------------------------------------------------------------------------------- 1 | # Wobe validator 2 | 3 | Wobe has a validator `beforeHandler` hook that allows you to validate the request body. It can be considered as an equivalent of `express-validator` for Express. 4 | 5 | Because validator's hook uses `typebox` in background, it is in a separate package to avoid unnecessary dependencies on the main package if you don't want to use this hook. 6 | 7 | ## Install 8 | 9 | ```sh 10 | bun install wobe-validator # On bun 11 | npm install wobe-validator # On npm 12 | yarn add wobe-validator # On yarn 13 | ``` 14 | 15 | ## Basic example 16 | 17 | In this example, we will check if the body of the request is an object with a `name` field that is a string. If the validation fails, the request will be rejected with a `400` status code. 18 | 19 | ```ts 20 | import { Wobe } from 'wobe' 21 | import { wobeValidator } from 'wobe-validator' 22 | import { Type as T } from '@sinclair/typebox' 23 | 24 | const wobe = new Wobe() 25 | 26 | const schema = T.Object({ 27 | name: T.String(), 28 | }) 29 | 30 | wobe.post( 31 | '/test', 32 | (ctx) => { 33 | return ctx.res.send('ok') 34 | }, 35 | wobeValidator(schema), 36 | ) 37 | 38 | wobe.listen(3000) 39 | ``` 40 | 41 | In the above example the following request will be **accepted**: 42 | 43 | ```ts 44 | // Success 45 | await fetch(`http://127.0.0.1:3000/test`, { 46 | method: 'POST', 47 | body: JSON.stringify({ name: 'testName' }), 48 | headers: { 49 | 'Content-Type': 'application/json', 50 | }, 51 | }) 52 | ``` 53 | 54 | The following request will be **rejected**: 55 | 56 | ```ts 57 | // Failed 58 | await fetch(`http://127.0.0.1:3000/test`, { 59 | method: 'POST', 60 | body: JSON.stringify({ name: 42 }), 61 | headers: { 62 | 'Content-Type': 'application/json', 63 | }, 64 | }) 65 | ``` 66 | 67 | ## Options 68 | 69 | The `wobeValidator` function accepts a schema in input that is the `typebox` schema. See [here](https://github.com/sinclairzx81/typebox) for more informations. 70 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/ecosystem/plugins/graphql-apollo-server.md: -------------------------------------------------------------------------------- 1 | # GraphQL Apollo Server 2 | 3 | A plugin to add a GraohQL Apollo server to your wobe app. 4 | 5 | You can simply use the plugin like this: 6 | 7 | ```ts 8 | import { Wobe } from 'wobe' 9 | import { WobeGraphqlApolloPlugin } from 'wobe-graphql-apollo' 10 | 11 | const wobe = new Wobe().usePlugin( 12 | await WobeGraphqlApolloPlugin({ 13 | options: { 14 | typeDefs: ` 15 | type Query { 16 | hello: String 17 | } 18 | `, 19 | resolvers: { 20 | Query: { 21 | hello: () => 'Hello from Apollo!', 22 | }, 23 | }, 24 | }, 25 | graphqlMiddleware: async (resolve, res) => { 26 | // Execute some code before graphql resolver 27 | 28 | const response = await resolve() 29 | 30 | // Execute some code after graphql resolver 31 | 32 | return response 33 | }, 34 | context: ({ request, response }) => { 35 | const accessToken = request.headers.get('Access-Token') 36 | 37 | response.setCookie('cookieName', 'cookieValue') 38 | 39 | return { accessToken } 40 | }, 41 | }), 42 | ) 43 | 44 | wobe.listen(port) 45 | ``` 46 | 47 | With GraphQL Apollo Server plugin, you have access to all apollo server options. You can refer to the [graphql-apollo-server documentation](https://www.apollographql.com/docs/apollo-server/) for more informations. 48 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/ecosystem/plugins/graphql-yoga.md: -------------------------------------------------------------------------------- 1 | # GraphQL Yoga 2 | 3 | A plugin to add a Yoga GraphQL server to your wobe app. 4 | 5 | You can simply use the plugin like this: 6 | 7 | ```ts 8 | import { Wobe } from 'wobe' 9 | import { WobeGraphqlYogaPlugin } from 'wobe-graphql-yoga' 10 | 11 | const wobe = new Wobe().usePlugin( 12 | WobeGraphqlYogaPlugin({ 13 | typeDefs: ` 14 | type Query { 15 | hello: String 16 | } 17 | `, 18 | resolvers: { 19 | Query: { 20 | hello: () => 'Hello from Yoga!', 21 | }, 22 | }, 23 | maskedErrors: false, // You can mask the errors to have generic errors in production 24 | graphqlMiddleware: async (resolve, res) => { 25 | // Execute some code before graphql resolver 26 | 27 | const response = await resolve() 28 | 29 | // Execute some code after graphql resolver 30 | 31 | return response 32 | }, 33 | context: ({ request, response }) => { 34 | const accessToken = request.headers.get('Access-Token') 35 | 36 | response.setCookie('cookieName', 'cookieValue') 37 | 38 | return { accessToken } 39 | }, 40 | }), 41 | ) 42 | 43 | wobe.listen(3000) 44 | ``` 45 | 46 | With GraphQL Yoga plugin, you have access to all yoga options. You can refer to the [graphql-yoga documentation](https://the-guild.dev/graphql/yoga-server/docs) for more informations. 47 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/ecosystem/plugins/index.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | An essential aspect of Wobe is its requirement for zero dependencies. This feature offers numerous advantages such as reduced startup time, smaller final bundle size, and enhanced performance. However, the absence of external dependencies limits its ability to address all use cases. To address this, Wobe integrates a plugin system. These plugins, additional packages available for download from the npm registry, extend Wobe's functionality. The overarching vision for Wobe is that its plugin ecosystem will expand over time, accommodating a wide array of plugins to cover diverse use cases. 4 | 5 | ## How to create and use a plugin 6 | 7 | A plugin is essentially a function that returns another function, which in turn accepts the Wobe object as a parameter. This inner function serves to expand the capabilities of the Wobe object by adding new methods or properties. 8 | 9 | ```ts 10 | import { Wobe } from 'wobe' 11 | 12 | const myPlugin = () => { 13 | return (wobe: Wobe) => { 14 | wobe.get('/test', (context) => context.res.sendText('Hello World')) 15 | } 16 | } 17 | 18 | const wobe = new Wobe().usePlugin(myPlugin()).listen(3000) 19 | ``` 20 | 21 | To utilize a plugin that returns a promise, you can directly `await` it within the `usePlugin` method. Such a plugin is handy in scenarios like initiating a server, such as with the graphql-apollo plugin. 22 | 23 | ```ts 24 | import { Wobe } from 'wobe' 25 | 26 | const myAsyncPlugin = async () => { 27 | return (wobe: Wobe) => { 28 | wobe.get('/test', (context) => context.res.sendText('Hello World')) 29 | } 30 | } 31 | 32 | const wobe = new Wobe().usePlugin(await myAsyncPlugin()).listen(3000) 33 | ``` 34 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/wobe/benchmark.md: -------------------------------------------------------------------------------- 1 | # Benchmarks 2 | 3 | Although performance is not the main focus of Wobe, it is still an important factor when choosing a web framework. So on, we have done some benchmarks to compare Wobe with other popular web frameworks. 4 | 5 | ## HTTP Server benchmark (on Bun runtime) 6 | 7 | Wobe is one of the fastest web framework based on the [benchmark](https://github.com/SaltyAom/bun-http-framework-benchmark) of SaltyAom. Indeed, this benchmark is complete and already contains a lot of others web frameworks. 8 | 9 | | Framework | Runtime | Average | Ping | Query | Body | 10 | | --------- | ------- | ---------- | ---------- | --------- | --------- | 11 | | bun | bun | 92,639.313 | 103,439.17 | 91,646.07 | 82,832.7 | 12 | | elysia | bun | 92,445.227 | 103,170.47 | 88,716.17 | 85,449.04 | 13 | | wobe | bun | 90,535.37 | 96,348.26 | 94,625.67 | 80,632.18 | 14 | | hono | bun | 81,832.787 | 89,200.82 | 81,096.3 | 75,201.24 | 15 | | fastify | bun | 49,648.977 | 62,511.85 | 58,904.51 | 27,530.57 | 16 | | express | bun | 31,370.06 | 39,775.79 | 36,605.68 | 17,728.71 | 17 | 18 | _Executed with 5 runs - 12/04/2024_ 19 | 20 | ## Startup benchmark 21 | 22 | Wobe is faster around 58% than Elysia to start. For more informations on this benchmark see [here](https://github.com/palixir/wobe/blob/main/packages/wobe-benchmark/startup/benchmark.ts). 23 | 24 | | Framework | Runtime | Time in (µs) | 25 | | --------- | ------- | ------------ | 26 | | Elysia | bun | 3,109 | 27 | | Wobe | bun | 1,820 | 28 | 29 | _Executed with 5 runs - 04/05/2024_ 30 | -------------------------------------------------------------------------------- /packages/wobe-documentation/doc/wobe/motivations.md: -------------------------------------------------------------------------------- 1 | # Motivations 2 | 3 | ## Yet another web framework ? 4 | 5 | You must be wondering why yet another web framework, as there are many already in existence, and it seems like just a race for performance. And you're right to ask that question; I must admit to feeling the same way. Wobe is not built on the philosophy of a performance race. While performance is indeed important, it's not more important than developer experience or the simplicity of maintaining the library itself. These aspects have too often been overlooked, leading to libraries that are highly performant but riddled with bugs or non-standard paradigms. 6 | 7 | ## Wobe's philosophy 8 | 9 | The idea behind creating Wobe arises from several factors. In my experience working with various TypeScript-based web frameworks such as Express, Fastify, Hono, and more recently, Elysia, I've noticed certain trends. On one hand, there are frameworks with extensive features but often outdated or complex to use, that could lack in performance. On the other hand, there are newer frameworks focused on speed but sometimes sacrificing simplicity or ecosystem. While performance is crucial, real-world scenarios often reveal that http benchmarks don't indicate the real performance. Because in the facts, the handler and the business logic take always more time to execute than find the route and call all the hooks. So on, the simplicity and a good ecosystem is for me the best indicator for a robust web framework. 10 | 11 | Wobe aims to strike a balance by providing a comprehensive toolkit, featuring a diverse array of hooks and common plugins tailored to address a variety of use cases. Wobe take all the advantages of any other frameworks without the inconvenient. It is designed to be **simple** to use, **lightweight**, **fast**, with a focus on developer productivity. 12 | -------------------------------------------------------------------------------- /packages/wobe-documentation/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: home 3 | 4 | hero: 5 | name: 'Wobe' 6 | text: 'A fast, lightweight and simple web framework' 7 | tagline: A solid library kicks off with killer documentation. Let's dive in together ! 8 | image: 9 | src: /logo.png 10 | alt: VitePress 11 | actions: 12 | - theme: brand 13 | text: Documentation 14 | link: /doc/wobe/motivations 15 | - theme: alt 16 | text: GitHub 17 | link: https://github.com/coratgerl/wobe 18 | 19 | features: 20 | - icon: '🧩' 21 | title: Simple & Easy to use 22 | details: Wobe respects the standard and provides a large ecosystem. 23 | - icon: '🚀' 24 | title: Fast & Lightweight 25 | details: Wobe is one of the fastest web framework on Bun, and it has 0 dependencies (only 23.9 kB). 26 | - icon: '🔧' 27 | title: Multi-runtime 28 | details: Wobe supports Node.js and Bun runtime. 29 | - icon: '🔌' 30 | title: Easy to extend 31 | details: Wobe has an easy-to-use plugin system that allows extending for all your personal use cases. 32 | --- 33 | -------------------------------------------------------------------------------- /packages/wobe-documentation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wobe-documentation", 3 | "module": "index.ts", 4 | "type": "module", 5 | "devDependencies": { 6 | "vitepress": "1.6.3" 7 | }, 8 | "scripts": { 9 | "release": "bun run build && vercel .vitepress/dist --prod", 10 | "dev": "vitepress dev", 11 | "build": "vitepress build", 12 | "preview": "vitepress preview" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/wobe-documentation/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palixir/wobe/827a1812c417cb3be1bb63d3dd931e0d9679fcde/packages/wobe-documentation/public/favicon.ico -------------------------------------------------------------------------------- /packages/wobe-documentation/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palixir/wobe/827a1812c417cb3be1bb63d3dd931e0d9679fcde/packages/wobe-documentation/public/logo.png -------------------------------------------------------------------------------- /packages/wobe-documentation/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/wobe-graphql-apollo/README.md: -------------------------------------------------------------------------------- 1 |

2 | Logo 3 |

4 | 5 |

Wobe

6 | 7 |
8 | Documentation 9 |   •   10 | Discord 11 |
12 | 13 | ## What is Wobe apollo ? 14 | 15 | **Wobe apollo** is a plugin for the **wobe** web framework that allows you to easily use the apollo server. 16 | 17 | ## Install 18 | 19 | ```sh 20 | bun install wobe-graphql-apollo # On bun 21 | npm install wobe-graphql-apollo # On npm 22 | yarn add wobe-graphql-apollo # On yarn 23 | ``` 24 | 25 | ## Basic example 26 | 27 | ```ts 28 | import { Wobe } from 'wobe' 29 | import { WobeGraphqlApolloPlugin } from 'wobe-graphql-apollo' 30 | 31 | const wobe = new Wobe().usePlugin( 32 | await WobeGraphqlApolloPlugin({ 33 | options: { 34 | typeDefs: ` 35 | type Query { 36 | hello: String 37 | } 38 | `, 39 | resolvers: { 40 | Query: { 41 | hello: () => 'Hello from Apollo!', 42 | }, 43 | }, 44 | }, 45 | }), 46 | ) 47 | 48 | wobe.listen(3000) 49 | ``` 50 | -------------------------------------------------------------------------------- /packages/wobe-graphql-apollo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wobe-graphql-apollo", 3 | "version": "1.0.6", 4 | "description": "Apollo GraphQL server for Wobe (official)", 5 | "homepage": "https://wobe.dev", 6 | "author": { 7 | "name": "coratgerl", 8 | "url": "https://github.com/coratgerl" 9 | }, 10 | "keywords": ["graphql-apollo", "wobe"], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/palixir/wobe" 14 | }, 15 | "license": "MIT", 16 | "main": "dist/index.js", 17 | "devDependencies": { 18 | "get-port": "7.0.0", 19 | "wobe": "*" 20 | }, 21 | "dependencies": { 22 | "@apollo/server": "4.11.3" 23 | }, 24 | "scripts": { 25 | "build": "bun build --minify --outdir dist $(pwd)/src/index.ts --target=bun --external=* && bun generate:types", 26 | "generate:types": "bun tsc --project .", 27 | "format": "biome format --write .", 28 | "lint": "biome lint . --no-errors-on-unmatched --config-path=../../", 29 | "ci": "bun lint $(pwd) && bun test src" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/wobe-graphql-apollo/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'bun:test' 2 | import { Wobe } from 'wobe' 3 | import getPort from 'get-port' 4 | import { WobeGraphqlApolloPlugin } from '.' 5 | 6 | describe('Wobe GraphQL Apollo plugin', () => { 7 | it('should have custom wobe context in graphql context', async () => { 8 | const port = await getPort() 9 | 10 | const wobe = new Wobe<{ customType: string }>().beforeHandler((ctx) => { 11 | ctx.customType = 'test' 12 | }) 13 | 14 | wobe.usePlugin( 15 | await WobeGraphqlApolloPlugin({ 16 | options: { 17 | typeDefs: `#graphql 18 | type Query { 19 | hello: String 20 | } 21 | `, 22 | resolvers: { 23 | Query: { 24 | hello: (_, __, context) => { 25 | context.res.setCookie('before', 'before') 26 | 27 | expect(context.res).toBeDefined() 28 | expect(context.request).toBeDefined() 29 | expect(context.customType).toEqual('test') 30 | return 'Hello from Apollo!' 31 | }, 32 | }, 33 | }, 34 | }, 35 | context: async () => { 36 | return { tata: 'test' } 37 | }, 38 | }), 39 | ) 40 | 41 | wobe.listen(port) 42 | 43 | const res = await fetch(`http://127.0.0.1:${port}/graphql`, { 44 | method: 'POST', 45 | headers: { 46 | 'Content-Type': 'application/json', 47 | }, 48 | body: JSON.stringify({ 49 | query: ` 50 | query { 51 | hello 52 | } 53 | `, 54 | }), 55 | }) 56 | 57 | expect(res.status).toBe(200) 58 | expect(res.headers.get('set-cookie')).toBe('before=before;') 59 | expect(await res.json()).toEqual({ 60 | data: { hello: 'Hello from Apollo!' }, 61 | }) 62 | 63 | wobe.stop() 64 | }) 65 | 66 | it('should have WobeResponse in graphql context', async () => { 67 | const port = await getPort() 68 | 69 | const wobe = new Wobe() 70 | 71 | wobe.usePlugin( 72 | await WobeGraphqlApolloPlugin({ 73 | options: { 74 | typeDefs: `#graphql 75 | type Query { 76 | hello: String 77 | } 78 | `, 79 | resolvers: { 80 | Query: { 81 | hello: (_, __, context) => { 82 | context.res.setCookie('before', 'before') 83 | 84 | expect(context.res).toBeDefined() 85 | expect(context.request).toBeDefined() 86 | return 'Hello from Apollo!' 87 | }, 88 | }, 89 | }, 90 | }, 91 | context: async () => { 92 | return { tata: 'test' } 93 | }, 94 | }), 95 | ) 96 | 97 | wobe.listen(port) 98 | 99 | const res = await fetch(`http://127.0.0.1:${port}/graphql`, { 100 | method: 'POST', 101 | headers: { 102 | 'Content-Type': 'application/json', 103 | }, 104 | body: JSON.stringify({ 105 | query: ` 106 | query { 107 | hello 108 | } 109 | `, 110 | }), 111 | }) 112 | 113 | expect(res.status).toBe(200) 114 | expect(res.headers.get('set-cookie')).toBe('before=before;') 115 | expect(await res.json()).toEqual({ 116 | data: { hello: 'Hello from Apollo!' }, 117 | }) 118 | 119 | wobe.stop() 120 | }) 121 | 122 | it("should use the graphql middleware if it's provided", async () => { 123 | const port = await getPort() 124 | 125 | const wobe = new Wobe() 126 | 127 | wobe.usePlugin( 128 | await WobeGraphqlApolloPlugin({ 129 | options: { 130 | typeDefs: `#graphql 131 | type Query { 132 | hello: String 133 | } 134 | `, 135 | resolvers: { 136 | Query: { 137 | hello: () => 'Hello from Apollo!', 138 | }, 139 | }, 140 | }, 141 | context: async () => { 142 | return { tata: 'test' } 143 | }, 144 | graphqlMiddleware: async (resolve, res) => { 145 | res.setCookie('before', 'before') 146 | 147 | const response = await resolve() 148 | 149 | res.setCookie('after', 'after') 150 | 151 | return response 152 | }, 153 | }), 154 | ) 155 | 156 | wobe.listen(port) 157 | 158 | const res = await fetch(`http://127.0.0.1:${port}/graphql`, { 159 | method: 'POST', 160 | headers: { 161 | 'Content-Type': 'application/json', 162 | }, 163 | body: JSON.stringify({ 164 | query: ` 165 | query { 166 | hello 167 | } 168 | `, 169 | }), 170 | }) 171 | 172 | expect(res.status).toBe(200) 173 | expect(res.headers.get('set-cookie')).toBe( 174 | 'before=before;, after=after;', 175 | ) 176 | expect(await res.json()).toEqual({ 177 | data: { hello: 'Hello from Apollo!' }, 178 | }) 179 | 180 | wobe.stop() 181 | }) 182 | 183 | it('should query graphql request with context in graphql resolver', async () => { 184 | const port = await getPort() 185 | 186 | const wobe = new Wobe() 187 | 188 | wobe.usePlugin( 189 | await WobeGraphqlApolloPlugin({ 190 | options: { 191 | typeDefs: `#graphql 192 | type Query { 193 | hello: String 194 | } 195 | `, 196 | resolvers: { 197 | Query: { 198 | hello: () => 'Hello from Apollo!', 199 | }, 200 | }, 201 | }, 202 | context: async ({ request }) => { 203 | expect(request.method).toBe('POST') 204 | 205 | return { tata: 'test' } 206 | }, 207 | }), 208 | ) 209 | 210 | wobe.listen(port) 211 | 212 | const res = await fetch(`http://127.0.0.1:${port}/graphql`, { 213 | method: 'POST', 214 | headers: { 215 | 'Content-Type': 'application/json', 216 | }, 217 | body: JSON.stringify({ 218 | query: ` 219 | query { 220 | hello 221 | } 222 | `, 223 | }), 224 | }) 225 | 226 | expect(res.status).toBe(200) 227 | expect(await res.json()).toEqual({ 228 | data: { hello: 'Hello from Apollo!' }, 229 | }) 230 | 231 | wobe.stop() 232 | }) 233 | 234 | it('should query graphql request', async () => { 235 | const port = await getPort() 236 | 237 | const wobe = new Wobe() 238 | 239 | wobe.usePlugin( 240 | await WobeGraphqlApolloPlugin({ 241 | options: { 242 | typeDefs: `#graphql 243 | type Query { 244 | hello: String 245 | } 246 | `, 247 | resolvers: { 248 | Query: { 249 | hello: () => 'Hello from Apollo!', 250 | }, 251 | }, 252 | }, 253 | }), 254 | ) 255 | 256 | wobe.listen(port) 257 | 258 | const res = await fetch(`http://127.0.0.1:${port}/graphql`, { 259 | method: 'POST', 260 | headers: { 261 | 'Content-Type': 'application/json', 262 | }, 263 | body: JSON.stringify({ 264 | query: ` 265 | query { 266 | hello 267 | } 268 | `, 269 | }), 270 | }) 271 | 272 | expect(res.status).toBe(200) 273 | expect(await res.json()).toEqual({ 274 | data: { hello: 'Hello from Apollo!' }, 275 | }) 276 | 277 | wobe.stop() 278 | }) 279 | 280 | it('should query graphql request on a custom graphql endpoint', async () => { 281 | const port = await getPort() 282 | 283 | const wobe = new Wobe() 284 | 285 | wobe.usePlugin( 286 | WobeGraphqlApolloPlugin({ 287 | graphqlEndpoint: '/graphql2', 288 | options: { 289 | typeDefs: `#graphql 290 | type Query { 291 | hello: String 292 | } 293 | `, 294 | resolvers: { 295 | Query: { 296 | hello: () => 'Hello from Apollo!', 297 | }, 298 | }, 299 | }, 300 | }), 301 | ) 302 | 303 | wobe.listen(port) 304 | 305 | const res = await fetch(`http://127.0.0.1:${port}/graphql2`, { 306 | method: 'POST', 307 | headers: { 308 | 'Content-Type': 'application/json', 309 | }, 310 | body: JSON.stringify({ 311 | query: ` 312 | query { 313 | hello 314 | } 315 | `, 316 | }), 317 | }) 318 | 319 | expect(res.status).toBe(200) 320 | expect(await res.json()).toEqual({ 321 | data: { hello: 'Hello from Apollo!' }, 322 | }) 323 | 324 | wobe.stop() 325 | }) 326 | }) 327 | -------------------------------------------------------------------------------- /packages/wobe-graphql-apollo/src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloServer, 3 | type ApolloServerOptions, 4 | type BaseContext, 5 | } from '@apollo/server' 6 | import { 7 | ApolloServerPluginLandingPageLocalDefault, 8 | ApolloServerPluginLandingPageProductionDefault, 9 | } from '@apollo/server/plugin/landingPage/default' 10 | import type { 11 | Wobe, 12 | MaybePromise, 13 | WobePlugin, 14 | WobeResponse, 15 | Context, 16 | } from 'wobe' 17 | 18 | const getQueryString = (url: string) => url.slice(url.indexOf('?', 11) + 1) 19 | 20 | export interface GraphQLApolloPluginOptions { 21 | graphqlMiddleware?: ( 22 | resolve: () => Promise, 23 | res: WobeResponse, 24 | ) => Promise 25 | } 26 | 27 | export const WobeGraphqlApolloPlugin = async ({ 28 | options, 29 | graphqlEndpoint = '/graphql', 30 | graphqlMiddleware, 31 | context: apolloContext, 32 | }: { 33 | options: ApolloServerOptions 34 | graphqlEndpoint?: string 35 | context?: (options: Context) => MaybePromise 36 | } & GraphQLApolloPluginOptions): Promise => { 37 | const server = new ApolloServer({ 38 | ...options, 39 | plugins: [ 40 | ...(options?.plugins || []), 41 | process.env.NODE_ENV === 'production' 42 | ? ApolloServerPluginLandingPageProductionDefault({ 43 | footer: false, 44 | }) 45 | : ApolloServerPluginLandingPageLocalDefault({ 46 | footer: false, 47 | }), 48 | ], 49 | }) 50 | 51 | await server.start() 52 | 53 | return (wobe: Wobe) => { 54 | const getResponse = async (context: Context) => { 55 | const fetchEndpoint = async (request: Request) => { 56 | const res = await server.executeHTTPGraphQLRequest({ 57 | httpGraphQLRequest: { 58 | method: request.method, 59 | body: 60 | request.method === 'GET' 61 | ? request.body 62 | : await request.json(), 63 | // @ts-expect-error 64 | headers: request.headers, 65 | search: getQueryString(request.url), 66 | }, 67 | context: async () => ({ 68 | ...context, 69 | ...(apolloContext ? await apolloContext(context) : {}), 70 | }), 71 | }) 72 | 73 | if (res.body.kind === 'complete') { 74 | const response = new Response(res.body.string, { 75 | status: res.status ?? 200, 76 | // @ts-expect-error 77 | headers: res.headers, 78 | }) 79 | 80 | return response 81 | } 82 | 83 | return new Response() 84 | } 85 | 86 | if (!graphqlMiddleware) return fetchEndpoint(context.request) 87 | 88 | return graphqlMiddleware(async () => { 89 | const response = await fetchEndpoint(context.request) 90 | 91 | return response 92 | }, context.res) 93 | } 94 | 95 | wobe.get(graphqlEndpoint, async (context) => { 96 | const response = await getResponse(context) 97 | 98 | for (const [key, value] of context.res.headers.entries()) { 99 | if (key === 'set-cookie') { 100 | response.headers.append('set-cookie', value) 101 | continue 102 | } 103 | 104 | response.headers.set(key, value) 105 | } 106 | 107 | return response 108 | }) 109 | 110 | wobe.post(graphqlEndpoint, async (context) => { 111 | const response = await getResponse(context) 112 | 113 | for (const [key, value] of context.res.headers.entries()) { 114 | if (key === 'set-cookie') { 115 | response.headers.append('set-cookie', value) 116 | continue 117 | } 118 | 119 | response.headers.set(key, value) 120 | } 121 | 122 | return response 123 | }) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /packages/wobe-graphql-apollo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "verbatimModuleSyntax": true, 14 | "outDir": "dist", 15 | "declaration": true, 16 | "emitDeclarationOnly": true, 17 | 18 | // Best practices 19 | "strict": true, 20 | "skipLibCheck": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | // Some stricter flags (disabled by default) 24 | "noUnusedLocals": false, 25 | "noUnusedParameters": false, 26 | "noPropertyAccessFromIndexSignature": false 27 | }, 28 | "exclude": ["node_modules", "dist", "**/*.test.ts"], 29 | "include": ["src/**/*.ts"] 30 | } 31 | -------------------------------------------------------------------------------- /packages/wobe-graphql-yoga/README.md: -------------------------------------------------------------------------------- 1 |

2 | Logo 3 |

4 |

Wobe

5 | 6 |
7 | Documentation 8 |   •   9 | Discord 10 |
11 | 12 | ## What is Wobe apollo ? 13 | 14 | **Wobe yoga** is a plugin for the **wobe** web framework that allows you to easily use the yoga graphql server. 15 | 16 | ## Install 17 | 18 | ```sh 19 | bun install wobe-graphql-yoga # On bun 20 | npm install wobe-graphql-yoga # On npm 21 | yarn add wobe-graphql-yoga # On yarn 22 | ``` 23 | 24 | ## Basic example 25 | 26 | ```ts 27 | import { Wobe } from 'wobe' 28 | import { WobeGraphqlYogaPlugin } from 'wobe-graphql-yoga' 29 | 30 | const wobe = new Wobe().usePlugin( 31 | WobeGraphqlYogaPlugin({ 32 | typeDefs: ` 33 | type Query { 34 | hello: String 35 | } 36 | `, 37 | resolvers: { 38 | Query: { 39 | hello: () => 'Hello from Yoga!', 40 | }, 41 | }, 42 | maskedErrors: false, // You can mask the errors to have generic errors in production 43 | }), 44 | ) 45 | 46 | wobe.listen(3000) 47 | ``` 48 | -------------------------------------------------------------------------------- /packages/wobe-graphql-yoga/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wobe-graphql-yoga", 3 | "version": "1.2.6", 4 | "description": "GraphQL Yoga server for Wobe (official)", 5 | "homepage": "https://wobe.dev", 6 | "author": { 7 | "name": "coratgerl", 8 | "url": "https://github.com/coratgerl" 9 | }, 10 | "keywords": [ 11 | "graphql-yoga", 12 | "wobe" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/palixir/wobe" 17 | }, 18 | "license": "MIT", 19 | "main": "dist/index.js", 20 | "devDependencies": { 21 | "wobe": "*", 22 | "get-port": "7.0.0" 23 | }, 24 | "dependencies": { 25 | "graphql-yoga": "5.11.0" 26 | }, 27 | "scripts": { 28 | "build": "bun build --minify --outdir dist $(pwd)/src/index.ts --target=bun --external=* && bun generate:types", 29 | "generate:types": "bun tsc --project .", 30 | "format": "biome format --write .", 31 | "lint": "biome lint . --no-errors-on-unmatched --config-path=../../", 32 | "ci": "bun lint $(pwd) && bun test src" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/wobe-graphql-yoga/src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createSchema, 3 | createYoga, 4 | type GraphQLSchemaWithContext, 5 | type YogaServerOptions, 6 | } from 'graphql-yoga' 7 | import type { 8 | Context, 9 | MaybePromise, 10 | Wobe, 11 | WobePlugin, 12 | WobeResponse, 13 | } from 'wobe' 14 | 15 | export type GraphqlYogaContext = 16 | | MaybePromise> 17 | | ((context: any) => MaybePromise) 18 | 19 | export interface GraphqlYogaPluginOptions { 20 | graphqlMiddleware?: ( 21 | resolve: () => Promise, 22 | res: WobeResponse, 23 | ) => Promise 24 | } 25 | 26 | export const WobeGraphqlYogaPlugin = ({ 27 | graphqlMiddleware, 28 | ...options 29 | }: { 30 | schema?: GraphQLSchemaWithContext> 31 | typeDefs?: string 32 | context?: GraphqlYogaContext 33 | resolvers?: Record 34 | } & Omit, 'context'> & 35 | GraphqlYogaPluginOptions): WobePlugin => { 36 | const yoga = createYoga<{ 37 | request: Request 38 | response: WobeResponse 39 | }>({ 40 | ...options, 41 | schema: 42 | options.schema || 43 | createSchema({ 44 | typeDefs: options.typeDefs || '', 45 | resolvers: options.resolvers || {}, 46 | }), 47 | }) 48 | 49 | const handleGraphQLRequest = async (context: Context) => { 50 | const getResponse = async () => { 51 | if (!graphqlMiddleware) return yoga.handle(context.request, context) 52 | 53 | return graphqlMiddleware( 54 | async () => yoga.handle(context.request, context), 55 | context.res, 56 | ) 57 | } 58 | 59 | const response = await getResponse() 60 | 61 | for (const [key, value] of context.res.headers.entries()) { 62 | if (key === 'set-cookie') { 63 | response.headers.append('set-cookie', value) 64 | continue 65 | } 66 | 67 | response.headers.set(key, value) 68 | } 69 | 70 | return response 71 | } 72 | 73 | return (wobe: Wobe) => { 74 | wobe.get(options?.graphqlEndpoint || '/graphql', async (context) => 75 | handleGraphQLRequest(context), 76 | ) 77 | wobe.post(options?.graphqlEndpoint || '/graphql', async (context) => 78 | handleGraphQLRequest(context), 79 | ) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /packages/wobe-graphql-yoga/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "verbatimModuleSyntax": true, 14 | "outDir": "dist", 15 | "declaration": true, 16 | "emitDeclarationOnly": true, 17 | 18 | // Best practices 19 | "strict": true, 20 | "skipLibCheck": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | // Some stricter flags (disabled by default) 24 | "noUnusedLocals": false, 25 | "noUnusedParameters": false, 26 | "noPropertyAccessFromIndexSignature": false 27 | }, 28 | "exclude": ["node_modules", "dist", "**/*.test.ts"], 29 | "include": ["src/**/*.ts"] 30 | } 31 | -------------------------------------------------------------------------------- /packages/wobe-validator/README.md: -------------------------------------------------------------------------------- 1 |

2 | Logo 3 |

4 |

Wobe

5 | 6 |
7 | Documentation 8 |   •   9 | Discord 10 |
11 | 12 | # What is wobe validator ? 13 | 14 | Wobe has a validator `beforeHandler` hook that allows you to validate the request body. It can be considered as an equivalent of `express-validator` for Express. 15 | 16 | Because validator's hook uses `typebox` in background, it is in a separate package to avoid unnecessary dependencies on the main package if you don't want to use this hook. 17 | 18 | ## Install 19 | 20 | ```sh 21 | bun install wobe-validator # On bun 22 | npm install wobe-validator # On npm 23 | yarn add wobe-validator # On yarn 24 | ``` 25 | 26 | ## Basic example 27 | 28 | In this example, we will check if the body of the request is an object with a `name` field that is a string. If the validation fails, the request will be rejected with a `400` status code. 29 | 30 | ```ts 31 | import { Wobe } from 'wobe' 32 | import { wobeValidator } from 'wobe-validator' 33 | import { Type as T } from '@sinclair/typebox' 34 | 35 | const wobe = new Wobe() 36 | 37 | const schema = T.Object({ 38 | name: T.String(), 39 | }) 40 | 41 | wobe.post( 42 | '/test', 43 | (ctx) => { 44 | return ctx.res.send('ok') 45 | }, 46 | wobeValidator(schema), 47 | ) 48 | 49 | wobe.listen(3000) 50 | ``` 51 | 52 | In the above example the following request will be accepted: 53 | 54 | ```ts 55 | // Success 56 | await fetch(`http://127.0.0.1:3000/test`, { 57 | method: 'POST', 58 | body: JSON.stringify({ name: 'testName' }), 59 | headers: { 60 | 'Content-Type': 'application/json', 61 | }, 62 | }) 63 | ``` 64 | 65 | The following request will be rejected: 66 | 67 | ```ts 68 | // Failed 69 | await fetch(`http://127.0.0.1:3000/test`, { 70 | method: 'POST', 71 | body: JSON.stringify({ name: 42 }), 72 | headers: { 73 | 'Content-Type': 'application/json', 74 | }, 75 | }) 76 | ``` 77 | 78 | ## Options 79 | 80 | The `wobeValidator` function accepts a schema in input that is the `typebox` schema. See [here](https://github.com/sinclairzx81/typebox) for more informations. 81 | -------------------------------------------------------------------------------- /packages/wobe-validator/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wobe-validator", 3 | "version": "1.0.1", 4 | "description": "Validator plugin for Wobe (official)", 5 | "homepage": "https://wobe.dev", 6 | "author": { 7 | "name": "coratgerl", 8 | "url": "https://github.com/coratgerl" 9 | }, 10 | "keywords": ["validator", "wobe"], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/palixir/wobe" 14 | }, 15 | "license": "MIT", 16 | "main": "dist/index.js", 17 | "dependencies": { 18 | "@sinclair/typebox": "0.32.27", 19 | "wobe": "*" 20 | }, 21 | "devDependencies": { 22 | "get-port": "7.0.0" 23 | }, 24 | "scripts": { 25 | "build": "bun build --outdir dist $(pwd)/src/index.ts --target=bun --external=* && bun generate:types", 26 | "generate:types": "bun tsc --project .", 27 | "lint": "biome lint . --no-errors-on-unmatched --config-path=../../", 28 | "ci": "bun lint $(pwd) && bun test src", 29 | "dev": "bun run --watch dev/index.ts" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/wobe-validator/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, beforeAll, afterAll } from 'bun:test' 2 | import { Type as T } from '@sinclair/typebox' 3 | import { Wobe } from 'wobe' 4 | import getPort from 'get-port' 5 | import { wobeValidator } from '.' 6 | 7 | const schema = T.Object({ 8 | name: T.String(), 9 | }) 10 | 11 | describe('wobe-validator', () => { 12 | let port: number 13 | let wobe: Wobe 14 | 15 | beforeAll(async () => { 16 | port = await getPort() 17 | wobe = new Wobe() 18 | 19 | wobe.post( 20 | '/test', 21 | (ctx) => { 22 | return ctx.res.send('ok') 23 | }, 24 | wobeValidator(schema), 25 | ) 26 | 27 | wobe.listen(port) 28 | }) 29 | 30 | afterAll(() => { 31 | wobe.stop() 32 | }) 33 | 34 | it('should return 200 for a valid request body', async () => { 35 | const response = await fetch(`http://127.0.0.1:${port}/test`, { 36 | method: 'POST', 37 | body: JSON.stringify({ name: 'testName' }), 38 | headers: { 39 | 'Content-Type': 'application/json', 40 | }, 41 | }) 42 | 43 | expect(response.status).toBe(200) 44 | expect(await response.text()).toBe('ok') 45 | }) 46 | 47 | it('should return 400 for an invalid request body', async () => { 48 | const response = await fetch(`http://127.0.0.1:${port}/test`, { 49 | method: 'POST', 50 | body: JSON.stringify({ name: 42 }), 51 | headers: { 52 | 'Content-Type': 'application/json', 53 | }, 54 | }) 55 | 56 | expect(response.status).toBe(400) 57 | expect(await response.json()).toEqual({ 58 | errors: [ 59 | { 60 | message: 'Expected string', 61 | path: '/name', 62 | schema: { 63 | type: 'string', 64 | }, 65 | type: 54, 66 | value: 42, 67 | }, 68 | ], 69 | }) 70 | }) 71 | 72 | it('should return 400 for a request if content-type not equal to application/json', async () => { 73 | const response = await fetch(`http://127.0.0.1:${port}/test`, { 74 | method: 'POST', 75 | body: JSON.stringify({ name: 42 }), 76 | headers: { 77 | 'Content-Type': 'invalid/application/json', 78 | }, 79 | }) 80 | 81 | expect(response.status).toBe(400) 82 | expect(await response.json()).toBeNull() 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /packages/wobe-validator/src/index.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, type Context, type WobeHandler } from 'wobe' 2 | import type { TSchema } from '@sinclair/typebox' 3 | import { Value } from '@sinclair/typebox/value' 4 | 5 | export const wobeValidator = (schema: TSchema): WobeHandler => { 6 | return async (ctx: Context) => { 7 | const request = ctx.request 8 | 9 | if (request.headers.get('content-type') !== 'application/json') 10 | throw new HttpException(new Response(null, { status: 400 })) 11 | 12 | const body = await request.json() 13 | 14 | if (!Value.Check(schema, body)) 15 | throw new HttpException( 16 | new Response( 17 | JSON.stringify({ 18 | errors: [...Value.Errors(schema, body)], 19 | }), 20 | { status: 400 }, 21 | ), 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/wobe-validator/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "verbatimModuleSyntax": true, 14 | "outDir": "dist", 15 | "declaration": true, 16 | "emitDeclarationOnly": true, 17 | 18 | // Best practices 19 | "strict": true, 20 | "skipLibCheck": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | // Some stricter flags (disabled by default) 24 | "noUnusedLocals": false, 25 | "noUnusedParameters": false, 26 | "noPropertyAccessFromIndexSignature": false 27 | }, 28 | "exclude": ["node_modules", "dist", "**/*.test.ts"], 29 | "include": ["src/**/*.ts"] 30 | } 31 | -------------------------------------------------------------------------------- /packages/wobe/README.md: -------------------------------------------------------------------------------- 1 |

2 | Logo 3 |

4 |

Wobe

5 | 6 |
7 | Documentation 8 |   •   9 | Discord 10 |
11 | 12 | ## What is Wobe? 13 | 14 | **Wobe** is a simple, fast, and lightweight web framework. Inspired by some web frameworks like Express, Hono, Elysia. It works on Node and Bun runtime. 15 | 16 | Wobe is very fast but not focused on performance; it focuses on simplicity and ease of use. It's very easy to create a web server with Wobe. 17 | 18 | ## Install 19 | 20 | ```sh 21 | bun install wobe # On bun 22 | npm install wobe # On npm 23 | yarn add wobe # On yarn 24 | ``` 25 | 26 | ## Basic example 27 | 28 | ```ts 29 | import { Wobe } from 'wobe' 30 | 31 | const app = new Wobe() 32 | .get('/hello', (context) => context.res.sendText('Hello world')) 33 | .get('/hello/:name', (context) => 34 | context.res.sendText(`Hello ${context.params.name}`), 35 | ) 36 | .listen(3000) 37 | ``` 38 | 39 | ## Features 40 | 41 | - **Simple & Easy to use**: Wobe respects the standard and provides a large ecosystem. 42 | - **Fast & Lightweight**: Wobe is one of the fastest web framework on Bun, and it has 0 dependencies (only 9,76 KB). 43 | - **Multi-runtime**: Wobe supports Node.js and Bun runtime. 44 | - **Easy to extend**: Wobe has an easy-to-use plugin system that allows extending for all your personal use cases. 45 | 46 | ## Benchmarks (on Bun runtime) 47 | 48 | Wobe is one of the fastest web framework based on the [benchmark](https://github.com/SaltyAom/bun-http-framework-benchmark) of SaltyAom. 49 | 50 | | Framework | Runtime | Average | Ping | Query | Body | 51 | | --------- | ------- | ---------- | ---------- | --------- | --------- | 52 | | bun | bun | 92,639.313 | 103,439.17 | 91,646.07 | 82,832.7 | 53 | | elysia | bun | 92,445.227 | 103,170.47 | 88,716.17 | 85,449.04 | 54 | | wobe | bun | 90,535.37 | 96,348.26 | 94,625.67 | 80,632.18 | 55 | | hono | bun | 81,832.787 | 89,200.82 | 81,096.3 | 75,201.24 | 56 | | fastify | bun | 49,648.977 | 62,511.85 | 58,904.51 | 27,530.57 | 57 | | express | bun | 31,370.06 | 39,775.79 | 36,605.68 | 17,728.71 | 58 | 59 | _Executed with 5 runs - 12/04/2024_ 60 | 61 | ## Contributing 62 | 63 | Contributions are always welcome! If you have an idea for something that should be added, modified, or removed, please don't hesitate to create a pull request (I promise a quick review). 64 | 65 | You can also create an issue to propose your ideas or report a bug. 66 | 67 | Of course, you can also use Wobe in your application; that is the better contribution at this day ❤️. 68 | 69 | If you like the project don't forget to share it. 70 | 71 | More informations on the [Contribution guide](https://github.com/palixir/wobe/blob/main/CONTRIBUTING) 72 | 73 | ## License 74 | 75 | Distributed under the MIT [License](https://github.com/palixir/wobe/blob/main/LICENSE). 76 | -------------------------------------------------------------------------------- /packages/wobe/dev/index.ts: -------------------------------------------------------------------------------- 1 | process.env.NODE_TEST = 'test' 2 | import { join } from 'node:path' 3 | import { Wobe, uploadDirectory } from '../src' 4 | 5 | new Wobe() 6 | .get('/', (ctx) => ctx.res.send('Hi')) 7 | .get( 8 | '/bucket/:filename', 9 | uploadDirectory({ directory: join(__dirname, '../fixtures') }), 10 | ) 11 | .listen(3000) 12 | -------------------------------------------------------------------------------- /packages/wobe/fixtures/avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palixir/wobe/827a1812c417cb3be1bb63d3dd931e0d9679fcde/packages/wobe/fixtures/avatar.jpg -------------------------------------------------------------------------------- /packages/wobe/fixtures/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFYzCCA0ugAwIBAgIUE3OBkLvKrRRtLwfYbWiMEkrV/EYwDQYJKoZIhvcNAQEL 3 | BQAwQTELMAkGA1UEBhMCZnIxDzANBgNVBAgMBmZyYW5jZTEhMB8GA1UECgwYSW50 4 | ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMB4XDTI0MDQyODE0MDMwNFoXDTI1MDQyODE0 5 | MDMwNFowQTELMAkGA1UEBhMCZnIxDzANBgNVBAgMBmZyYW5jZTEhMB8GA1UECgwY 6 | SW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A 7 | MIICCgKCAgEAwG1aXYaH/XnkyCxkv9uRFUsE142P+/+5O7zHwOETxMu/ScE0I31j 8 | LMKHMtWYRlmlSoUYsbrs2nGSOT26weS9bdmhmHnBS1nMy58knDEaGgvObIKvlpz2 9 | wQoyJWOZcxJrZiqvBAfaNkY3qsqlOaE4nJ4xDwWGWmUTR4iAdCQaX0hTPP9E/Wha 10 | evbjTfmDfWoS+sTqFtlbuvxXTdqsNvPoV/AFPz/GcdLolTSv/fdA1+OspxJRHBGe 11 | spzysLYEZ3oEtxBJ12tBBVk9wb7cwEBUpWATfBBttd/mqiMMPqfqHyRVyHfKBzNk 12 | pUgYCNr3J7HQA9nQsbzT0N6U9eytVcq9Qe4JmXQdBn2fo+NXnCMaY/S3WcnkNCFR 13 | bwswVTHds2E9OScEmrgEOF3vDUnognH2SLRdgsmyU2+NiCaCQVX6TCk5WNj669Oz 14 | /FFIaAMYrFk/OvsaRkPdyP8fei1Pff6Fx1XyEH8MeYk13eLT6/iNgoshO+NJ/c5b 15 | ZYBToursGr77d9y8IPAa4RBSPfoTPs/XkjuyHhSWiVIbSZ676ty+zW0hRPmgnACk 16 | kNe7Mi9wkW6eNfVk1Jlr9Pu9Vk9kfP0C3heuBbquLwvtLAjMQibvrexTC+bYnT3R 17 | OD8kXTA+8+Af/IjEbzY69wiPQTqrnrbngiO2A3wVYHvT4+4LkvC+bjUCAwEAAaNT 18 | MFEwHQYDVR0OBBYEFPD36pGsJj24DeFFZITUkuor2GBIMB8GA1UdIwQYMBaAFPD3 19 | 6pGsJj24DeFFZITUkuor2GBIMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEL 20 | BQADggIBABq5OmzJIIp5uJ91hu2K0MnA96NFhNDHaPn6Ry+csOwulj6wdXh3S/Pz 21 | r4CoNxVpC/1vjLBqawZ9DZPZVjprkjHrBXkD5hEz9CAaujbO2ngQz+y9jDGKbXxW 22 | OrCXldv3n+5bZCgAg01umtz2+VCOrCkgPoV3Wcb8Oia2JFS+kWFlxy7NCDhOLnoT 23 | uV5VqYKC0wX0gLH1zNY6sabvCnXsHbSo0Y7/53NoDsgnWZMm42ySFc6Mml4DeI6U 24 | x69gfdA7qNoqOAMRWWuaQA3RM0RPhphj/xJv1XYxks0JqibQt/2P5CDo5cIkxLYG 25 | vQ4nLEIN9UrAT2QRP8EcXD+zI2PMsVJ0xSH4NoTPzZaWpAYS5/UfCocCxSDu8g8K 26 | d/jF1rdzWjaSvqAodJS27dOTLjDqAcxzMQ4uQZajWs9rZUq+YCTCDj01C8hRtGI8 27 | rrbJkrdAhNA6eQiTWY+PrZy0XzCJdKmwJGdDJI3xGGGvrbBb5NehnLGPzMF2FGz4 28 | L91if3SOTQ9vt/tsa2f992WEUjFSWMXNesIJbd0oGIlAZQiP62dce4f2nkJCTw/Y 29 | Y9EijIk2HwO3wqwCurhwdRCZ1gd8qO/0/D705+MryA6malqiPmqYe8/gYGjZ1NWR 30 | IBns4kYUL6TJGX5hNGdR/isphOD+eMSyou1KpAX/MWHAKK24efU0 31 | -----END CERTIFICATE----- 32 | -------------------------------------------------------------------------------- /packages/wobe/fixtures/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN ENCRYPTED PRIVATE KEY----- 2 | MIIJpDBWBgkqhkiG9w0BBQ0wSTAxBgkqhkiG9w0BBQwwJAQQXzXij9e6CMDzdSNu 3 | s5qyfQICCAAwDAYIKoZIhvcNAgkFADAUBggqhkiG9w0DBwQIeRA0C1naPzkEgglI 4 | 1vEruRSd06P2gP7+td/Wl1BInGjo7hQ36hdSmbz3DWLX3RIsoF/0jRU4+3Y5o7Yb 5 | +/5WxYcHOqCm+dFQLNfJMujgRbDAVEBvRLwGf0mjfauuWizFrV/7NNuIhfNKg1L9 6 | 2Ib8gxjVYCvxGPGtCY7P6QDSaDbFnOu15nZHV/OAsoZ3OCNCYZZt7sc6Dw7SwW4I 7 | vhI9XXnKpVZ49jrHShv5XS+qLE2/PUx2/MowYvj4y7KIOTKPHcCpnFbmOAEPgLbX 8 | Yq0wyc/Nw6nNnmLKmR7zf4kjfG9yWfQZfG28H3isCRFnmEwzxdO4tlHbZKWVXomq 9 | LTy3Uc/+67iTICbHVIm5SLgw75l8CRy5DXbKvg0GxfhMlIUAW14NPKlTkEL11a+M 10 | /Tm1NnusDcY7VJZfCWDoeHfR3xIduOF7XS2utQQNaQKmyhBr4Xg+CAXNFtPSrmyZ 11 | 8zg4y/Q7IHpe0PtTzRK+J68JwRSycDRCvUKQzokBRKCeWEgFE7GpbJkNGnjBjSDE 12 | m5X+KUgOhuZy12bg2jiCsneEbMvP78bJ7RXgM47KDDAb/LO/0rH0QUpotzxGHN4/ 13 | SukP8Rc44ks6p6Gcy3tjMHzRaC88CSNRfiEvHtMMCG+sSRF82EJ+ERGR7p85eRyU 14 | lyU9C4LzxH5SDExDsyDh1sFIAVVr0Ltf6eEkfPFD6ZolLFDoxeLdHNNVNmVxBTNn 15 | pNbfjadR5MV/oBuRRxFRqQW7B1YKtXgdYte+Map7k/zSjIUXTlDRV+lyxwRoaL1/ 16 | ithDZeOOYtzeHiYaSW8raj2sIvBgj3n47idJWyB0EqmOVA5v3fZiXmQ+nDBvcgdn 17 | jJUL9Uqic4WZu+qautvSwXk7libR5dk8SC2w8i6HCt+KJlWG4M/JaHeQ1Swqzyri 18 | xta4qlWnOIt1X2WtuDoJAxzZv8pdfhWwi1uGnu+8Oln7Scnm2MnF02wQZ0KtQuhV 19 | JdCfTvY2U9h+Si3BrxLarplPAmOmjCO+LkXOEfqCqMtadXOXGfFWL2z4AnuGFhRQ 20 | z9UNZ81HYl6a8J5MJQq+Qao9KJVj4G06puMS1DSafX5o7l6inXqqz9oyDMDxr4SG 21 | RTTHNT8xlZuTKBtxu60Jw/OJV2gxZ2xtk28qhNL8JTbpDu+l616gYy54vr95NY56 22 | YU+qtUTfYAfBei2mwpHTolqyEnmL4RRygSpmHT5fVa9hjqcHNNa400880I2g9kzf 23 | kZ9N5PslSShLw1KXfFA9124LNPhNfMYVKCeQ5EqXwPuI4/0IDzAoFdBpTDey5GjH 24 | JcPnkTqVXmaUKYUeQNcf2PLP/GEAJ2h/nzD30MUbvyPZK5NaLDj4hUcLjGmNbTTD 25 | elVDB8lPylOVMYOUKsHK7Ax1wHisiI9AUQr2/kEboIOnWp81XTd+YfN/F40N33Lf 26 | z3/bQ9qwxrPt5T/1tEvtjj8hbkR96KITuNL7ts+ORfOh2V7eWAL9JLz87eBL1GoP 27 | vnKsrK0EQTIFuYJrXrz9H6+xHL75p2pm3iZPcyaQltGvGafuXty6AysiYHvqDrdE 28 | or7HEJoCvPwl+sFBO5V5YnRubZZvfanL5KT08Me8vP16IcLuo4ZO4QNBqpdyz0id 29 | +ewGT5iSzpULPPoRZk8AS21mLW/R9hUjWSbxWGkUWxRZ4ZMnmAzp49ezZ63xdX8f 30 | N4aJKbxhWwxjXnZLcFJpl7ToonpJ8wLAG26Q/kgXqPTN0zb1vf04vWls1RGx9fX+ 31 | E4jVkwNC9Fxcww3Ff9HO3Cd88roieGUDnmsWVZduOigvmh5StAgCzaMLioFTMZez 32 | FgWk25wb5TkWbYvSgWawK2WsWYAFdGUBov9200F6oIESHXLmLGlP6eMKwD1iCXNY 33 | JYouaz2xY5Jn0QDscIg0gvxwWhCbHiwwbP9OozgK1xltg8A38Z88yaGSAEyBVNdE 34 | kyUIsEpaSZulZ8yHxpl/24PH2uTWaMIdEem5CM0GVgoyRiE/H4Ifp08gXx2EQCfa 35 | hYHNKhKxS9KKQANwhgSIyCNhgrvySiT60pQY46dWFJZlkw37dbcLHGgv02ChZKh6 36 | mNibJV1dteLMZ2oUGeec5/4pppibJYNEwpqMXbUEy++0BppBHw2jU+EDAweyz+ss 37 | /SgmlZXFlWe2oCyFyKCq8EtesYvX/MujOgA2wUdA/DW5ZUYrvSOCx9TJEj+Nb7Jy 38 | hgmK59aLB9Gy9isGsA+SRo4J4OlEeDmnAmsW14WSNoX3G9d7VJv5VHjiHgR5ePsF 39 | Xzv0z7fDfI12ttnodyzQb82AZEuMoGODRtucCrqPidCNZJlPm0wxaqmx1hKiF8Dx 40 | VGb6rcXkKAdQSTBxIpYg42DSSWJuSVSCXTcXWLOWOmV7Dk78c8AEOw3UX5gb31Bg 41 | YxURQjbQM/yDgqlg1aiesqkt1LAo/ojVo3lCIDJO+YHUrdkfPwyTARJ9hvCv6VZD 42 | +4nw2H7aZRWBnOF5bQXdrWCzSzPNkrum2Fhj9jN09DgLFR1BqIO5DAQGPALxQNXL 43 | DbZssVkWXNuXMcXtAMqrZQwhwIodK7GvkT0ioYM28comUboTbQYxKK4AOJPtgCqe 44 | HS8sFbC4XZ6QJOrANHbZ9ViXWUdP18O/Np8j+pCp8lZZI/I5pkaB6/Uy2yuWZ54d 45 | RnwK/xMmRs0UDU4jUHzBE83KKyVC080bj401fDu6wsccwEAq8hof88MlChdbuWiI 46 | sA3mJJrFAs2gsWfHeSNfMan9XVUKIUxNWr8+r5SffxBkypbuoEmHoij/HENr8OhQ 47 | ZnftmI28R9DQZPig4OIxorCzghgNa9H9G9hzDdkVd4Qj45PgC3ElB7PIWz2H+sf4 48 | MFmy1dL9r2+Adr4239bDil9LC5Zo/TffZjJUYWfba6YDZ3BvM0ODVhitp8v9/OcT 49 | 0rY/KgRSGS3T0e0ISYxL5b8OTfPLfpJmvLVWFjZeRLmyWv85pExh4q7ThAL3Ao/d 50 | cS7x3vq0GzYussvR8F3s7cARMqGQvAK61F+rUAPU8wttGw0pHVg1y4XBguuyp9wZ 51 | P3GFApaAYTC1InPkhvIzQ97l2Z749K65FZyEvq4cgxYt5gSgMowlwPdA4fv6hfM5 52 | spQpkftzffHzE6HSvjIyJCdXwg4jWK8BPtiqyh8qHvvxhfMUEqG0Yh3u08BJZeIz 53 | pm108mT1uZXjrXiMi7D3mqHXJkQJEZEK 54 | -----END ENCRYPTED PRIVATE KEY----- 55 | -------------------------------------------------------------------------------- /packages/wobe/fixtures/testFile.html: -------------------------------------------------------------------------------- 1 | 2 | testfile 3 | 4 | -------------------------------------------------------------------------------- /packages/wobe/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wobe", 3 | "version": "1.1.10", 4 | "description": "A fast, lightweight and simple web framework", 5 | "homepage": "https://wobe.dev", 6 | "author": { 7 | "name": "coratgerl", 8 | "url": "https://github.com/coratgerl" 9 | }, 10 | "license": "MIT", 11 | "keywords": ["server", "bun", "wobe"], 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/palixir/wobe" 15 | }, 16 | "main": "dist/index.js", 17 | "devDependencies": { 18 | "get-port": "7.0.0" 19 | }, 20 | "scripts": { 21 | "build": "bun build --outdir dist $(pwd)/src/index.ts --target=bun && bun generate:types", 22 | "generate:types": "bun tsc --project .", 23 | "lint": "biome lint . --no-errors-on-unmatched --config-path=../../", 24 | "ci": "bun lint $(pwd) && bun run test:bun src && bun test:node src", 25 | "format": "biome format --write .", 26 | "test:bun": "NODE_TLS_REJECT_UNAUTHORIZED=0 bun test", 27 | "test:node": "NODE_TLS_REJECT_UNAUTHORIZED=0 NODE_TEST='true' bun test", 28 | "dev": "bun run --watch dev/index.ts" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/wobe/src/Context.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, mock, spyOn } from 'bun:test' 2 | import { Context } from './Context' 3 | 4 | describe('Context', () => { 5 | it('should execute handler correctly', async () => { 6 | const context = new Context(new Request('https://example.com')) 7 | 8 | const mockHandler = mock(() => new Response('Hello World')) 9 | const mockBeforeHandler = mock(() => { 10 | expect(context.state).toEqual('beforeHandler') 11 | }) 12 | const mockAfterHandler = mock(() => { 13 | expect(context.state).toEqual('afterHandler') 14 | }) 15 | 16 | context.handler = mockHandler 17 | context.beforeHandlerHook = [mockBeforeHandler] 18 | context.afterHandlerHook = [mockAfterHandler] 19 | 20 | await context.executeHandler() 21 | 22 | expect(mockHandler).toHaveBeenCalledTimes(1) 23 | expect(await context.res.response?.text()).toEqual('Hello World') 24 | expect(mockBeforeHandler).toHaveBeenCalledTimes(1) 25 | expect(mockAfterHandler).toHaveBeenCalledTimes(1) 26 | }) 27 | 28 | it('should return the response from after handler if return a Response object', async () => { 29 | const context = new Context(new Request('https://example.com')) 30 | 31 | const mockHandler = mock(() => new Response('Hello World')) 32 | 33 | const mockAfterHandler = mock(() => { 34 | return new Response('Response from after handler') 35 | }) 36 | 37 | context.handler = mockHandler 38 | context.afterHandlerHook = [mockAfterHandler] 39 | 40 | const res = await context.executeHandler() 41 | 42 | expect(await res.text()).toEqual('Response from after handler') 43 | expect(mockHandler).toHaveBeenCalledTimes(1) 44 | expect(mockAfterHandler).toHaveBeenCalledTimes(1) 45 | }) 46 | 47 | it('should correctly initialize context', () => { 48 | const request = new Request('https://example.com') 49 | const context = new Context(request) 50 | 51 | expect(context.request).toEqual(request) 52 | expect(context.res).toBeDefined() 53 | expect(context.state).toEqual('beforeHandler') 54 | expect(context.requestStartTimeInMs).toBeUndefined() 55 | }) 56 | 57 | it('should redirect client to a specific url', () => { 58 | const request = new Request('https://example.com') 59 | const context = new Context(request) 60 | 61 | const spyContextRes = spyOn(context.res, 'send') 62 | 63 | context.redirect('https://example.com/test') 64 | 65 | expect(context.res.headers.get('Location')).toEqual( 66 | 'https://example.com/test', 67 | ) 68 | expect(context.res.status).toEqual(302) 69 | 70 | expect(spyContextRes).toHaveBeenCalledTimes(1) 71 | expect(spyContextRes).toHaveBeenCalledWith('OK') 72 | 73 | // Redirect permanently 74 | context.redirect('https://example.com/test2', 301) 75 | 76 | expect(context.res.headers.get('Location')).toEqual( 77 | 'https://example.com/test2', 78 | ) 79 | expect(context.res.status).toEqual(301) 80 | }) 81 | 82 | it('should get the cookie in the request headers', () => { 83 | const request = new Request('https://example.com', { 84 | headers: { 85 | cookie: 'test=tata; test2=titi', 86 | }, 87 | }) 88 | const context = new Context(request) 89 | 90 | expect(context.getCookie('test')).toEqual('tata') 91 | expect(context.getCookie('test2')).toEqual('titi') 92 | expect(context.getCookie('invalid')).toBeUndefined() 93 | }) 94 | 95 | it('should get the cookie in the request headers with only one cookie', () => { 96 | const request = new Request('https://example.com', { 97 | headers: { 98 | cookie: 'test=tata', 99 | }, 100 | }) 101 | const context = new Context(request) 102 | 103 | expect(context.getCookie('test')).toEqual('tata') 104 | expect(context.getCookie('test2')).toBeUndefined() 105 | expect(context.getCookie('invalid')).toBeUndefined() 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /packages/wobe/src/Context.ts: -------------------------------------------------------------------------------- 1 | import type { HttpMethod, WobeHandler } from './Wobe' 2 | import { WobeResponse } from './WobeResponse' 3 | import type { RadixTree } from './router' 4 | import { extractPathnameAndSearchParams } from './utils' 5 | 6 | export class Context { 7 | public res: WobeResponse 8 | public request: Request 9 | public params: Record = {} 10 | public pathname = '' 11 | public query: Record = {} 12 | 13 | public state: 'beforeHandler' | 'afterHandler' = 'beforeHandler' 14 | public requestStartTimeInMs: number | undefined = undefined 15 | public getIpAdress: () => string = () => '' 16 | 17 | public handler: WobeHandler | undefined = undefined 18 | public beforeHandlerHook: Array> = [] 19 | public afterHandlerHook: Array> = [] 20 | 21 | constructor(request: Request, router?: RadixTree) { 22 | this.request = request 23 | this.res = new WobeResponse(request) 24 | 25 | this._findRoute(router) 26 | } 27 | 28 | private _findRoute(router?: RadixTree) { 29 | const { pathName, searchParams } = extractPathnameAndSearchParams( 30 | this.request.url, 31 | ) 32 | 33 | const route = router?.findRoute( 34 | this.request.method as HttpMethod, 35 | pathName, 36 | ) 37 | 38 | this.query = searchParams || {} 39 | this.pathname = pathName 40 | this.params = route?.params || {} 41 | this.handler = route?.handler 42 | this.beforeHandlerHook = route?.beforeHandlerHook || [] 43 | this.afterHandlerHook = route?.afterHandlerHook || [] 44 | } 45 | 46 | /** 47 | * Redirect to a specific URL 48 | * @param url The URL to redirect 49 | * @param status The status of the redirection 50 | */ 51 | redirect(url: string, status = 302) { 52 | this.res.headers.set('Location', url) 53 | this.res.status = status 54 | 55 | this.res.send('OK') 56 | } 57 | 58 | /** 59 | * Execute the handler of the route 60 | */ 61 | async executeHandler() { 62 | this.state = 'beforeHandler' 63 | // We need to run hook sequentially 64 | for (const hookBeforeHandler of this.beforeHandlerHook) 65 | await hookBeforeHandler(this) 66 | 67 | const resultHandler = await this.handler?.(this) 68 | 69 | if (resultHandler instanceof Response) this.res.response = resultHandler 70 | 71 | this.state = 'afterHandler' 72 | 73 | // We need to run hook sequentially 74 | let responseAfterHook = undefined 75 | for (const hookAfterHandler of this.afterHandlerHook) 76 | responseAfterHook = await hookAfterHandler(this) 77 | 78 | if (responseAfterHook instanceof Response) return responseAfterHook 79 | 80 | return this.res.response || new Response(null, { status: 404 }) 81 | } 82 | 83 | getCookie(name: string) { 84 | const cookieHeader = this.request.headers.get('cookie') 85 | 86 | if (!cookieHeader) return undefined 87 | 88 | const split = cookieHeader 89 | .split(';') 90 | .map((cookie) => cookie.replaceAll(' ', '')) 91 | 92 | const existingCookie = split.find( 93 | (element) => element.split('=')[0] === name, 94 | ) 95 | 96 | if (!existingCookie) return undefined 97 | 98 | return existingCookie.split('=')[1] 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /packages/wobe/src/HttpException.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom exception for HTTP errors 3 | * Example usage: throw new HttpException(new Response('Not found', { status: 404 })) 4 | */ 5 | export class HttpException extends Error { 6 | response: Response 7 | 8 | constructor(response: Response) { 9 | super(response.statusText) 10 | 11 | this.response = response 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/wobe/src/WobeResponse.ts: -------------------------------------------------------------------------------- 1 | export interface SetCookieOptions { 2 | path?: string 3 | domain?: string 4 | expires?: Date 5 | maxAge?: number 6 | secure?: boolean 7 | httpOnly?: boolean 8 | sameSite?: 'Strict' | 'Lax' | 'None' 9 | } 10 | 11 | export class WobeResponse { 12 | public request: Request 13 | public response: Response | undefined = undefined 14 | public headers = new Headers() 15 | public status = 200 16 | public statusText = 'OK' 17 | 18 | constructor(request: Request) { 19 | this.request = request 20 | } 21 | 22 | /** 23 | * Copy a response into an existing wobe instance response 24 | * @param response The response to copy 25 | * @returns A new wobe instance response 26 | */ 27 | copy(response: Response) { 28 | const wobeResponse = new WobeResponse(this.request) 29 | 30 | wobeResponse.headers = new Headers(response.headers) 31 | 32 | for (const [key, value] of this.headers.entries()) 33 | wobeResponse.headers.set(key, value) 34 | 35 | wobeResponse.status = response.status 36 | wobeResponse.statusText = response.statusText 37 | wobeResponse.response = response 38 | 39 | return wobeResponse 40 | } 41 | 42 | /** 43 | * Set a cookie 44 | * @param name The name of the cookie 45 | * @param value The value of the cookie 46 | * @param options The options of the cookie 47 | */ 48 | setCookie(name: string, value: string, options?: SetCookieOptions) { 49 | let cookie = `${name}=${value};` 50 | 51 | if (options) { 52 | const { 53 | httpOnly, 54 | path, 55 | domain, 56 | expires, 57 | sameSite, 58 | maxAge, 59 | secure, 60 | } = options 61 | 62 | if (httpOnly) cookie = `${cookie} HttpOnly;` 63 | if (path) cookie = `${cookie} Path=${path};` 64 | if (domain) cookie = `${cookie} Domain=${domain};` 65 | if (expires) cookie = `${cookie} Expires=${expires.toUTCString()};` 66 | if (sameSite) cookie = `${cookie} SameSite=${sameSite};` 67 | if (secure) cookie = `${cookie} Secure;` 68 | if (maxAge) cookie = `${cookie} Max-Age=${maxAge};` 69 | } 70 | 71 | this.headers?.append('Set-Cookie', cookie) 72 | } 73 | 74 | /** 75 | * Get a cookie 76 | * @param cookieName The name of the cookie 77 | */ 78 | getCookie(cookieName: string) { 79 | const cookies = this.request.headers.get('Cookie') 80 | 81 | if (!cookies) return 82 | 83 | const cookie = cookies.split(';').find((c) => c.includes(cookieName)) 84 | 85 | if (!cookie) return 86 | 87 | return cookie.split('=')[1] 88 | } 89 | 90 | /** 91 | * Delete a cookie 92 | * @param name The name of the cookie 93 | */ 94 | deleteCookie(name: string) { 95 | this.setCookie(name, '', { expires: new Date(0) }) 96 | } 97 | 98 | /** 99 | * Send a JSON response 100 | * @param content The json content of the response 101 | * @returns The response 102 | */ 103 | sendJson(content: Record) { 104 | this.headers.set('content-type', 'application/json') 105 | this.headers.set('charset', 'utf-8') 106 | 107 | this.response = new Response(JSON.stringify(content), { 108 | headers: this.headers, 109 | status: this.status, 110 | statusText: this.statusText, 111 | }) 112 | 113 | return this.response 114 | } 115 | 116 | /** 117 | * Send a text response 118 | * @param content The text content of the response 119 | * @returns The response 120 | */ 121 | sendText(content: string) { 122 | this.headers.set('content-type', 'text/plain') 123 | this.headers.set('charset', 'utf-8') 124 | 125 | this.response = new Response(content, { 126 | headers: this.headers, 127 | status: this.status, 128 | statusText: this.statusText, 129 | }) 130 | 131 | return this.response 132 | } 133 | 134 | /** 135 | * Send a response (text or json) 136 | * @param content The content of the response 137 | * @param object The object contains the status, statusText and headers of the response 138 | * @returns The response 139 | */ 140 | send( 141 | content: string | Record | ArrayBuffer | Buffer | null, 142 | { 143 | status, 144 | statusText, 145 | headers = new Headers(), 146 | }: { 147 | status?: number 148 | statusText?: string 149 | headers?: Record 150 | } = {}, 151 | ) { 152 | let body: string | ArrayBuffer | Buffer | null = null 153 | 154 | if (content instanceof Buffer || content instanceof ArrayBuffer) { 155 | body = content 156 | } else if (typeof content === 'object') { 157 | this.headers.set('content-type', 'application/json') 158 | this.headers.set('charset', 'utf-8') 159 | 160 | body = JSON.stringify(content) 161 | } else { 162 | this.headers.set('content-type', 'text/plain') 163 | this.headers.set('charset', 'utf-8') 164 | 165 | body = content 166 | } 167 | 168 | if (status) this.status = status 169 | if (statusText) this.statusText = statusText 170 | 171 | if (headers) { 172 | const entries = Object.entries(headers) 173 | 174 | for (const [key, value] of entries) { 175 | this.headers?.set(key, value) 176 | } 177 | } 178 | 179 | this.response = new Response(body, { 180 | headers: this.headers, 181 | status: this.status, 182 | statusText: this.statusText, 183 | }) 184 | 185 | return this.response 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /packages/wobe/src/adapters/bun/bun.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, beforeEach, spyOn } from 'bun:test' 2 | import getPort from 'get-port' 3 | import { Wobe } from '../../Wobe' 4 | import { HttpException } from '../../HttpException' 5 | import { join } from 'node:path' 6 | import { readFile } from 'node:fs/promises' 7 | 8 | describe.skipIf(process.env.NODE_TEST === 'true')('Bun server', () => { 9 | const spyBunServer = spyOn(global.Bun, 'serve') 10 | 11 | beforeEach(() => { 12 | spyBunServer.mockClear() 13 | }) 14 | 15 | it('should reset the wobe response if context already exist in cache', async () => { 16 | const port = await getPort() 17 | const wobe = new Wobe({ tls: undefined }) 18 | 19 | wobe.get('/hi', async (ctx) => { 20 | if (ctx.res.status === 201) { 21 | throw new HttpException( 22 | new Response('Status should be equal to 200'), 23 | ) 24 | } 25 | 26 | ctx.res.sendText('Hi') 27 | ctx.res.status = 201 28 | }) 29 | 30 | wobe.listen(port) 31 | 32 | await fetch(`http://127.0.0.1:${port}/hi`) 33 | 34 | const res = await fetch(`http://127.0.0.1:${port}/hi`) 35 | 36 | expect(await res.text()).not.toEqual('Status should be equal to 200') 37 | 38 | wobe.stop() 39 | }) 40 | 41 | it('should call simple bun server without https', async () => { 42 | const port = await getPort() 43 | const wobe = new Wobe({ tls: undefined }) 44 | 45 | wobe.get('/hi', (ctx) => ctx.res.sendText('Hi')) 46 | 47 | wobe.listen(port) 48 | 49 | const response = await fetch(`http://127.0.0.1:${port}/hi`) 50 | 51 | expect(response.status).toBe(200) 52 | expect(spyBunServer).toHaveBeenCalledTimes(1) 53 | expect(spyBunServer).toHaveBeenCalledWith({ 54 | port: expect.any(Number), 55 | tls: { 56 | key: undefined, 57 | cert: undefined, 58 | }, 59 | development: true, 60 | websocket: expect.anything(), 61 | fetch: expect.any(Function), 62 | }) 63 | 64 | wobe.stop() 65 | }) 66 | 67 | it('should call create server from node:https if https options is not undefined', async () => { 68 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' 69 | 70 | const port = await getPort() 71 | 72 | const key = await Bun.file( 73 | `${import.meta.dirname}/../../../fixtures/key.pem`, 74 | ).text() 75 | const cert = await Bun.file( 76 | `${import.meta.dirname}/../../../fixtures/cert.pem`, 77 | ).text() 78 | 79 | const wobe = new Wobe({ 80 | tls: { 81 | key, 82 | cert, 83 | passphrase: 'test', 84 | }, 85 | }) 86 | 87 | wobe.get('/hi', (ctx) => ctx.res.sendText('Hi')) 88 | 89 | wobe.listen(port) 90 | 91 | const response = await fetch(`https://127.0.0.1:${port}/hi`, {}) 92 | 93 | expect(response.status).toBe(200) 94 | expect(spyBunServer).toHaveBeenCalledTimes(1) 95 | expect(spyBunServer).toHaveBeenCalledWith({ 96 | port: expect.any(Number), 97 | tls: { 98 | key, 99 | cert, 100 | passphrase: 'test', 101 | }, 102 | development: true, 103 | websocket: expect.anything(), 104 | fetch: expect.any(Function), 105 | }) 106 | 107 | wobe.stop() 108 | }) 109 | 110 | it('should serve a binary file correctly', async () => { 111 | const port = await getPort() 112 | const wobe = new Wobe({ tls: undefined }) 113 | 114 | const uploadDirectory = join(__dirname, '../../../fixtures') 115 | const fileName = 'avatar.jpg' 116 | const filePath = join(uploadDirectory, fileName) 117 | 118 | wobe.get('/binary-test', async (ctx) => { 119 | const fileContent = await readFile(filePath) 120 | ctx.res.headers.set('Content-Type', 'image/jpeg') 121 | ctx.res.send(fileContent) 122 | }) 123 | 124 | wobe.listen(port) 125 | 126 | const response = await fetch(`http://127.0.0.1:${port}/binary-test`) 127 | 128 | expect(response.status).toBe(200) 129 | expect(response.headers.get('Content-Type')).toBe('image/jpeg') 130 | 131 | const fileContent = await readFile(filePath) 132 | const responseArrayBuffer = await response.arrayBuffer() 133 | expect( 134 | Buffer.from(responseArrayBuffer).equals(Buffer.from(fileContent)), 135 | ).toBe(true) 136 | 137 | wobe.stop() 138 | }) 139 | }) 140 | -------------------------------------------------------------------------------- /packages/wobe/src/adapters/bun/bun.ts: -------------------------------------------------------------------------------- 1 | import type { RuntimeAdapter } from '..' 2 | import { Context } from '../../Context' 3 | import { HttpException } from '../../HttpException' 4 | import type { WobeOptions, WobeWebSocket } from '../../Wobe' 5 | import type { RadixTree } from '../../router' 6 | import { bunWebSocket } from './websocket' 7 | 8 | export const BunAdapter = (): RuntimeAdapter => ({ 9 | createServer: ( 10 | port: number, 11 | router: RadixTree, 12 | options?: WobeOptions, 13 | webSocket?: WobeWebSocket, 14 | ) => 15 | Bun.serve({ 16 | port, 17 | tls: { 18 | ...options?.tls, 19 | }, 20 | hostname: options?.hostname, 21 | development: process.env.NODE_ENV !== 'production', 22 | websocket: bunWebSocket(webSocket), 23 | async fetch(req, server) { 24 | try { 25 | const context = new Context(req, router) 26 | 27 | context.getIpAdress = () => 28 | this.requestIP(req)?.address || '' 29 | 30 | if (webSocket && webSocket.path === context.pathname) { 31 | // We need to run hook sequentially 32 | for (const hookBeforeSocketUpgrade of webSocket.beforeWebSocketUpgrade || 33 | []) 34 | await hookBeforeSocketUpgrade(context) 35 | 36 | if (server.upgrade(req)) return 37 | } 38 | 39 | if (!context.handler) { 40 | options?.onNotFound?.(req) 41 | 42 | return new Response(null, { status: 404 }) 43 | } 44 | 45 | // Need to await before turn to catch potential error 46 | return await context.executeHandler() 47 | } catch (err: any) { 48 | if (err instanceof Error) options?.onError?.(err) 49 | 50 | if (err instanceof HttpException) return err.response 51 | 52 | return new Response(err.message, { 53 | status: Number(err.code) || 500, 54 | }) 55 | } 56 | }, 57 | }), 58 | stopServer: async (server) => server.stop(), 59 | }) 60 | -------------------------------------------------------------------------------- /packages/wobe/src/adapters/bun/index.ts: -------------------------------------------------------------------------------- 1 | export * from './bun' 2 | export * from './websocket' 3 | -------------------------------------------------------------------------------- /packages/wobe/src/adapters/bun/websocket.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | beforeAll, 6 | afterAll, 7 | mock, 8 | beforeEach, 9 | } from 'bun:test' 10 | import { Wobe } from '../../Wobe' 11 | import getPort from 'get-port' 12 | import { bunWebSocket } from './websocket' 13 | 14 | const waitWebsocketOpened = (ws: WebSocket) => 15 | new Promise((resolve) => { 16 | ws.onopen = resolve 17 | }) 18 | 19 | const waitWebsocketClosed = (ws: WebSocket) => 20 | new Promise((resolve) => { 21 | ws.onclose = resolve 22 | }) 23 | 24 | describe.skipIf(process.env.NODE_TEST === 'true')('Bun - websocket', () => { 25 | 4 26 | const mockOnOpen = mock(() => {}) 27 | const mockOnMessage = mock(() => {}) 28 | const mockOnClose = mock(() => {}) 29 | const mockOnDrain = mock(() => {}) 30 | 31 | let port: number 32 | let wobe: Wobe 33 | 34 | beforeAll(async () => { 35 | port = await getPort() 36 | 37 | wobe = new Wobe() 38 | 39 | wobe.useWebSocket({ 40 | path: '/ws', 41 | onOpen: mockOnOpen, 42 | onMessage: mockOnMessage, 43 | onClose: mockOnClose, 44 | onDrain: mockOnDrain, 45 | }) 46 | 47 | wobe.listen(port) 48 | }) 49 | 50 | afterAll(() => { 51 | wobe.stop() 52 | }) 53 | 54 | beforeEach(() => { 55 | mockOnOpen.mockClear() 56 | mockOnMessage.mockClear() 57 | mockOnClose.mockClear() 58 | mockOnDrain.mockClear() 59 | }) 60 | 61 | it('should call onOpen when the websocket connection is opened', async () => { 62 | const ws = new WebSocket(`ws://localhost:${port}/ws`) 63 | 64 | await waitWebsocketOpened(ws) 65 | 66 | ws.send('Hello') 67 | 68 | expect(mockOnOpen).toHaveBeenCalledTimes(1) 69 | expect(mockOnMessage).toHaveBeenCalledTimes(0) 70 | 71 | ws.close() 72 | }) 73 | 74 | it('should call onMessage when the websocket connection receives a message', async () => { 75 | const ws = new WebSocket(`ws://localhost:${port}/ws`) 76 | 77 | await waitWebsocketOpened(ws) 78 | 79 | ws.send('Hello') 80 | 81 | expect(mockOnMessage).toHaveBeenCalledTimes(1) 82 | expect(mockOnMessage).toHaveBeenCalledWith(ws, 'Hello') 83 | 84 | ws.close() 85 | }) 86 | 87 | it('should call onClose when the websocket connection is closed', async () => { 88 | const ws = new WebSocket(`ws://localhost:${port}/ws`) 89 | 90 | await waitWebsocketOpened(ws) 91 | 92 | ws.close() 93 | 94 | expect(mockOnOpen).toHaveBeenCalledTimes(1) 95 | expect(mockOnClose).toHaveBeenCalledTimes(1) 96 | }) 97 | 98 | it('should not call onOpen if the pathname is wrong', async () => { 99 | const ws = new WebSocket(`ws://localhost:${port}/wrong`) 100 | 101 | await waitWebsocketClosed(ws) 102 | 103 | expect(mockOnOpen).toHaveBeenCalledTimes(0) 104 | expect(mockOnClose).toHaveBeenCalledTimes(1) 105 | }) 106 | 107 | it('should have all wobe websockets options', async () => { 108 | const websocket = bunWebSocket({ 109 | backpressureLimit: 1024, 110 | closeOnBackpressureLimit: true, 111 | idleTimeout: 1000, 112 | maxPayloadLength: 1024, 113 | compression: true, 114 | } as any) 115 | 116 | expect(websocket.perMessageDeflate).toBe(true) 117 | expect(websocket.maxPayloadLength).toBe(1024) 118 | expect(websocket.idleTimeout).toBe(1000) 119 | expect(websocket.backpressureLimit).toBe(1024) 120 | expect(websocket.closeOnBackpressureLimit).toBe(true) 121 | expect(websocket.message).toBeDefined() 122 | expect(websocket.open).toBeDefined() 123 | expect(websocket.close).toBeDefined() 124 | expect(websocket.drain).toBeDefined() 125 | }) 126 | 127 | it('should call all beforeHandler before the websocket upgrade', async () => { 128 | const port2 = await getPort() 129 | 130 | const wobe2 = new Wobe() 131 | 132 | const mockBeforeHandler1 = mock(() => {}) 133 | const mockBeforeHandler2 = mock(() => {}) 134 | 135 | wobe2 136 | .useWebSocket({ 137 | path: '/ws', 138 | beforeWebSocketUpgrade: [ 139 | mockBeforeHandler1, 140 | mockBeforeHandler2, 141 | ], 142 | }) 143 | .listen(port2) 144 | 145 | const ws = new WebSocket(`ws://localhost:${port2}/ws`) 146 | 147 | await waitWebsocketOpened(ws) 148 | 149 | expect(mockBeforeHandler1).toHaveBeenCalledTimes(1) 150 | expect(mockBeforeHandler2).toHaveBeenCalledTimes(1) 151 | 152 | ws.close() 153 | 154 | wobe2.stop() 155 | }) 156 | 157 | it('should not established the socket connection if one of the beforeSocketUpgrade failed', async () => { 158 | const port2 = await getPort() 159 | 160 | const wobe2 = new Wobe() 161 | 162 | const mockBeforeHandler1 = mock(() => { 163 | throw new Error('error') 164 | }) 165 | const mockBeforeHandler2 = mock(() => {}) 166 | 167 | wobe2 168 | .useWebSocket({ 169 | path: '/ws', 170 | beforeWebSocketUpgrade: [ 171 | mockBeforeHandler1, 172 | mockBeforeHandler2, 173 | ], 174 | }) 175 | .listen(port2) 176 | 177 | const ws = new WebSocket(`ws://localhost:${port2}/ws`) 178 | 179 | await waitWebsocketClosed(ws) 180 | 181 | ws.close() 182 | 183 | expect(mockBeforeHandler1).toHaveBeenCalledTimes(1) 184 | expect(mockOnOpen).toHaveBeenCalledTimes(0) 185 | expect(mockBeforeHandler2).toHaveBeenCalledTimes(0) 186 | 187 | wobe2.stop() 188 | }) 189 | }) 190 | -------------------------------------------------------------------------------- /packages/wobe/src/adapters/bun/websocket.ts: -------------------------------------------------------------------------------- 1 | import type { WebSocketHandler } from 'bun' 2 | import type { WobeWebSocket } from '../../Wobe' 3 | 4 | export const bunWebSocket = (webSocket?: WobeWebSocket): WebSocketHandler => { 5 | return { 6 | perMessageDeflate: webSocket?.compression, 7 | maxPayloadLength: webSocket?.maxPayloadLength, 8 | idleTimeout: webSocket?.idleTimeout, 9 | backpressureLimit: webSocket?.backpressureLimit, 10 | closeOnBackpressureLimit: webSocket?.closeOnBackpressureLimit, 11 | message(ws, message) { 12 | webSocket?.onMessage?.(ws, message) 13 | }, 14 | open(ws) { 15 | webSocket?.onOpen?.(ws) 16 | }, 17 | close(ws, code, message) { 18 | webSocket?.onClose?.(ws, code, message) 19 | }, 20 | drain(ws) { 21 | webSocket?.onDrain?.(ws) 22 | }, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/wobe/src/adapters/index.ts: -------------------------------------------------------------------------------- 1 | import type { WobeOptions, WobeWebSocket } from '../Wobe' 2 | import type { RadixTree } from '../router' 3 | 4 | export * from './bun' 5 | export * from './node' 6 | 7 | export interface RuntimeAdapter { 8 | createServer: ( 9 | port: number, 10 | router: RadixTree, 11 | options?: WobeOptions, 12 | webSocket?: WobeWebSocket, 13 | ) => any 14 | 15 | stopServer: (server: any) => void 16 | } 17 | -------------------------------------------------------------------------------- /packages/wobe/src/adapters/node/index.ts: -------------------------------------------------------------------------------- 1 | export * from './node' 2 | -------------------------------------------------------------------------------- /packages/wobe/src/adapters/node/node.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, spyOn, beforeEach } from 'bun:test' 2 | import * as nodeHttp from 'node:http' 3 | import * as nodeHttps from 'node:https' 4 | import { Wobe } from '../../Wobe' 5 | import getPort from 'get-port' 6 | import { HttpException } from '../../HttpException' 7 | import { join } from 'node:path' 8 | import { readFile } from 'node:fs/promises' 9 | 10 | describe.skipIf(process.env.NODE_TEST !== 'true')('Node server', () => { 11 | const spyCreateHttpServer = spyOn(nodeHttp, 'createServer') 12 | const spyCreateHttpsServer = spyOn(nodeHttps, 'createServer') 13 | 14 | beforeEach(() => { 15 | spyCreateHttpServer.mockClear() 16 | spyCreateHttpsServer.mockClear() 17 | }) 18 | 19 | it('should reset the wobe response if context already exist in cache', async () => { 20 | const port = await getPort() 21 | const wobe = new Wobe({ tls: undefined }) 22 | 23 | wobe.get('/hi', async (ctx) => { 24 | if (ctx.res.status === 201) { 25 | throw new HttpException( 26 | new Response('Status should be equal to 200'), 27 | ) 28 | } 29 | 30 | ctx.res.sendText('Hi') 31 | ctx.res.status = 201 32 | }) 33 | 34 | wobe.listen(port) 35 | 36 | await fetch(`http://127.0.0.1:${port}/hi`) 37 | 38 | const res = await fetch(`http://127.0.0.1:${port}/hi`) 39 | 40 | expect(await res.text()).not.toEqual('Status should be equal to 200') 41 | 42 | wobe.stop() 43 | }) 44 | 45 | it('should call create server from node:http if https options is undefined', async () => { 46 | const port = await getPort() 47 | const wobe = new Wobe({ tls: undefined }) 48 | 49 | wobe.get('/hi', (ctx) => ctx.res.sendText('Hi')) 50 | 51 | wobe.listen(port) 52 | 53 | const response = await fetch(`http://127.0.0.1:${port}/hi`) 54 | 55 | expect(response.status).toBe(200) 56 | expect(spyCreateHttpServer).toHaveBeenCalledTimes(1) 57 | expect(spyCreateHttpsServer).toHaveBeenCalledTimes(0) 58 | 59 | wobe.stop() 60 | }) 61 | 62 | it('should call create server from node:https if https options is not undefined', async () => { 63 | const port = await getPort() 64 | 65 | const key = await Bun.file( 66 | `${import.meta.dirname}/../../../fixtures/key.pem`, 67 | ).text() 68 | const cert = await Bun.file( 69 | `${import.meta.dirname}/../../../fixtures/cert.pem`, 70 | ).text() 71 | 72 | const wobe = new Wobe({ 73 | tls: { 74 | key, 75 | cert, 76 | passphrase: 'test', 77 | }, 78 | }) 79 | 80 | wobe.get('/hi', (ctx) => ctx.res.sendText('Hi')) 81 | 82 | wobe.listen(port) 83 | 84 | const response = await fetch(`https://127.0.0.1:${port}/hi`, {}) 85 | 86 | expect(response.status).toBe(200) 87 | expect(spyCreateHttpServer).toHaveBeenCalledTimes(0) 88 | expect(spyCreateHttpsServer).toHaveBeenCalledTimes(1) 89 | expect(spyCreateHttpsServer).toHaveBeenCalledWith( 90 | { key, cert, passphrase: 'test' }, 91 | expect.any(Function), 92 | ) 93 | 94 | wobe.stop() 95 | }) 96 | 97 | it('should serve a binary file correctly', async () => { 98 | const port = await getPort() 99 | const wobe = new Wobe({ tls: undefined }) 100 | 101 | const uploadDirectory = join(__dirname, '../../../fixtures') 102 | const fileName = 'avatar.jpg' 103 | const filePath = join(uploadDirectory, fileName) 104 | 105 | wobe.get('/binary-test', async (ctx) => { 106 | const fileContent = await readFile(filePath) 107 | ctx.res.headers.set('Content-Type', 'image/jpeg') 108 | ctx.res.send(fileContent) 109 | }) 110 | 111 | wobe.listen(port) 112 | 113 | const response = await fetch(`http://127.0.0.1:${port}/binary-test`) 114 | 115 | expect(response.status).toBe(200) 116 | expect(response.headers.get('Content-Type')).toBe('image/jpeg') 117 | 118 | const fileContent = await readFile(filePath) 119 | const responseArrayBuffer = await response.arrayBuffer() 120 | expect( 121 | Buffer.from(responseArrayBuffer).equals(Buffer.from(fileContent)), 122 | ).toBe(true) 123 | 124 | wobe.stop() 125 | }) 126 | }) 127 | -------------------------------------------------------------------------------- /packages/wobe/src/adapters/node/node.ts: -------------------------------------------------------------------------------- 1 | import { createServer as createHttpServer } from 'node:http' 2 | import { createServer as createHttpsServer } from 'node:https' 3 | import { HttpException } from '../../HttpException' 4 | import { Context } from '../../Context' 5 | import type { RuntimeAdapter } from '..' 6 | import type { RadixTree } from '../../router' 7 | import type { WobeOptions } from '../../Wobe' 8 | 9 | const transformResponseInstanceToValidResponse = async (response: Response) => { 10 | const headers: Record = {} 11 | 12 | response.headers.forEach((value, name) => { 13 | headers[name] = value 14 | }) 15 | 16 | const contentType = response.headers.get('content-type') 17 | 18 | if (contentType === 'appplication/json') 19 | return { headers, body: await response.json() } 20 | 21 | if (contentType === 'text/plain') 22 | return { headers, body: await response.text() } 23 | 24 | const arrayBuffer = await response.arrayBuffer() 25 | return { headers, body: Buffer.from(arrayBuffer) } 26 | } 27 | 28 | export const NodeAdapter = (): RuntimeAdapter => ({ 29 | createServer: (port: number, router: RadixTree, options?: WobeOptions) => { 30 | // @ts-expect-error 31 | const createServer: typeof createHttpsServer = options?.tls 32 | ? createHttpsServer 33 | : createHttpServer 34 | const certificateObject = { ...options?.tls } || {} 35 | 36 | return createServer(certificateObject, async (req, res) => { 37 | const url = `http://${req.headers.host}${req.url}` 38 | 39 | let body = '' 40 | req.on('data', (chunk) => { 41 | body += chunk 42 | }) 43 | 44 | req.on('end', async () => { 45 | try { 46 | const request = new Request(url, { 47 | method: req.method, 48 | headers: req.headers as any, 49 | body: 50 | req.method !== 'GET' && req.method !== 'HEAD' 51 | ? body 52 | : undefined, 53 | }) 54 | 55 | const context = new Context(request, router) 56 | 57 | if (!context.handler) { 58 | options?.onNotFound?.(context.request) 59 | 60 | res.writeHead(404) 61 | res.end() 62 | return 63 | } 64 | 65 | context.getIpAdress = () => req.socket.remoteAddress || '' 66 | 67 | const response = await context.executeHandler() 68 | 69 | const { headers, body: responseBody } = 70 | await transformResponseInstanceToValidResponse(response) 71 | 72 | res.writeHead( 73 | response.status || 404, 74 | response.statusText, 75 | headers, 76 | ) 77 | 78 | res.write(responseBody) 79 | } catch (err: any) { 80 | if (err instanceof Error) options?.onError?.(err) 81 | 82 | if (!(err instanceof HttpException)) { 83 | res.writeHead(Number(err.code) || 500) 84 | res.write(err.message) 85 | 86 | res.end() 87 | return 88 | } 89 | 90 | const { headers, body: responseBody } = 91 | await transformResponseInstanceToValidResponse( 92 | err.response, 93 | ) 94 | 95 | res.writeHead( 96 | err.response.status || 500, 97 | err.response.statusText, 98 | headers, 99 | ) 100 | 101 | res.write(responseBody) 102 | } 103 | 104 | res.end() 105 | }) 106 | }).listen(port, options?.hostname) 107 | }, 108 | stopServer: (server: any) => server.close(), 109 | }) 110 | -------------------------------------------------------------------------------- /packages/wobe/src/hooks/bearerAuth.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'bun:test' 2 | import { bearerAuth } from './bearerAuth' 3 | import { Context } from '../Context' 4 | 5 | describe('BearerAuth', () => { 6 | it('should authorize the request if the token is valid', () => { 7 | const request = new Request('http://localhost:3000/test', { 8 | headers: { 9 | Authorization: 'Bearer 123', 10 | }, 11 | }) 12 | 13 | const handler = bearerAuth({ 14 | token: '123', 15 | }) 16 | 17 | const context = new Context(request) 18 | 19 | expect(() => handler(context)).not.toThrow() 20 | }) 21 | 22 | it('should authorize the request if there is not space between prefix and token', () => { 23 | const request = new Request('http://localhost:3000/test', { 24 | headers: { 25 | Authorization: 'Bearer123', 26 | }, 27 | }) 28 | 29 | const handler = bearerAuth({ 30 | token: '123', 31 | }) 32 | 33 | const context = new Context(request) 34 | 35 | expect(() => handler(context)).not.toThrow() 36 | }) 37 | 38 | it('should authorize the request if there is a custom hash function', () => { 39 | const request = new Request('http://localhost:3000/test', { 40 | headers: { 41 | Authorization: 'Bearer 123', 42 | }, 43 | }) 44 | 45 | const handler = bearerAuth({ 46 | token: '123', 47 | // Fake hash function 48 | hashFunction: (token) => token, 49 | }) 50 | 51 | const context = new Context(request) 52 | 53 | expect(() => handler(context)).not.toThrow() 54 | }) 55 | 56 | it('should not authorize the request if the token is invalid', () => { 57 | const request = new Request('http://localhost:3000/test', { 58 | headers: { 59 | Authorization: 'Bearer invalid token', 60 | }, 61 | }) 62 | 63 | const handler = bearerAuth({ 64 | token: '123', 65 | }) 66 | 67 | const context = new Context(request) 68 | 69 | expect(() => handler(context)).toThrow() 70 | }) 71 | 72 | it('should not authorize the request if the authorization is missing', () => { 73 | const request = new Request('http://localhost:3000/test', {}) 74 | 75 | const handler = bearerAuth({ 76 | token: '123', 77 | }) 78 | 79 | const context = new Context(request) 80 | 81 | expect(() => handler(context)).toThrow() 82 | }) 83 | 84 | it('should not authorize the request if the prefix is bad', () => { 85 | const request = new Request('http://localhost:3000/test', { 86 | headers: { 87 | Authorization: 'InvalidPrefix invalid token', 88 | }, 89 | }) 90 | 91 | const handler = bearerAuth({ 92 | token: '123', 93 | }) 94 | 95 | const context = new Context(request) 96 | 97 | expect(() => handler(context)).toThrow() 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /packages/wobe/src/hooks/bearerAuth.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'node:crypto' 2 | import { HttpException } from '../HttpException' 3 | import type { WobeHandler } from '../Wobe' 4 | 5 | export interface BearerAuthOptions { 6 | token: string 7 | realm?: string 8 | hashFunction?: (token: string) => string 9 | } 10 | 11 | const prefix = 'Bearer' 12 | 13 | const defaultHash = (token: string) => 14 | createHash('sha256').update(token).digest('base64') 15 | 16 | /** 17 | * bearerAuth is a hook that checks if the request has a valid Bearer token 18 | */ 19 | export const bearerAuth = ({ 20 | token, 21 | hashFunction = defaultHash, 22 | realm = '', 23 | }: BearerAuthOptions): WobeHandler => { 24 | return (ctx) => { 25 | const requestAuthorization = ctx.request.headers.get('Authorization') 26 | 27 | if (!requestAuthorization) 28 | throw new HttpException( 29 | new Response('Unauthorized', { 30 | status: 401, 31 | headers: { 32 | 'WWW-Authenticate': `${prefix} realm="${realm}", error="invalid_request"`, 33 | }, 34 | }), 35 | ) 36 | 37 | if (!requestAuthorization.startsWith(prefix)) 38 | throw new HttpException( 39 | new Response('Unauthorized', { 40 | status: 401, 41 | headers: { 42 | 'WWW-Authenticate': `${prefix} realm="${realm}", error="invalid_request"`, 43 | }, 44 | }), 45 | ) 46 | 47 | const requestToken = requestAuthorization.slice(prefix.length).trim() 48 | 49 | const hashedRequestToken = hashFunction(requestToken) 50 | const hashedToken = hashFunction(token) 51 | 52 | if (hashedToken !== hashedRequestToken) 53 | throw new HttpException( 54 | new Response('Unauthorized', { 55 | status: 401, 56 | headers: { 57 | 'WWW-Authenticate': `${prefix} realm="${realm}", error="invalid_token"`, 58 | }, 59 | }), 60 | ) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/wobe/src/hooks/bodyLimit.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'bun:test' 2 | import { bodyLimit } from './bodyLimit' 3 | import { Context } from '../Context' 4 | 5 | describe('bodyLimit', () => { 6 | const invalidRequest = new Request('http://localhost:3000/test', { 7 | headers: { 8 | 'Content-Length': '1000', // 1000 bytes 9 | }, 10 | }) 11 | 12 | const validRequest = new Request('http://localhost:3000/test', { 13 | headers: { 14 | 'Content-Length': '400', // 400 bytes 15 | }, 16 | }) 17 | 18 | it('should not throw an error if the body is not too large', async () => { 19 | const handler = bodyLimit({ 20 | maxSize: 500, // 500 bytes 21 | }) 22 | 23 | const context = new Context(validRequest) 24 | 25 | expect(() => handler(context)).not.toThrow() 26 | }) 27 | 28 | it('should throw an error if the body is too large', async () => { 29 | const handler = bodyLimit({ 30 | maxSize: 500, // 500 bytes 31 | }) 32 | 33 | const context = new Context(invalidRequest) 34 | 35 | expect(() => handler(context)).toThrow() 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /packages/wobe/src/hooks/bodyLimit.ts: -------------------------------------------------------------------------------- 1 | import { HttpException } from '../HttpException' 2 | import type { WobeHandler } from '../Wobe' 3 | 4 | interface BodyLimitOptions { 5 | maxSize: number 6 | } 7 | 8 | /** 9 | * bodyLimit is a hook that checks if the request body is too large 10 | */ 11 | export const bodyLimit = (options: BodyLimitOptions): WobeHandler => { 12 | return (ctx) => { 13 | // The content-length header is not always present 14 | if (ctx.request.headers.get('Content-Length')) { 15 | const contentLength = Number( 16 | ctx.request.headers.get('Content-Length') || 0, 17 | ) 18 | 19 | if (contentLength > options.maxSize) 20 | throw new HttpException( 21 | new Response('Payload too large', { status: 413 }), 22 | ) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/wobe/src/hooks/cors.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'bun:test' 2 | import { cors } from './cors' 3 | import { Context } from '../Context' 4 | 5 | describe('Cors hook', () => { 6 | const request = new Request('http://localhost:3000/test') 7 | const optionsRequest = new Request('http://localhost:3000/test', { 8 | method: 'OPTIONS', 9 | }) 10 | 11 | it('should allow origin to * when to origin is specifid', async () => { 12 | const handler = cors() 13 | 14 | const context = new Context(request) 15 | 16 | handler(context) 17 | 18 | expect(context.res.headers?.get('Access-Control-Allow-Origin')).toBe( 19 | '*', 20 | ) 21 | expect(context.res.headers?.get('Vary')).toBeNull() 22 | }) 23 | 24 | it('should set origin to Vary if the origin is !== *', async () => { 25 | const handler = cors({ 26 | origin: 'http://localhost:3000', 27 | }) 28 | 29 | const context = new Context(request) 30 | 31 | handler(context) 32 | 33 | expect(context.res.headers?.get('Vary')).toBe('Origin') 34 | }) 35 | 36 | it('should not set origin to Vary if the origin is === *', async () => { 37 | const handler = cors() 38 | 39 | const context = new Context(request) 40 | 41 | handler(context) 42 | 43 | expect(context.res.headers?.get('Vary')).toBeNull() 44 | }) 45 | 46 | it('should correctly allow origin with simple string', async () => { 47 | const handler = cors({ 48 | origin: 'http://localhost:3000', 49 | }) 50 | 51 | const context = new Context(request) 52 | 53 | handler(context) 54 | 55 | expect(context.res.headers?.get('Access-Control-Allow-Origin')).toBe( 56 | 'http://localhost:3000', 57 | ) 58 | }) 59 | 60 | it('should correctly allow origin with an array', async () => { 61 | const handler = cors({ 62 | origin: ['http://localhost:3000', 'http://localhost:3001'], 63 | }) 64 | 65 | // With no origin header 66 | const context = new Context(request) 67 | 68 | handler(context) 69 | 70 | expect(context.res.headers?.get('Access-Control-Allow-Origin')).toBe( 71 | 'http://localhost:3000', 72 | ) 73 | 74 | const context2 = new Context( 75 | new Request('http://localhost:3000/test', { 76 | headers: { 77 | origin: 'http://localhost:3001', 78 | }, 79 | }), 80 | ) 81 | 82 | // With an origin header 83 | handler(context2) 84 | 85 | expect(context2.res.headers?.get('Access-Control-Allow-Origin')).toBe( 86 | 'http://localhost:3001', 87 | ) 88 | }) 89 | 90 | it('should correctly allow origin with a function', async () => { 91 | const handler = cors({ 92 | origin: (origin) => { 93 | if (origin === 'http://localhost:3000') 94 | return 'http://localhost:3000' 95 | 96 | return 'http://localhost:3001' 97 | }, 98 | }) 99 | 100 | // With no origin header 101 | const context = new Context(request) 102 | 103 | handler(context) 104 | 105 | expect(context.res.headers?.get('Access-Control-Allow-Origin')).toBe( 106 | 'http://localhost:3001', 107 | ) 108 | 109 | const context2 = new Context( 110 | new Request('http://localhost:3000/test', { 111 | headers: { 112 | origin: 'http://localhost:3000', 113 | }, 114 | }), 115 | ) 116 | 117 | // With an origin header 118 | handler(context2) 119 | 120 | expect(context2.res.headers?.get('Access-Control-Allow-Origin')).toBe( 121 | 'http://localhost:3000', 122 | ) 123 | }) 124 | 125 | it('should allow credentials', async () => { 126 | const handler = cors({ 127 | origin: 'http://localhost:3000', 128 | credentials: true, 129 | }) 130 | 131 | const context = new Context(request) 132 | 133 | handler(context) 134 | 135 | expect( 136 | context.res.headers?.get('Access-Control-Allow-Credentials'), 137 | ).toBe('true') 138 | }) 139 | 140 | it('should not allow credentials', async () => { 141 | const handler = cors({ 142 | origin: 'http://localhost:3000', 143 | credentials: false, 144 | }) 145 | 146 | const context = new Context(request) 147 | 148 | handler(context) 149 | 150 | expect( 151 | context.res.headers?.get('Access-Control-Allow-Credentials'), 152 | ).toBeNull() 153 | }) 154 | 155 | it('should control expose headers', async () => { 156 | const handlerWithExposeHeaders = cors({ 157 | origin: 'http://localhost:3000', 158 | exposeHeaders: ['X-Test'], 159 | }) 160 | 161 | const context = new Context(request) 162 | 163 | handlerWithExposeHeaders(context) 164 | 165 | expect(context.res.headers?.get('Access-Control-Expose-Headers')).toBe( 166 | 'X-Test', 167 | ) 168 | }) 169 | 170 | it('should have expose headers to null when no expose headers is defined', async () => { 171 | const handlerWithoutExposeHeaders = cors({ 172 | origin: 'http://localhost:3000', 173 | }) 174 | 175 | const context = new Context(request) 176 | 177 | handlerWithoutExposeHeaders(context) 178 | 179 | expect( 180 | context.res.headers?.get('Access-Control-Expose-Headers'), 181 | ).toBeNull() 182 | }) 183 | 184 | it('should not set max age for others request than OPTIONS', async () => { 185 | const handlerWithMaxAge = cors({ 186 | origin: 'http://localhost:3000', 187 | maxAge: 100, 188 | }) 189 | 190 | const context = new Context(request) 191 | 192 | handlerWithMaxAge(context) 193 | 194 | expect(context.res.headers?.get('Access-Control-Max-Age')).toBeNull() 195 | }) 196 | 197 | it('should set max age for OPTIONS request if defined', async () => { 198 | const handlerWithMaxAge = cors({ 199 | origin: 'http://localhost:3000', 200 | maxAge: 100, 201 | }) 202 | 203 | const context = new Context(optionsRequest) 204 | 205 | handlerWithMaxAge(context) 206 | 207 | expect(context.res.headers?.get('Access-Control-Max-Age')).toBe('100') 208 | }) 209 | 210 | it('should not set allow methods for others requests than OPTIONS', async () => { 211 | const handlerWithAllowMethods = cors({ 212 | origin: 'http://localhost:3000', 213 | allowMethods: ['GET', 'POST'], 214 | }) 215 | 216 | const context = new Context(request) 217 | 218 | handlerWithAllowMethods(context) 219 | 220 | expect( 221 | context.res.headers?.get('Access-Control-Allow-Methods'), 222 | ).toBeNull() 223 | }) 224 | 225 | it('should set allow methods for OPTIONS requests', async () => { 226 | const handlerWithAllowMethods = cors({ 227 | origin: 'http://localhost:3000', 228 | allowMethods: ['GET', 'POST'], 229 | }) 230 | 231 | const context = new Context(optionsRequest) 232 | 233 | handlerWithAllowMethods(context) 234 | 235 | expect(context.res.headers?.get('Access-Control-Allow-Methods')).toBe( 236 | 'GET,POST', 237 | ) 238 | }) 239 | 240 | it('should set allow headers with an allow headers on OPTIONS requests', async () => { 241 | const handlerWithAllowMethods = cors({ 242 | origin: 'http://localhost:3000', 243 | allowHeaders: ['X-Test'], 244 | }) 245 | 246 | const context = new Context(optionsRequest) 247 | 248 | handlerWithAllowMethods(context) 249 | 250 | expect(context.res.headers?.get('Access-Control-Allow-Headers')).toBe( 251 | 'X-Test', 252 | ) 253 | expect(context.res.headers?.get('Vary')).toBe( 254 | 'Origin, Access-Control-Request-Headers', 255 | ) 256 | }) 257 | 258 | it('should set allow headers without an allow headers on OPTIONS request', async () => { 259 | const customRequest = new Request('http://localhost:3000/test', { 260 | method: 'OPTIONS', 261 | headers: { 262 | 'Access-Control-Request-Headers': 'X-Test', 263 | }, 264 | }) 265 | 266 | const handlerWithAllowMethods = cors({ 267 | origin: 'http://localhost:3000', 268 | }) 269 | 270 | const context = new Context(customRequest) 271 | 272 | handlerWithAllowMethods(context) 273 | 274 | expect(context.res.headers?.get('Access-Control-Allow-Headers')).toBe( 275 | 'X-Test', 276 | ) 277 | 278 | expect(context.res.headers?.get('Vary')).toBe( 279 | 'Origin, Access-Control-Request-Headers', 280 | ) 281 | }) 282 | 283 | it('should delete Content-Lenght and Content-type on OPTIONS request', async () => { 284 | const handlerWithAllowMethods = cors({ 285 | origin: 'http://localhost:3000', 286 | }) 287 | 288 | const context = new Context(optionsRequest) 289 | 290 | context.res.headers.set('Content-Length', '100') 291 | context.res.headers.set('Content-Type', 'application/json') 292 | 293 | handlerWithAllowMethods(context) 294 | 295 | expect(context.res.headers?.get('Content-Length')).toBeNull() 296 | expect(context.res.headers?.get('Content-Type')).toBeNull() 297 | }) 298 | 299 | it('should return response on requests OPTIONS', async () => { 300 | const handlerWithAllowMethods = cors({ 301 | origin: 'http://localhost:3000', 302 | }) 303 | 304 | const context = new Context(optionsRequest) 305 | 306 | await handlerWithAllowMethods(context) 307 | 308 | expect(context.res.status).toBe(204) 309 | expect(context.res.statusText).toBe('OK') 310 | }) 311 | }) 312 | -------------------------------------------------------------------------------- /packages/wobe/src/hooks/cors.ts: -------------------------------------------------------------------------------- 1 | import type { WobeHandler } from '../Wobe' 2 | 3 | type Origin = 4 | | string 5 | | string[] 6 | | ((origin: string) => string | undefined | null) 7 | 8 | export interface CorsOptions { 9 | origin: Origin 10 | allowMethods?: string[] 11 | allowHeaders?: string[] 12 | maxAge?: number 13 | credentials?: boolean 14 | exposeHeaders?: string[] 15 | } 16 | 17 | /** 18 | * cors is a hook that adds the necessary headers to enable CORS 19 | */ 20 | export const cors = (options?: CorsOptions): WobeHandler => { 21 | const defaults: CorsOptions = { 22 | origin: '*', 23 | allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], 24 | allowHeaders: [], 25 | exposeHeaders: [], 26 | } 27 | 28 | const opts = { 29 | ...defaults, 30 | ...options, 31 | } 32 | 33 | return (ctx) => { 34 | const requestOrigin = ctx.request.headers.get('origin') || '' 35 | 36 | const getAllowOrigin = (origin: Origin) => { 37 | if (typeof origin === 'string') return origin 38 | 39 | if (typeof origin === 'function') return origin(requestOrigin) 40 | 41 | return origin.includes(requestOrigin) ? requestOrigin : origin[0] 42 | } 43 | 44 | const allowOrigin = getAllowOrigin(opts.origin) 45 | 46 | if (allowOrigin) 47 | ctx.res.headers.set('Access-Control-Allow-Origin', allowOrigin) 48 | 49 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin 50 | if (opts.origin !== '*') ctx.res.headers.set('Vary', 'Origin') 51 | 52 | if (opts.credentials) 53 | ctx.res.headers.set('Access-Control-Allow-Credentials', 'true') 54 | 55 | if (opts.exposeHeaders?.length) 56 | ctx.res.headers.set( 57 | 'Access-Control-Expose-Headers', 58 | opts.exposeHeaders.join(','), 59 | ) 60 | 61 | if (ctx.request.method === 'OPTIONS') { 62 | if (opts.maxAge) 63 | ctx.res.headers.set( 64 | 'Access-Control-Max-Age', 65 | opts.maxAge.toString(), 66 | ) 67 | 68 | if (opts.allowMethods?.length) 69 | ctx.res.headers.set( 70 | 'Access-Control-Allow-Methods', 71 | opts.allowMethods.join(','), 72 | ) 73 | 74 | const headers = opts.allowHeaders?.length 75 | ? opts.allowHeaders 76 | : ctx.request.headers 77 | .get('Access-Control-Request-Headers') 78 | ?.split(/\s*,\s*/) 79 | 80 | if (headers?.length) { 81 | ctx.res.headers.set( 82 | 'Access-Control-Allow-Headers', 83 | headers.join(','), 84 | ) 85 | ctx.res.headers?.append( 86 | 'Vary', 87 | 'Access-Control-Request-Headers', 88 | ) 89 | } 90 | 91 | ctx.res.headers?.delete('Content-Length') 92 | ctx.res.headers?.delete('Content-Type') 93 | 94 | ctx.res.status = 204 95 | ctx.res.statusText = 'OK' 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /packages/wobe/src/hooks/csrf.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'bun:test' 2 | import { csrf } from './csrf' 3 | import { Context } from '../Context' 4 | 5 | describe('Csrf hook', () => { 6 | it('should not block requests with a valid origin (string)', () => { 7 | const request = new Request('http://localhost:3000/test', { 8 | headers: { 9 | origin: 'http://localhost:3000', 10 | }, 11 | }) 12 | 13 | const handler = csrf({ origin: 'http://localhost:3000' }) 14 | 15 | const context = new Context(request) 16 | context.getIpAdress = () => 'ipAdress' 17 | 18 | expect(() => handler(context)).not.toThrow() 19 | }) 20 | 21 | it('should not block requests with a valid origin (array)', () => { 22 | const request = new Request('http://localhost:3000/test', { 23 | headers: { 24 | origin: 'http://localhost:3000', 25 | }, 26 | }) 27 | 28 | const handler = csrf({ 29 | origin: ['http://localhost:3001', 'http://localhost:3000'], 30 | }) 31 | 32 | const context = new Context(request) 33 | context.getIpAdress = () => 'ipAdress' 34 | 35 | expect(() => handler(context)).not.toThrow('CSRF: Invalid origin') 36 | }) 37 | 38 | it('should not block requests with a valid origin (function)', () => { 39 | const request = new Request('http://localhost:3000/test', { 40 | headers: { 41 | origin: 'http://localhost:3000', 42 | }, 43 | }) 44 | 45 | const handler = csrf({ 46 | origin: (origin) => origin === 'http://localhost:3000', 47 | }) 48 | 49 | const context = new Context(request) 50 | context.getIpAdress = () => 'ipAdress' 51 | 52 | expect(() => handler(context)).not.toThrow() 53 | }) 54 | 55 | it('should block requests with an invalid origin (string)', async () => { 56 | const request = new Request('http://localhost:3000/test', {}) 57 | 58 | const handler = csrf({ origin: 'http://localhost:3000' }) 59 | 60 | const context = new Context(request) 61 | context.getIpAdress = () => 'ipAdress' 62 | 63 | expect(() => handler(context)).toThrow() 64 | }) 65 | 66 | it('should block requests with an invalid origin (array)', () => { 67 | const request = new Request('http://localhost:3000/test', { 68 | headers: { 69 | origin: 'http://localhost:3001', 70 | }, 71 | }) 72 | 73 | const handler = csrf({ 74 | origin: ['http://localhost:3000', 'http://localhost:3002'], 75 | }) 76 | 77 | const context = new Context(request) 78 | context.getIpAdress = () => 'ipAdress' 79 | 80 | expect(() => handler(context)).toThrow() 81 | }) 82 | 83 | it('should block requests with an invalid origin (function)', () => { 84 | const request = new Request('http://localhost:3000/test', { 85 | headers: { 86 | origin: 'http://localhost:3001', 87 | }, 88 | }) 89 | 90 | const context = new Context(request) 91 | context.getIpAdress = () => 'ipAdress' 92 | 93 | const handler = csrf({ 94 | origin: (origin) => origin === 'http://localhost:3000', 95 | }) 96 | 97 | expect(() => handler(context)).toThrow() 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /packages/wobe/src/hooks/csrf.ts: -------------------------------------------------------------------------------- 1 | import type { WobeHandler } from '../Wobe' 2 | import { HttpException } from '../HttpException' 3 | 4 | type Origin = string | string[] | ((origin: string) => boolean) 5 | 6 | export interface CsrfOptions { 7 | origin: Origin 8 | } 9 | 10 | const isSameOrigin = (optsOrigin: Origin, requestOrigin: string) => { 11 | if (typeof optsOrigin === 'string') return optsOrigin === requestOrigin 12 | 13 | if (typeof optsOrigin === 'function') return optsOrigin(requestOrigin) 14 | 15 | return optsOrigin.includes(requestOrigin) 16 | } 17 | 18 | // Reliability on these headers comes from the fact that they cannot be altered programmatically 19 | // as they fall under forbidden headers list, meaning that only the browser can set them. 20 | // https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#using-standard-headers-to-verify-origin 21 | 22 | /** 23 | * csrf is a hook that checks if the request has a valid CSRF token 24 | */ 25 | export const csrf = (options: CsrfOptions): WobeHandler => { 26 | return (ctx) => { 27 | const requestOrigin = ctx.request.headers.get('origin') || '' 28 | 29 | if (!isSameOrigin(options.origin, requestOrigin)) 30 | throw new HttpException( 31 | new Response('CSRF: Invalid origin', { status: 403 }), 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/wobe/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cors' 2 | export * from './csrf' 3 | export * from './secureHeaders' 4 | export * from './bodyLimit' 5 | export * from './bearerAuth' 6 | export * from './logger' 7 | export * from './rateLimit' 8 | export * from './uploadDirectory' 9 | -------------------------------------------------------------------------------- /packages/wobe/src/hooks/logger.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, mock, beforeEach } from 'bun:test' 2 | import { logger } from './logger' 3 | import { Context } from '../Context' 4 | 5 | describe('logger', () => { 6 | const mockLoggerFunction = mock(() => {}) 7 | 8 | beforeEach(() => { 9 | mockLoggerFunction.mockClear() 10 | }) 11 | 12 | it('should log before handler', () => { 13 | const request = new Request('http://localhost:3000/test', { 14 | headers: { 15 | Authorization: 'Bearer 123', 16 | }, 17 | }) 18 | 19 | const handler = logger({ 20 | loggerFunction: mockLoggerFunction, 21 | }) 22 | 23 | const context = new Context(request) 24 | 25 | const now = Date.now() 26 | 27 | handler(context) 28 | 29 | expect(mockLoggerFunction).toHaveBeenCalledTimes(1) 30 | expect(mockLoggerFunction).toHaveBeenCalledWith({ 31 | beforeHandler: true, 32 | method: 'GET', 33 | url: 'http://localhost:3000/test', 34 | }) 35 | 36 | expect(context.requestStartTimeInMs).toBeGreaterThanOrEqual(now) 37 | }) 38 | 39 | it('should log after handler', () => { 40 | const request = new Request('http://localhost:3000/test', { 41 | headers: { 42 | Authorization: 'Bearer 123', 43 | }, 44 | }) 45 | 46 | const handler = logger({ 47 | loggerFunction: mockLoggerFunction, 48 | }) 49 | 50 | const context = new Context(request) 51 | 52 | // We begin to handle the beforeHandler to get the requestStartTimeInMs 53 | handler(context) 54 | 55 | context.state = 'afterHandler' 56 | 57 | handler(context) 58 | 59 | expect(mockLoggerFunction).toHaveBeenCalledTimes(2) 60 | expect(mockLoggerFunction).toHaveBeenNthCalledWith(2, { 61 | beforeHandler: false, 62 | method: 'GET', 63 | url: 'http://localhost:3000/test', 64 | status: 200, 65 | requestStartTimeInMs: expect.any(Number), 66 | }) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /packages/wobe/src/hooks/logger.ts: -------------------------------------------------------------------------------- 1 | import type { HttpMethod, WobeHandler } from '../Wobe' 2 | 3 | export interface LoggerFunctionOptions { 4 | beforeHandler: boolean 5 | method: HttpMethod 6 | url: string 7 | status?: number 8 | requestStartTimeInMs?: number 9 | } 10 | 11 | export interface LoggerOptions { 12 | loggerFunction: (options: LoggerFunctionOptions) => void 13 | } 14 | 15 | const defaultLoggerFunction = ({ 16 | beforeHandler, 17 | method, 18 | url, 19 | status, 20 | requestStartTimeInMs, 21 | }: LoggerFunctionOptions) => { 22 | console.log( 23 | `[${ 24 | beforeHandler ? 'Before handler' : 'After handler' 25 | }] [${method}] ${url}${status ? ' (status:' + status + ')' : ''}${ 26 | requestStartTimeInMs 27 | ? '[' + (Date.now() - requestStartTimeInMs) + 'ms]' 28 | : '' 29 | }`, 30 | ) 31 | } 32 | 33 | /** 34 | * logger is a hook that logs the request method, url, and status code 35 | */ 36 | export const logger = ( 37 | { loggerFunction }: LoggerOptions = { 38 | loggerFunction: defaultLoggerFunction, 39 | }, 40 | ): WobeHandler => { 41 | return (ctx) => { 42 | const { state, request } = ctx 43 | 44 | if (state === 'beforeHandler') { 45 | loggerFunction({ 46 | beforeHandler: true, 47 | method: request.method as HttpMethod, 48 | url: request.url, 49 | }) 50 | ctx.requestStartTimeInMs = Date.now() 51 | } 52 | 53 | if (state === 'afterHandler') { 54 | loggerFunction({ 55 | beforeHandler: false, 56 | method: request.method as HttpMethod, 57 | url: request.url, 58 | status: ctx.res.status, 59 | requestStartTimeInMs: ctx.requestStartTimeInMs, 60 | }) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/wobe/src/hooks/rateLimit.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, mock } from 'bun:test' 2 | import { rateLimit } from './rateLimit' 3 | import { Context } from '../Context' 4 | 5 | // @ts-expect-error 6 | const mockHttpExceptionConstructor = mock((response: Response) => {}) 7 | 8 | mock.module('../HttpException', () => ({ 9 | HttpException: class HttpException { 10 | constructor(response: Response) { 11 | mockHttpExceptionConstructor(response) 12 | } 13 | }, 14 | })) 15 | 16 | describe('rateLimit', () => { 17 | it('should authorize the number of request - 1 by second', () => { 18 | const request = new Request('http://localhost:3000/test') 19 | 20 | const handler = rateLimit({ 21 | interval: 1000, 22 | numberOfRequests: 100, 23 | }) 24 | 25 | const context = new Context(request) 26 | context.getIpAdress = () => 'ipAdress' 27 | 28 | for (let i = 0; i < 100; i++) handler(context) 29 | 30 | expect(() => handler(context)).toThrow() 31 | }) 32 | 33 | it('should limit the number of request by second', () => { 34 | const request = new Request('http://localhost:3000/test') 35 | 36 | const handler = rateLimit({ 37 | interval: 100, 38 | numberOfRequests: 2, 39 | }) 40 | 41 | const context = new Context(request) 42 | context.getIpAdress = () => 'ipAdress' 43 | 44 | handler(context) 45 | handler(context) 46 | 47 | expect(() => handler(context)).toThrow() 48 | }) 49 | 50 | it('should limit the number of request by second', () => { 51 | const request = new Request('http://localhost:3000/test') 52 | 53 | const handler = rateLimit({ 54 | interval: 100, 55 | numberOfRequests: 2, 56 | }) 57 | 58 | const context = new Context(request) 59 | context.getIpAdress = () => 'ipAdress' 60 | 61 | handler(context) 62 | handler(context) 63 | 64 | expect(() => handler(context)).toThrow() 65 | }) 66 | 67 | it('should clear the number of request each interval', async () => { 68 | const request = new Request('http://localhost:3000/test') 69 | 70 | const handler = rateLimit({ 71 | interval: 100, 72 | numberOfRequests: 2, 73 | }) 74 | 75 | const context = new Context(request) 76 | context.getIpAdress = () => 'ipAdress' 77 | 78 | handler(context) 79 | handler(context) 80 | 81 | await new Promise((resolve) => setTimeout(resolve, 100)) 82 | 83 | expect(() => handler(context)).not.toThrow() 84 | }) 85 | 86 | it('should authorize 2 requests by user', () => { 87 | const request = new Request('http://localhost:3000/test') 88 | 89 | const handler = rateLimit({ 90 | interval: 1000, 91 | numberOfRequests: 2, 92 | }) 93 | 94 | const context = new Context(request) 95 | 96 | handler(context) 97 | handler(context) 98 | 99 | expect(() => handler(context)).toThrow() 100 | 101 | context.getIpAdress = () => 'ipAdress2' 102 | 103 | handler(context) 104 | handler(context) 105 | 106 | expect(() => handler(context)).toThrow() 107 | }) 108 | 109 | it('should throw the correct http error', async () => { 110 | const request = new Request('http://localhost:3000/test') 111 | 112 | const handler = rateLimit({ 113 | interval: 1000, 114 | numberOfRequests: 2, 115 | }) 116 | 117 | const context = new Context(request) 118 | 119 | handler(context) 120 | handler(context) 121 | 122 | expect(() => handler(context)).toThrow() 123 | 124 | const responseFromHttpException = mockHttpExceptionConstructor.mock 125 | .calls[0][0] as Response 126 | 127 | expect(responseFromHttpException.status).toBe(429) 128 | expect(await responseFromHttpException.text()).toBe( 129 | 'Rate limit exceeded', 130 | ) 131 | }) 132 | }) 133 | -------------------------------------------------------------------------------- /packages/wobe/src/hooks/rateLimit.ts: -------------------------------------------------------------------------------- 1 | import { HttpException } from '../HttpException' 2 | import type { WobeHandler } from '../Wobe' 3 | import { WobeStore } from '../tools' 4 | 5 | export interface RateLimitOptions { 6 | interval: number 7 | numberOfRequests: number 8 | } 9 | 10 | /** 11 | * rateLimit is a hook that limits the number of requests per interval 12 | */ 13 | export const rateLimit = ({ 14 | interval, 15 | numberOfRequests, 16 | }: RateLimitOptions): WobeHandler => { 17 | const store = new WobeStore({ 18 | interval, 19 | }) 20 | 21 | return (ctx) => { 22 | const ipAdress = ctx.getIpAdress() 23 | 24 | const userRequests = store.get(ipAdress) || 0 25 | 26 | if (userRequests >= numberOfRequests) 27 | throw new HttpException( 28 | new Response('Rate limit exceeded', { status: 429 }), 29 | ) 30 | 31 | store.set(ipAdress, userRequests + 1) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/wobe/src/hooks/secureHeaders.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'bun:test' 2 | import { secureHeaders } from './secureHeaders' 3 | import { Context } from '../Context' 4 | 5 | describe('Secure headers', () => { 6 | it('should set Content-Security-Policy', () => { 7 | const request = new Request('http://localhost:3000/test', { 8 | headers: { 9 | origin: 'http://localhost:3000', 10 | }, 11 | }) 12 | 13 | const handler = secureHeaders({ 14 | contentSecurityPolicy: { 15 | 'default-src': ["'self'"], 16 | 'report-to': 'endpoint-5', 17 | }, 18 | }) 19 | 20 | const context = new Context(request) 21 | 22 | handler(context) 23 | 24 | expect(context.res.headers.get('Content-Security-Policy')).toEqual( 25 | "default-src 'self'; report-to endpoint-5", 26 | ) 27 | }) 28 | 29 | it('should set Cross-Origin-Embedder-Policy', () => { 30 | const request = new Request('http://localhost:3000/test', { 31 | headers: { 32 | origin: 'http://localhost:3000', 33 | }, 34 | }) 35 | 36 | const handler = secureHeaders({ 37 | crossOriginEmbedderPolicy: 'random-value', 38 | }) 39 | 40 | const context = new Context(request) 41 | 42 | handler(context) 43 | 44 | expect(context.res.headers.get('Cross-Origin-Embedder-Policy')).toEqual( 45 | 'random-value', 46 | ) 47 | }) 48 | 49 | it('should have a default value for Cross-Origin-Opener-Policy', () => { 50 | const request = new Request('http://localhost:3000/test', { 51 | headers: { 52 | origin: 'http://localhost:3000', 53 | }, 54 | }) 55 | 56 | const handler = secureHeaders({}) 57 | 58 | const context = new Context(request) 59 | 60 | handler(context) 61 | 62 | expect(context.res.headers.get('Cross-Origin-Opener-Policy')).toEqual( 63 | 'same-origin', 64 | ) 65 | }) 66 | 67 | it('should set Cross-Origin-Opener-Policy', () => { 68 | const request = new Request('http://localhost:3000/test', { 69 | headers: { 70 | origin: 'http://localhost:3000', 71 | }, 72 | }) 73 | 74 | const handler = secureHeaders({ 75 | crossOriginOpenerPolicy: 'random-value', 76 | }) 77 | 78 | const context = new Context(request) 79 | 80 | handler(context) 81 | 82 | expect(context.res.headers.get('Cross-Origin-Opener-Policy')).toEqual( 83 | 'random-value', 84 | ) 85 | }) 86 | 87 | it('should have default value for Cross-Origin-Resource-Policty', () => { 88 | const request = new Request('http://localhost:3000/test', { 89 | headers: { 90 | origin: 'http://localhost:3000', 91 | }, 92 | }) 93 | 94 | const handler = secureHeaders({}) 95 | 96 | const context = new Context(request) 97 | 98 | handler(context) 99 | 100 | expect(context.res.headers.get('Cross-Origin-Resource-Policy')).toEqual( 101 | 'same-site', 102 | ) 103 | }) 104 | 105 | it('should set Cross-Origin-Resource-Policy', () => { 106 | const request = new Request('http://localhost:3000/test', { 107 | headers: { 108 | origin: 'http://localhost:3000', 109 | }, 110 | }) 111 | 112 | const handler = secureHeaders({ 113 | crossOriginResourcePolicy: 'random-value', 114 | }) 115 | 116 | const context = new Context(request) 117 | 118 | handler(context) 119 | 120 | expect(context.res.headers.get('Cross-Origin-Resource-Policy')).toEqual( 121 | 'random-value', 122 | ) 123 | }) 124 | 125 | it('should have default value for Referer-Policy', () => { 126 | const request = new Request('http://localhost:3000/test', { 127 | headers: { 128 | origin: 'http://localhost:3000', 129 | }, 130 | }) 131 | 132 | const handler = secureHeaders({}) 133 | 134 | const context = new Context(request) 135 | 136 | handler(context) 137 | 138 | expect(context.res.headers.get('Referrer-Policy')).toEqual( 139 | 'no-referrer', 140 | ) 141 | }) 142 | 143 | it('should set Referrer-Policy', () => { 144 | const request = new Request('http://localhost:3000/test', { 145 | headers: { 146 | origin: 'http://localhost:3000', 147 | }, 148 | }) 149 | 150 | const handler = secureHeaders({ 151 | referrerPolicy: 'random-value', 152 | }) 153 | 154 | const context = new Context(request) 155 | 156 | handler(context) 157 | 158 | expect(context.res.headers.get('Referrer-Policy')).toEqual( 159 | 'random-value', 160 | ) 161 | }) 162 | 163 | it('should have default value for Strict-Transport-Security', () => { 164 | const request = new Request('http://localhost:3000/test', { 165 | headers: { 166 | origin: 'http://localhost:3000', 167 | }, 168 | }) 169 | 170 | const handler = secureHeaders({}) 171 | 172 | const context = new Context(request) 173 | 174 | handler(context) 175 | 176 | expect(context.res.headers.get('Strict-Transport-Security')).toEqual( 177 | 'max-age=31536000; includeSubDomains', 178 | ) 179 | }) 180 | 181 | it('should set Strict-Transport-Security', () => { 182 | const request = new Request('http://localhost:3000/test', { 183 | headers: { 184 | origin: 'http://localhost:3000', 185 | }, 186 | }) 187 | 188 | const handler = secureHeaders({ 189 | strictTransportSecurity: ['random-value1', 'random-value2'], 190 | }) 191 | 192 | const context = new Context(request) 193 | 194 | handler(context) 195 | 196 | expect(context.res.headers.get('Strict-Transport-Security')).toEqual( 197 | 'random-value1; random-value2', 198 | ) 199 | }) 200 | 201 | it('should have default value for X-Content-Type-Options', () => { 202 | const request = new Request('http://localhost:3000/test', { 203 | headers: { 204 | origin: 'http://localhost:3000', 205 | }, 206 | }) 207 | 208 | const handler = secureHeaders({}) 209 | 210 | const context = new Context(request) 211 | 212 | handler(context) 213 | 214 | expect(context.res.headers.get('X-Content-Type-Options')).toEqual( 215 | 'nosniff', 216 | ) 217 | }) 218 | 219 | it('should set X-Content-Type-Options', () => { 220 | const request = new Request('http://localhost:3000/test', { 221 | headers: { 222 | origin: 'http://localhost:3000', 223 | }, 224 | }) 225 | 226 | const handler = secureHeaders({ 227 | xContentTypeOptions: 'random-value', 228 | }) 229 | 230 | const context = new Context(request) 231 | 232 | handler(context) 233 | 234 | expect(context.res.headers.get('X-Content-Type-Options')).toEqual( 235 | 'random-value', 236 | ) 237 | }) 238 | 239 | it('should have default value for X-Download-Options', () => { 240 | const request = new Request('http://localhost:3000/test', { 241 | headers: { 242 | origin: 'http://localhost:3000', 243 | }, 244 | }) 245 | 246 | const handler = secureHeaders({}) 247 | 248 | const context = new Context(request) 249 | 250 | handler(context) 251 | 252 | expect(context.res.headers.get('X-Download-Options')).toEqual('noopen') 253 | }) 254 | 255 | it('should set X-Download-Options', () => { 256 | const request = new Request('http://localhost:3000/test', { 257 | headers: { 258 | origin: 'http://localhost:3000', 259 | }, 260 | }) 261 | 262 | const handler = secureHeaders({ 263 | xDownloadOptions: 'random-value', 264 | }) 265 | 266 | const context = new Context(request) 267 | 268 | handler(context) 269 | 270 | expect(context.res.headers.get('X-Download-Options')).toEqual( 271 | 'random-value', 272 | ) 273 | }) 274 | }) 275 | -------------------------------------------------------------------------------- /packages/wobe/src/hooks/secureHeaders.ts: -------------------------------------------------------------------------------- 1 | import type { WobeHandler } from '../Wobe' 2 | 3 | interface ContentSecurityPolicyOptions { 4 | 'default-src'?: string[] 5 | 'base-uri'?: string[] 6 | 'child-src'?: string[] 7 | 'connect-src'?: string[] 8 | 'font-src'?: string[] 9 | 'form-action'?: string[] 10 | 'frame-ancestors'?: string[] 11 | 'frame-src'?: string[] 12 | 'img-src'?: string[] 13 | 'manifest-src'?: string[] 14 | 'media-src'?: string[] 15 | 'object-src'?: string[] 16 | 'report-to'?: string 17 | sandbox?: string[] 18 | 'script-src'?: string[] 19 | 'script-src-attr'?: string[] 20 | 'script-src-elem'?: string[] 21 | 'style-src'?: string[] 22 | 'style-src-attr'?: string[] 23 | 'style-src-elem'?: string[] 24 | 'upgrade-insecure-requests'?: string[] 25 | 'worker-src'?: string[] 26 | } 27 | 28 | export interface SecureHeadersOptions { 29 | contentSecurityPolicy?: ContentSecurityPolicyOptions 30 | crossOriginEmbedderPolicy?: string 31 | crossOriginOpenerPolicy?: string 32 | crossOriginResourcePolicy?: string 33 | referrerPolicy?: string 34 | strictTransportSecurity?: string[] 35 | xContentTypeOptions?: string 36 | xDownloadOptions?: string 37 | } 38 | 39 | /** 40 | * secureHeaders is a hook that sets secure headers (equivalent of helmet on express) 41 | */ 42 | export const secureHeaders = ({ 43 | contentSecurityPolicy, 44 | crossOriginEmbedderPolicy, 45 | crossOriginOpenerPolicy = 'same-origin', 46 | crossOriginResourcePolicy = 'same-site', 47 | referrerPolicy = 'no-referrer', 48 | strictTransportSecurity = ['max-age=31536000; includeSubDomains'], 49 | xContentTypeOptions = 'nosniff', 50 | xDownloadOptions = 'noopen', 51 | }: SecureHeadersOptions): WobeHandler => { 52 | return (ctx) => { 53 | if (contentSecurityPolicy) { 54 | const formatContentSecurityPolicy = Object.entries( 55 | contentSecurityPolicy, 56 | ) 57 | .map( 58 | ([key, value]) => 59 | `${key} ${ 60 | Array.isArray(value) ? value.join(' ') : value 61 | }`, 62 | ) 63 | .join('; ') 64 | 65 | ctx.res.headers.set( 66 | 'Content-Security-Policy', 67 | formatContentSecurityPolicy, 68 | ) 69 | } 70 | 71 | if (crossOriginEmbedderPolicy) 72 | ctx.res.headers.set( 73 | 'Cross-Origin-Embedder-Policy', 74 | crossOriginEmbedderPolicy, 75 | ) 76 | 77 | if (crossOriginOpenerPolicy) 78 | ctx.res.headers.set( 79 | 'Cross-Origin-Opener-Policy', 80 | crossOriginOpenerPolicy, 81 | ) 82 | 83 | if (crossOriginResourcePolicy) 84 | ctx.res.headers.set( 85 | 'Cross-Origin-Resource-Policy', 86 | crossOriginResourcePolicy, 87 | ) 88 | 89 | if (referrerPolicy) 90 | ctx.res.headers.set('Referrer-Policy', referrerPolicy) 91 | 92 | if (strictTransportSecurity) 93 | ctx.res.headers.set( 94 | 'Strict-Transport-Security', 95 | strictTransportSecurity.join('; '), 96 | ) 97 | 98 | if (xContentTypeOptions) 99 | ctx.res.headers.set('X-Content-Type-Options', xContentTypeOptions) 100 | 101 | if (xDownloadOptions) 102 | ctx.res.headers.set('X-Download-Options', xDownloadOptions) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /packages/wobe/src/hooks/uploadDirectory.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, beforeEach, afterEach } from 'bun:test' 2 | import { uploadDirectory } from './uploadDirectory' 3 | import { Context } from '../Context' 4 | import { join } from 'node:path' 5 | import { mkdir, writeFile, rm } from 'node:fs/promises' 6 | import getPort from 'get-port' 7 | import { Wobe } from '../Wobe' 8 | 9 | describe('UploadDirectory Hook', () => { 10 | const testDirectory = join(__dirname, 'test-bucket') 11 | const fileName = 'test-file.txt' 12 | const filePath = join(testDirectory, fileName) 13 | 14 | beforeEach(async () => { 15 | // Create a test directory and file before each test 16 | await mkdir(testDirectory, { recursive: true }) 17 | await writeFile(filePath, 'This is a test file.') 18 | }) 19 | 20 | afterEach(async () => { 21 | // Clean up the test directory and file after each test 22 | await rm(testDirectory, { recursive: true, force: true }) 23 | }) 24 | 25 | it('should serve an existing file with correct Content-Type', async () => { 26 | const port = await getPort() 27 | const wobe = new Wobe() 28 | 29 | wobe.get( 30 | '/bucket/:filename', 31 | uploadDirectory({ directory: testDirectory }), 32 | ) 33 | 34 | wobe.listen(port) 35 | 36 | const response = await fetch( 37 | `http://127.0.0.1:${port}/bucket/${fileName}`, 38 | ) 39 | 40 | expect(response.status).toBe(200) 41 | expect(response.headers.get('Content-Type')).toBe('text/plain') 42 | 43 | const fileContent = await response.text() 44 | expect(fileContent).toBe('This is a test file.') 45 | 46 | wobe.stop() 47 | }) 48 | 49 | it('should return 404 if the file does not exist', async () => { 50 | const port = await getPort() 51 | const wobe = new Wobe() 52 | 53 | wobe.get( 54 | '/bucket/:filename', 55 | uploadDirectory({ directory: testDirectory }), 56 | ) 57 | 58 | wobe.listen(port) 59 | 60 | const response = await fetch( 61 | `http://127.0.0.1:${port}/bucket/non-existent-file.txt`, 62 | ) 63 | 64 | expect(response.status).toBe(404) 65 | expect(await response.text()).toBe('File not found') 66 | 67 | wobe.stop() 68 | }) 69 | 70 | it('should return 400 if the filename parameter is missing', async () => { 71 | const port = await getPort() 72 | const wobe = new Wobe() 73 | 74 | wobe.get( 75 | '/bucket/:filename', 76 | uploadDirectory({ directory: testDirectory }), 77 | ) 78 | 79 | wobe.listen(port) 80 | 81 | const response = await fetch(`http://127.0.0.1:${port}/bucket/`) 82 | 83 | expect(response.status).toBe(400) 84 | expect(await response.text()).toBe('Filename is required') 85 | 86 | wobe.stop() 87 | }) 88 | 89 | it('should return 401 if not authorized', async () => { 90 | const port = await getPort() 91 | const wobe = new Wobe() 92 | 93 | wobe.get( 94 | '/bucket/:filename', 95 | uploadDirectory({ directory: testDirectory, isAuthorized: false }), 96 | ) 97 | 98 | wobe.listen(port) 99 | 100 | const response = await fetch(`http://127.0.0.1:${port}/bucket/`) 101 | 102 | expect(response.status).toBe(401) 103 | expect(await response.text()).toBe('Unauthorized') 104 | 105 | wobe.stop() 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /packages/wobe/src/hooks/uploadDirectory.ts: -------------------------------------------------------------------------------- 1 | import { access, constants, readFile } from 'node:fs/promises' 2 | import { join, extname } from 'node:path' 3 | import type { WobeHandler } from '../Wobe' 4 | import mimeTypes from '../utils' 5 | 6 | export interface UploadDirectoryOptions { 7 | directory: string 8 | isAuthorized?: boolean 9 | } 10 | 11 | /** 12 | * uploadDirectory is a hook that allow you to access to all files in a directory 13 | * You must provide the filename parameter in the route 14 | * Usage: wobe.get('/bucket/:filename', uploadDirectory({ directory: './bucket', isAuthorized: true })) 15 | */ 16 | export const uploadDirectory = ({ 17 | directory, 18 | isAuthorized = true, 19 | }: UploadDirectoryOptions): WobeHandler => { 20 | return async (ctx) => { 21 | if (!isAuthorized) { 22 | ctx.res.status = 401 23 | return ctx.res.sendText('Unauthorized') 24 | } 25 | 26 | const fileName = ctx.params.filename 27 | 28 | if (!fileName) { 29 | ctx.res.status = 400 30 | return ctx.res.sendText('Filename is required') 31 | } 32 | 33 | const filePath = join(directory, fileName) 34 | 35 | try { 36 | await access(filePath, constants.F_OK) 37 | 38 | const fileContent = await readFile(filePath) 39 | 40 | const ext = extname(filePath).toLowerCase() 41 | 42 | const contentType = mimeTypes[ext] || 'application/octet-stream' 43 | 44 | ctx.res.headers.set('Content-Type', contentType) 45 | ctx.res.headers.set( 46 | 'Content-Length', 47 | fileContent.byteLength.toString(), 48 | ) 49 | 50 | return ctx.res.send(fileContent) 51 | } catch (error) { 52 | ctx.res.status = 404 53 | return ctx.res.sendText('File not found') 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/wobe/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Wobe' 2 | export * from './utils' 3 | export * from './Context' 4 | export * from './WobeResponse' 5 | export * from './adapters' 6 | export * from './router' 7 | export * from './tools' 8 | export * from './HttpException' 9 | export * from './hooks' 10 | -------------------------------------------------------------------------------- /packages/wobe/src/router/RadixTree.ts: -------------------------------------------------------------------------------- 1 | import type { Hook, HttpMethod, WobeHandler } from '../Wobe' 2 | 3 | export interface Node { 4 | name: string 5 | children: Array 6 | handler?: WobeHandler 7 | beforeHandlerHook?: Array> 8 | afterHandlerHook?: Array> 9 | method?: HttpMethod 10 | isParameterNode?: boolean 11 | isWildcardNode?: boolean 12 | params?: Record 13 | } 14 | 15 | export class RadixTree { 16 | public root: Node = { name: '/', children: [] } 17 | private isOptimized = false 18 | 19 | addRoute(method: HttpMethod, path: string, handler: WobeHandler) { 20 | const pathParts = path.split('/').filter(Boolean) 21 | 22 | let currentNode = this.root 23 | 24 | for (let i = 0; i < pathParts.length; i++) { 25 | const pathPart = pathParts[i] 26 | const isParameterNode = pathPart[0] === ':' 27 | const isWildcardNode = pathPart[0] === '*' 28 | 29 | let foundNode = currentNode.children.find( 30 | (node) => 31 | node.name === (i === 0 ? '' : '/') + pathPart && 32 | (node.method === method || !node.method), 33 | ) 34 | 35 | if ( 36 | foundNode && 37 | foundNode.method === method && 38 | i === pathParts.length - 1 39 | ) 40 | throw new Error(`Route ${method} ${path} already exists`) 41 | 42 | if (!foundNode) { 43 | foundNode = { 44 | name: (i === 0 ? '' : '/') + pathPart, 45 | children: [], 46 | isParameterNode, 47 | isWildcardNode, 48 | } 49 | 50 | currentNode.children.push(foundNode) 51 | } 52 | 53 | currentNode = foundNode 54 | } 55 | 56 | currentNode.handler = handler 57 | currentNode.method = method 58 | } 59 | 60 | _addHookToNode(node: Node, hook: Hook, handler: WobeHandler) { 61 | switch (hook) { 62 | case 'beforeHandler': { 63 | if (!node.beforeHandlerHook) node.beforeHandlerHook = [] 64 | 65 | node.beforeHandlerHook.push(handler) 66 | break 67 | } 68 | case 'afterHandler': { 69 | if (!node.afterHandlerHook) node.afterHandlerHook = [] 70 | 71 | node.afterHandlerHook.push(handler) 72 | break 73 | } 74 | 75 | case 'beforeAndAfterHandler': { 76 | if (!node.beforeHandlerHook) node.beforeHandlerHook = [] 77 | 78 | if (!node.afterHandlerHook) node.afterHandlerHook = [] 79 | 80 | node.beforeHandlerHook.push(handler) 81 | node.afterHandlerHook.push(handler) 82 | break 83 | } 84 | default: 85 | break 86 | } 87 | } 88 | 89 | addHook( 90 | hook: Hook, 91 | path: string, 92 | handler: WobeHandler, 93 | method: HttpMethod, 94 | node?: Node, 95 | ) { 96 | if (this.isOptimized) 97 | throw new Error( 98 | 'Cannot add hooks after the tree has been optimized', 99 | ) 100 | 101 | let currentNode = node || this.root 102 | 103 | // For hooks with no specific path 104 | if (path === '*') { 105 | const addHookToChildren = (node: Node) => { 106 | for (let i = 0; i < node.children.length; i++) { 107 | const child = node.children[i] 108 | 109 | if ( 110 | child.handler && 111 | (method === child.method || method === 'ALL') 112 | ) 113 | this._addHookToNode(child, hook, handler) 114 | 115 | addHookToChildren(child) 116 | } 117 | } 118 | 119 | for (let i = 0; i < currentNode.children.length; i++) { 120 | const child = currentNode.children[i] 121 | 122 | if ( 123 | child.handler && 124 | (method === child.method || method === 'ALL') 125 | ) { 126 | this._addHookToNode(child, hook, handler) 127 | } 128 | 129 | if (child.children.length > 0) addHookToChildren(child) 130 | } 131 | 132 | return 133 | } 134 | 135 | const pathParts = path.split('/').filter(Boolean) 136 | 137 | for (let i = 0; i < pathParts.length; i++) { 138 | const pathPart = pathParts[i] 139 | const isWildcardNode = pathPart[0] === '*' 140 | 141 | if (isWildcardNode) { 142 | const nextPathJoin = '/' + pathParts.slice(i + 1).join('/') 143 | 144 | for (const child of currentNode.children) { 145 | if (child.method === method || !child.method) 146 | this.addHook(hook, nextPathJoin, handler, method, child) 147 | } 148 | 149 | return 150 | } 151 | 152 | const foundNode = currentNode.children.find( 153 | (node) => 154 | node.name === 155 | (currentNode.name === '/' ? '' : '/') + pathPart && 156 | ((node.method && node.method === method) || !node.method), 157 | ) 158 | 159 | if (!foundNode) break 160 | 161 | currentNode = foundNode 162 | } 163 | 164 | this._addHookToNode(currentNode, hook, handler) 165 | } 166 | 167 | // This function is used to find the route in the tree 168 | // The path in the node could be for example /a and in children /simple 169 | // or it can also be /a/simple/route if there is only one children in each node 170 | findRoute(method: HttpMethod, path: string) { 171 | let localPath = path 172 | if (path[0] !== '/') localPath = '/' + path 173 | 174 | const { length: pathLength } = localPath 175 | 176 | if (pathLength === 1 && localPath === '/') return this.root 177 | 178 | let nextIndexToEnd = 0 179 | let params: Record | undefined = undefined 180 | 181 | const isNodeMatch = ( 182 | node: Node, 183 | indexToBegin: number, 184 | indexToEnd: number, 185 | ): Node | null => { 186 | const nextIndexToBegin = indexToBegin + (indexToEnd - indexToBegin) 187 | 188 | for (let i = 0; i < node.children.length; i++) { 189 | const child = node.children[i] 190 | const childName = child.name 191 | 192 | const isChildWildcardOrParameterNode = 193 | child.isWildcardNode || child.isParameterNode 194 | 195 | // We get the next end index 196 | nextIndexToEnd = localPath.indexOf( 197 | '/', 198 | isChildWildcardOrParameterNode 199 | ? nextIndexToBegin + 1 200 | : nextIndexToBegin + childName.length - 1, 201 | ) 202 | 203 | if (nextIndexToEnd === -1) nextIndexToEnd = pathLength 204 | 205 | if (indexToEnd === nextIndexToEnd && !child.isWildcardNode) 206 | continue 207 | 208 | // If the child is not a wildcard or parameter node 209 | // and the length of the child name is different from the length of the path 210 | if ( 211 | !isChildWildcardOrParameterNode && 212 | nextIndexToEnd - nextIndexToBegin !== childName.length 213 | ) 214 | continue 215 | 216 | if (child.isParameterNode) { 217 | if (!params) params = {} 218 | 219 | const indexToAddIfFirstNode = indexToBegin === 0 ? 0 : 1 220 | 221 | params[childName.slice(1 + indexToAddIfFirstNode)] = 222 | localPath.slice( 223 | nextIndexToBegin + indexToAddIfFirstNode, 224 | nextIndexToEnd, 225 | ) 226 | } 227 | 228 | // If the child has no children and the node is a wildcard or parameter node 229 | if ( 230 | isChildWildcardOrParameterNode && 231 | child.children.length === 0 && 232 | child.method === method 233 | ) 234 | return child 235 | 236 | if ( 237 | nextIndexToEnd >= pathLength - 1 && 238 | (child.method === method || child.method === 'ALL') 239 | ) { 240 | if (isChildWildcardOrParameterNode) return child 241 | 242 | const pathToCompute = localPath.slice( 243 | nextIndexToBegin, 244 | nextIndexToEnd, 245 | ) 246 | 247 | if (pathToCompute === childName) return child 248 | } 249 | 250 | const foundNode = isNodeMatch( 251 | child, 252 | nextIndexToBegin, 253 | nextIndexToEnd, 254 | ) 255 | 256 | if (foundNode) return foundNode 257 | } 258 | 259 | return null 260 | } 261 | 262 | const route = isNodeMatch(this.root, 0, this.root.name.length) 263 | 264 | if (params && route) route.params = params 265 | 266 | return route 267 | } 268 | 269 | // This function optimize the tree by merging all the nodes that only have one child 270 | optimizeTree() { 271 | const optimizeNode = (node: Node) => { 272 | // Merge multiple nodes that have only one child except parameter, wildcard and root nodes 273 | if ( 274 | node.children.length === 1 && 275 | !node.handler && 276 | !node.isParameterNode && 277 | !node.children[0].isParameterNode && 278 | !node.isWildcardNode && 279 | !node.children[0].isWildcardNode && 280 | node.name !== '/' 281 | ) { 282 | const child = node.children[0] 283 | 284 | node.name += child.name 285 | node.children = child.children 286 | node.handler = child.handler 287 | node.method = child.method 288 | node.beforeHandlerHook = child.beforeHandlerHook 289 | node.afterHandlerHook = child.afterHandlerHook 290 | 291 | optimizeNode(node) 292 | } 293 | 294 | node.children.forEach(optimizeNode) 295 | } 296 | 297 | optimizeNode(this.root) 298 | 299 | this.isOptimized = true 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /packages/wobe/src/router/index.ts: -------------------------------------------------------------------------------- 1 | export * from './RadixTree' 2 | -------------------------------------------------------------------------------- /packages/wobe/src/tools/WobeStore.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, beforeEach, spyOn } from 'bun:test' 2 | import { WobeStore } from './WobeStore' 3 | 4 | describe('WobeStore', () => { 5 | const wobeStore = new WobeStore({ 6 | interval: 100, 7 | }) 8 | 9 | beforeEach(() => { 10 | wobeStore.clear() 11 | }) 12 | 13 | it('should init a WobeStore', () => { 14 | const spySetInterval = spyOn(global, 'setInterval') 15 | const spyClearInterval = spyOn(global, 'clearInterval') 16 | 17 | const store = new WobeStore({ interval: 100 }) 18 | 19 | store.stop() 20 | 21 | expect(spySetInterval).toHaveBeenCalledTimes(1) 22 | expect(spySetInterval).toHaveBeenCalledWith(expect.any(Function), 100) 23 | expect(spyClearInterval).toHaveBeenCalledTimes(1) 24 | expect(spyClearInterval).toHaveBeenCalledWith(store.intervalId) 25 | }) 26 | 27 | it('should store a value', () => { 28 | wobeStore.set('key', 'value') 29 | 30 | expect(wobeStore.get('key')).toBe('value') 31 | }) 32 | 33 | it('should clear a wobe store', () => { 34 | wobeStore.set('key', 'value') 35 | 36 | expect(wobeStore.get('key')).toBe('value') 37 | 38 | wobeStore.clear() 39 | 40 | expect(wobeStore.get('key')).toBeUndefined() 41 | }) 42 | 43 | it('should return undefined if the key does not exist', () => { 44 | expect(wobeStore.get('key2')).toBeUndefined() 45 | }) 46 | 47 | it('should clear a cache after timeLimit', () => { 48 | const localWobeStore = new WobeStore({ 49 | interval: 100, 50 | }) 51 | 52 | localWobeStore.set('key', 'value') 53 | 54 | setTimeout(() => { 55 | expect(localWobeStore.get('key')).not.toBeUndefined() 56 | }, 50) 57 | 58 | setTimeout(() => { 59 | expect(localWobeStore.get('key')).toBeUndefined() 60 | }, 100) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /packages/wobe/src/tools/WobeStore.ts: -------------------------------------------------------------------------------- 1 | export interface WobeStoreOptions { 2 | interval: number 3 | } 4 | 5 | /** 6 | * WobeStore is a class that stores data for a certain amount of time 7 | */ 8 | export class WobeStore { 9 | private options: WobeStoreOptions 10 | private store: Record 11 | 12 | public intervalId: Timer | undefined = undefined 13 | 14 | constructor(options: WobeStoreOptions) { 15 | this.options = options 16 | this.store = {} 17 | 18 | this._init() 19 | } 20 | 21 | _init() { 22 | this.intervalId = setInterval(() => { 23 | this.clear() 24 | }, this.options.interval) 25 | } 26 | 27 | set(key: string, value: T) { 28 | this.store[key] = value 29 | } 30 | 31 | get(key: string): T | undefined { 32 | return this.store[key] 33 | } 34 | 35 | clear() { 36 | this.store = {} 37 | } 38 | 39 | stop() { 40 | clearInterval(this.intervalId) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/wobe/src/tools/index.ts: -------------------------------------------------------------------------------- 1 | export * from './WobeStore' 2 | -------------------------------------------------------------------------------- /packages/wobe/src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'bun:test' 2 | import { extractPathnameAndSearchParams } from './utils' 3 | 4 | describe('Utils', () => { 5 | describe('extractPathnameAndSearchParams', () => { 6 | it('should extract pathname from a route with http', () => { 7 | const route = 'http://localhost:3000/test' 8 | const { pathName } = extractPathnameAndSearchParams(route) 9 | expect(pathName).toBe('/test') 10 | }) 11 | 12 | it('should extract pathname from a route with https', () => { 13 | const route = 'https://localhost:3000/test' 14 | const { pathName } = extractPathnameAndSearchParams(route) 15 | expect(pathName).toBe('/test') 16 | }) 17 | 18 | it('should extract pathname from a route', () => { 19 | const route = 'http://localhost:3000/' 20 | const { pathName } = extractPathnameAndSearchParams(route) 21 | expect(pathName).toBe('/') 22 | }) 23 | 24 | it('should extract pathname with sub pathname from a route', () => { 25 | const route = 'http://localhost:3000/test/subtest' 26 | const { pathName } = extractPathnameAndSearchParams(route) 27 | expect(pathName).toBe('/test/subtest') 28 | }) 29 | 30 | it('should extract single search param from a route', () => { 31 | const route = 'http://localhost:3000/test?name=John' 32 | const { pathName, searchParams } = 33 | extractPathnameAndSearchParams(route) 34 | 35 | expect(pathName).toBe('/test') 36 | expect(searchParams).toEqual({ name: 'John' }) 37 | }) 38 | 39 | it('should extract search params from a route', () => { 40 | const route = 'http://localhost:3000/test?name=John&age=30' 41 | const { pathName, searchParams } = 42 | extractPathnameAndSearchParams(route) 43 | 44 | expect(pathName).toBe('/test') 45 | expect(searchParams).toEqual({ name: 'John', age: '30' }) 46 | }) 47 | 48 | it('should extract search params from a complex route', () => { 49 | const route = 50 | 'http://localhost:3000/test?name=John&age=30&firstName=Pierre-Jacques&Country=Pays basque' 51 | const { pathName, searchParams } = 52 | extractPathnameAndSearchParams(route) 53 | 54 | expect(pathName).toBe('/test') 55 | expect(searchParams).toEqual({ 56 | name: 'John', 57 | age: '30', 58 | firstName: 'Pierre-Jacques', 59 | Country: 'Pays basque', 60 | }) 61 | }) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /packages/wobe/src/utils.ts: -------------------------------------------------------------------------------- 1 | export const extractPathnameAndSearchParams = (url: string) => { 2 | // 8 because this is the length of 'https://' 3 | const queryIndex = url.indexOf('?', 8) 4 | const urlLength = url.length 5 | 6 | const isQueryContainsSearchParams = queryIndex !== -1 7 | 8 | const path = url.slice( 9 | url.indexOf('/', 8), 10 | !isQueryContainsSearchParams ? urlLength : queryIndex, 11 | ) 12 | 13 | if (isQueryContainsSearchParams) { 14 | const searchParams: Record = {} 15 | let indexOfLastParam = queryIndex + 1 16 | let indexOfLastEqual = -1 17 | 18 | for (let i = queryIndex + 1; i < urlLength; i++) { 19 | const char = url[i] 20 | 21 | if (char === '=') { 22 | indexOfLastEqual = i 23 | continue 24 | } 25 | 26 | if (char === '&' || i === urlLength - 1) { 27 | searchParams[url.slice(indexOfLastParam, indexOfLastEqual)] = 28 | url.slice( 29 | indexOfLastEqual + 1, 30 | i === urlLength - 1 ? i + 1 : i, 31 | ) 32 | indexOfLastParam = i + 1 33 | } 34 | } 35 | 36 | return { pathName: path, searchParams } 37 | } 38 | 39 | return { pathName: path } 40 | } 41 | 42 | type MimeType = { 43 | [key: string]: string 44 | } 45 | 46 | export const mimeTypes: MimeType = { 47 | '.html': 'text/html', 48 | '.css': 'text/css', 49 | '.js': 'application/javascript', 50 | '.json': 'application/json', 51 | '.xml': 'application/xml', 52 | '.png': 'image/png', 53 | '.jpg': 'image/jpeg', 54 | '.jpeg': 'image/jpeg', 55 | '.gif': 'image/gif', 56 | '.svg': 'image/svg+xml', 57 | '.pdf': 'application/pdf', 58 | '.txt': 'text/plain', 59 | '.csv': 'text/csv', 60 | '.mp3': 'audio/mpeg', 61 | '.wav': 'audio/wav', 62 | '.flac': 'audio/flac', 63 | '.mp4': 'video/mp4', 64 | '.webm': 'video/webm', 65 | '.ogg': 'video/ogg', 66 | '.mpeg': 'video/mpeg', 67 | '.zip': 'application/zip', 68 | '.doc': 'application/msword', 69 | '.docx': 70 | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 71 | '.xls': 'application/vnd.ms-excel', 72 | '.xlsx': 73 | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 74 | '.bmp': 'image/bmp', 75 | '.ico': 'image/x-icon', 76 | '.tiff': 'image/tiff', 77 | '.rtf': 'application/rtf', 78 | '.md': 'text/markdown', 79 | '.epub': 'application/epub+zip', 80 | '.woff': 'font/woff', 81 | '.woff2': 'font/woff2', 82 | '.otf': 'font/otf', 83 | '.ttf': 'font/ttf', 84 | '.7z': 'application/x-7z-compressed', 85 | '.tar': 'application/x-tar', 86 | '.gz': 'application/gzip', 87 | '.rar': 'application/vnd.rar', 88 | '.avi': 'video/x-msvideo', 89 | '.mov': 'video/quicktime', 90 | '.wmv': 'video/x-ms-wmv', 91 | '.flv': 'video/x-flv', 92 | '.mkv': 'video/x-matroska', 93 | '.psd': 'image/vnd.adobe.photoshop', 94 | '.ai': 'application/postscript', 95 | '.eps': 'application/postscript', 96 | '.ps': 'application/postscript', 97 | '.sql': 'application/sql', 98 | '.sh': 'application/x-sh', 99 | '.php': 'application/x-httpd-php', 100 | '.ppt': 'application/vnd.ms-powerpoint', 101 | '.pptx': 102 | 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 103 | '.odt': 'application/vnd.oasis.opendocument.text', 104 | '.ods': 'application/vnd.oasis.opendocument.spreadsheet', 105 | '.odp': 'application/vnd.oasis.opendocument.presentation', 106 | '.aac': 'audio/aac', 107 | '.mid': 'audio/midi', 108 | '.wma': 'audio/x-ms-wma', 109 | '.webp': 'image/webp', 110 | '.m4a': 'audio/mp4', 111 | '.m4v': 'video/mp4', 112 | '.3gp': 'video/3gpp', 113 | '.3g2': 'video/3gpp2', 114 | '.ts': 'video/mp2t', 115 | '.m3u8': 'application/vnd.apple.mpegurl', 116 | '.ics': 'text/calendar', 117 | '.vcf': 'text/vcard', 118 | '.yaml': 'application/x-yaml', 119 | '.yml': 'application/x-yaml', 120 | '.avif': 'image/avif', 121 | '.heic': 'image/heic', 122 | '.heif': 'image/heif', 123 | '.jxl': 'image/jxl', 124 | '.webmanifest': 'application/manifest+json', 125 | '.opus': 'audio/opus', 126 | '.weba': 'audio/webm', 127 | '.mjs': 'text/javascript', 128 | '.cjs': 'text/javascript', 129 | } 130 | 131 | export default mimeTypes 132 | -------------------------------------------------------------------------------- /packages/wobe/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "verbatimModuleSyntax": true, 14 | "outDir": "dist", 15 | "declaration": true, 16 | "emitDeclarationOnly": true, 17 | 18 | // Best practices 19 | "strict": true, 20 | "skipLibCheck": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | // Some stricter flags (disabled by default) 24 | "noUnusedLocals": false, 25 | "noUnusedParameters": false, 26 | "noPropertyAccessFromIndexSignature": false 27 | }, 28 | "exclude": ["node_modules", "dist", "**/*.test.ts"], 29 | "include": ["src/**/*.ts"] 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false 25 | } 26 | } 27 | --------------------------------------------------------------------------------