├── .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 |
3 |
4 | Wobe
5 |
6 |
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 |
3 |
4 |
5 | Wobe
6 |
7 |
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 |
3 |
4 | Wobe
5 |
6 |
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 |
3 |
4 | Wobe
5 |
6 |
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 |
3 |
4 | Wobe
5 |
6 |
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 |
--------------------------------------------------------------------------------