├── .all-contributorsrc ├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .github ├── FUNDING.yml ├── semantic.yml └── workflows │ ├── codeql-analysis.yml │ └── coverage.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .npmignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .versionrc ├── .vscode ├── extensions.json └── settings.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── __tests__ ├── __snapshots__ │ └── openapi.test.ts.snap ├── app.test.ts ├── encrypt.test.ts ├── events.test.ts ├── handler.test-d.ts ├── handler.test.ts ├── merge.ts ├── ms.tests.ts ├── openapi.test.ts ├── plugins.test.ts ├── routeSpecificity.test.ts ├── router.test-d.ts ├── router.test.ts ├── serializeObject.test.ts ├── tsconfig.json ├── types.spec.ts └── uniqueId.test.ts ├── acceptance_tests ├── dist │ ├── index.cjs │ └── index.mjs ├── package.json ├── rollup.config.js ├── src │ ├── app.ts │ ├── index.ts │ ├── modules │ │ └── users │ │ │ ├── usersPlugin.ts │ │ │ ├── usersServicePlugin.ts │ │ │ └── usersValidators.ts │ └── plugins │ │ ├── db │ │ └── dbPlugin.ts │ │ └── logger │ │ └── loggerPlugin.ts ├── tsconfig.json └── yarn.lock ├── api-extractor.json ├── docs ├── .eslintrc ├── README.md ├── md-layout.js ├── md-loader.js ├── next-env.d.ts ├── next.config.js ├── package.json ├── public │ ├── favicon.ico │ ├── icons │ │ ├── chevron-down.svg │ │ ├── chevron-up.svg │ │ └── menu.svg │ └── vercel.svg ├── remark-plugins.js ├── server.api.json ├── src │ ├── components │ │ ├── Accordion │ │ │ ├── Accordion.module.scss │ │ │ └── Accordion.tsx │ │ ├── Header │ │ │ ├── Header.module.scss │ │ │ └── Header.tsx │ │ ├── Layout.tsx │ │ ├── Reference │ │ │ ├── Reference.module.scss │ │ │ └── Reference.tsx │ │ └── TableOfContents │ │ │ ├── TableOfContents.module.scss │ │ │ └── TableOfContents.tsx │ ├── pages │ │ ├── _app.tsx │ │ ├── layout.tsx │ │ └── reference │ │ │ ├── AppOptions.md │ │ │ ├── AppOptionsCookies.md │ │ │ ├── CorsOrigin.md │ │ │ ├── CorsOriginFunction.md │ │ │ ├── EventBus.md │ │ │ ├── HttpError.md │ │ │ ├── HttpMethod.md │ │ │ ├── HttpStatusCode.md │ │ │ ├── ParseRouteParams.md │ │ │ ├── RequestId.md │ │ │ ├── RouteConfig.md │ │ │ ├── ServerId.md │ │ │ ├── SetCookieOptions.md │ │ │ ├── StatusError.md │ │ │ ├── TypeOfWebApp.md │ │ │ ├── TypeOfWebEvents.md │ │ │ ├── TypeOfWebPlugin.md │ │ │ ├── TypeOfWebRequest.md │ │ │ ├── TypeOfWebRequestMeta.md │ │ │ ├── TypeOfWebRequestToolkit.md │ │ │ ├── TypeOfWebResponse.md │ │ │ ├── TypeOfWebServer.md │ │ │ ├── TypeOfWebServerMeta.md │ │ │ ├── createApp.md │ │ │ ├── createPlugin.md │ │ │ ├── index.md │ │ │ ├── isStatusError.md │ │ │ └── parseRequestId.md │ └── styles │ │ ├── Layout.module.scss │ │ ├── globals.scss │ │ └── prism.css ├── tools │ ├── apiExtractorUtils.ts │ ├── constants.ts │ ├── files.ts │ ├── html.ts │ ├── stringify.ts │ ├── tsdoc.ts │ ├── types.ts │ └── utils.ts ├── tsconfig.json └── yarn.lock ├── etc └── server.api.md ├── examples ├── simple.ts └── tsconfig.json ├── jest-setup-after-env.ts ├── jest.config.js ├── package.json ├── rollup.config.js ├── src ├── index.ts ├── modules │ ├── app.ts │ ├── augment.ts │ ├── cache.ts │ ├── events.ts │ ├── http.ts │ ├── httpStatusCodes.ts │ ├── openapi.ts │ ├── plugins.ts │ ├── router.ts │ └── shared.ts └── utils │ ├── encryptCookies.ts │ ├── errors.ts │ ├── merge.ts │ ├── ms.ts │ ├── node.ts │ ├── routeSpecificity.ts │ ├── serializeObject.ts │ ├── types.ts │ └── uniqueId.ts ├── tsconfig.eslint.json ├── tsconfig.json ├── tsdoc.json └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "server", 3 | "projectOwner": "typeofweb", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "angular", 12 | "contributors": [ 13 | { 14 | "login": "wisnie", 15 | "name": "Bartłomiej Wiśniewski", 16 | "avatar_url": "https://avatars.githubusercontent.com/u/47081011?v=4", 17 | "profile": "https://github.com/wisnie", 18 | "contributions": [ 19 | "code" 20 | ] 21 | } 22 | ], 23 | "contributorsPerLine": 7, 24 | "skipCi": true 25 | } 26 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | jest-*.ts 2 | docs 3 | jest.config.js 4 | rollup.config.js 5 | dist 6 | coverage 7 | __tests__/bench.js 8 | acceptance_tests 9 | etc 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parserOptions": { 4 | "project": "tsconfig.eslint.json" 5 | }, 6 | "extends": ["plugin:@typeofweb/recommended"], 7 | "rules": { 8 | "import/dynamic-import-chunkname": "off" 9 | }, 10 | "overrides": [ 11 | { 12 | "files": ["__tests__/**/*.ts"], 13 | "rules": { 14 | "@typescript-eslint/consistent-type-assertions": "off", 15 | "@typescript-eslint/no-unsafe-member-access": "off", 16 | "@typescript-eslint/no-non-null-assertion": "off" 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.json linguist-language=JSON-with-Comments 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [typeofweb] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: typeofweb 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/semantic.yml: -------------------------------------------------------------------------------- 1 | # Always validate the PR title, and ignore the commits 2 | titleOnly: true 3 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '42 21 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'javascript' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Test and Build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | tests: 11 | if: "! contains(toJSON(github.event.commits.*.message), '[skip-ci]')" 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Read .nvmrc 20 | run: echo "##[set-output name=NVMRC;]$(cat .nvmrc)" 21 | id: nvm 22 | 23 | - name: Use Node.js 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: '${{ steps.nvm.outputs.NVMRC }}' 27 | 28 | - name: Get yarn cache directory path 29 | id: yarn-cache-dir-path 30 | run: echo "::set-output name=dir::$(yarn cache dir)" 31 | 32 | - uses: actions/cache@v2 33 | id: yarn-cache 34 | with: 35 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 36 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 37 | restore-keys: | 38 | ${{ runner.os }}-yarn- 39 | ${{ runner.os }}- 40 | 41 | - name: Install dependencies 42 | run: yarn install --frozen-lockfile 43 | 44 | - name: Run tests 45 | run: | 46 | yarn build 47 | yarn test 48 | 49 | - name: Collect coverage 50 | run: curl -s https://codecov.io/bash | bash 51 | 52 | - name: Smoke test 53 | run: | 54 | yarn link 55 | cd acceptance_tests 56 | yarn install --frozen-lockfile 57 | yarn link "@typeofweb/server" 58 | yarn build 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage/ 4 | .clinic/ 5 | .vscode/snipsnap.code-snippets 6 | *.log 7 | .rollup.cache 8 | !acceptance_tests/dist 9 | temp 10 | 11 | 12 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 13 | 14 | # next.js 15 | docs/.next/ 16 | docs/out/ 17 | 18 | # production 19 | docs/build 20 | 21 | 22 | # local env files 23 | docs/.env.local 24 | docs/.env.development.local 25 | docs/.env.test.local 26 | docs/.env.production.local 27 | 28 | # vercel 29 | docs/.vercel 30 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | cd docs && yarn build:api && cd .. 5 | yarn lint-staged 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /src 3 | 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 12 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | etc 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 120 6 | } 7 | -------------------------------------------------------------------------------- /.versionrc: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { 4 | "type": "feat", 5 | "section": "Features" 6 | }, 7 | { 8 | "type": "fix", 9 | "section": "Bug Fixes" 10 | }, 11 | { 12 | "type": "refactor", 13 | "section": "Refactoring" 14 | }, 15 | { 16 | "type": "chore", 17 | "section": "Chores" 18 | }, 19 | { 20 | "type": "docs", 21 | "section": "Docs" 22 | }, 23 | { 24 | "type": "test", 25 | "section": "Tests" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "typescript.tsdk": "node_modules/typescript/lib", 5 | "eslint.packageManager": "yarn", 6 | "eslint.run": "onSave", 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [INSERT CONTACT METHOD]. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][mozilla coc]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][faq]. Translations are available 126 | at [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 130 | [mozilla coc]: https://github.com/mozilla/diversity 131 | [faq]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Type of Web - Michał Miszczyszyn 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 | # @typeofweb/server 2 | 3 | 4 | [![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat-square)](#contributors-) 5 | 6 | 7 | [![codecov](https://codecov.io/gh/typeofweb/server/branch/main/graph/badge.svg?token=X1Z8BQ0TFG)](https://codecov.io/gh/typeofweb/server) 8 | [![npm](https://img.shields.io/npm/v/@typeofweb/server.svg)](https://www.npmjs.com/package/@typeofweb/server) 9 | 10 | ## Docs 11 | 12 | ## Sponsors 13 | 14 | <your name here> 15 | 16 | See [opencollective.com/typeofweb](https://opencollective.com/typeofweb) or [github.com/sponsors/typeofweb](https://github.com/sponsors/typeofweb)! ❤️ 17 | 18 | 19 | 20 | ## Contributors ✨ 21 | 22 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |

Bartłomiej Wiśniewski

💻
32 | 33 | 34 | 35 | 36 | 37 | 38 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 39 | 40 | ## Example 41 | 42 | ```ts 43 | import { createApp } from '@typeofweb/server'; 44 | 45 | import { dbPlugin } from './dbPlugin'; 46 | import { authPlugin } from './authPlugin'; 47 | 48 | const app = await createApp({ 49 | host: 'localhost', 50 | port: 3000, 51 | }); 52 | 53 | app.plugin(dbPlugin); 54 | app.plugin(authPlugin); 55 | 56 | app.route({ 57 | path: '/health-check/:count', 58 | method: 'GET', 59 | validation: { 60 | query: {}, 61 | params: { 62 | count: number(), 63 | }, 64 | payload: {}, 65 | response: {}, 66 | }, 67 | async handler(request) { 68 | if (!request.plugins.auth.session) { 69 | throw new HttpError(HttpStatusCode.Unauthorized); 70 | } 71 | 72 | const { params } = request; 73 | const result = await request.server.plugins.db.user.findOne(params.count); 74 | 75 | request.events.emit('found', result); 76 | 77 | return result; 78 | }, 79 | }); 80 | 81 | const server = await app.listen(); 82 | ``` 83 | 84 | ```ts 85 | // dbPlugin.ts 86 | 87 | import { createPlugin } from '@typeofweb/server'; 88 | 89 | declare module '@typeofweb/server' { 90 | interface TypeOfWebServerMeta { 91 | readonly db: PrismaClient; 92 | } 93 | 94 | interface TypeOfWebRequestMeta { 95 | readonly auth: { readonly session: Session }; 96 | } 97 | 98 | interface TypeOfWebServerEvents { 99 | readonly found: User; 100 | } 101 | } 102 | 103 | export const dbPlugin = createPlugin('db', async (app) => { 104 | return { 105 | server: new Prisma(), 106 | }; 107 | }); 108 | ``` 109 | 110 | ```ts 111 | // authPlugin.ts 112 | 113 | import { createPlugin } from '@typeofweb/server'; 114 | 115 | export const authPlugin = createPlugin('auth', async (app) => { 116 | return { 117 | request(request) { 118 | const session = await request.plugins.db.session.findOne({ id: request.cookies.session }); 119 | return { session }; 120 | }, 121 | }; 122 | }); 123 | ``` 124 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/openapi.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`open api generator getOpenApiForRoutes should generate schema for whole app 1`] = ` 4 | Object { 5 | "definitions": Object { 6 | "GetUsersByUserIdInvoicesParams": Object { 7 | "additionalProperties": false, 8 | "properties": Object { 9 | "userId": Object { 10 | "type": "string", 11 | }, 12 | }, 13 | "required": Array [ 14 | "userId", 15 | ], 16 | "type": "object", 17 | }, 18 | "GetUsersByUserIdInvoicesResponse": Object { 19 | "items": Object { 20 | "additionalProperties": false, 21 | "properties": Object { 22 | "id": Object { 23 | "type": "string", 24 | }, 25 | "total": Object { 26 | "type": "number", 27 | }, 28 | }, 29 | "required": Array [ 30 | "id", 31 | "total", 32 | ], 33 | "type": "object", 34 | }, 35 | "type": "array", 36 | }, 37 | "PostUsersByUserIdInvoicesParams": Object { 38 | "additionalProperties": false, 39 | "properties": Object { 40 | "userId": Object { 41 | "type": "string", 42 | }, 43 | }, 44 | "required": Array [ 45 | "userId", 46 | ], 47 | "type": "object", 48 | }, 49 | "PostUsersByUserIdInvoicesPayload": Object { 50 | "additionalProperties": false, 51 | "properties": Object { 52 | "id": Object { 53 | "type": "string", 54 | }, 55 | "item": Object { 56 | "additionalProperties": false, 57 | "properties": Object { 58 | "price": Object { 59 | "type": "number", 60 | }, 61 | }, 62 | "required": Array [ 63 | "price", 64 | ], 65 | "type": "object", 66 | }, 67 | }, 68 | "required": Array [ 69 | "id", 70 | "item", 71 | ], 72 | "type": "object", 73 | }, 74 | "PostUsersByUserIdInvoicesQuery": Object { 75 | "additionalProperties": false, 76 | "properties": Object { 77 | "category": Object { 78 | "anyOf": Array [ 79 | Object { 80 | "const": "html", 81 | "type": "string", 82 | }, 83 | Object { 84 | "const": "css", 85 | "type": "string", 86 | }, 87 | ], 88 | }, 89 | "isFun": Object { 90 | "type": "boolean", 91 | }, 92 | "search": Object { 93 | "type": "string", 94 | }, 95 | }, 96 | "required": Array [ 97 | "isFun", 98 | "search", 99 | "category", 100 | ], 101 | "type": "object", 102 | }, 103 | "PostUsersByUserIdInvoicesResponse": Object { 104 | "items": Object { 105 | "additionalProperties": false, 106 | "properties": Object { 107 | "id": Object { 108 | "type": "string", 109 | }, 110 | "total": Object { 111 | "type": "number", 112 | }, 113 | }, 114 | "required": Array [ 115 | "id", 116 | "total", 117 | ], 118 | "type": "object", 119 | }, 120 | "type": "array", 121 | }, 122 | "PutUsersByUserIdInvoicesParams": Object { 123 | "additionalProperties": false, 124 | "properties": Object { 125 | "userId": Object { 126 | "type": "string", 127 | }, 128 | }, 129 | "required": Array [ 130 | "userId", 131 | ], 132 | "type": "object", 133 | }, 134 | }, 135 | "info": Object { 136 | "title": "Swagger", 137 | "version": "1.1.1", 138 | }, 139 | "paths": Object { 140 | "/users/{userId}/invoices": Object { 141 | "put": Object { 142 | "operationId": "PutUsersByUserIdInvoices", 143 | "parameters": Array [ 144 | Object { 145 | "in": "path", 146 | "name": "userId", 147 | "required": true, 148 | "type": "string", 149 | }, 150 | ], 151 | "responses": Object { 152 | "default": Object { 153 | "description": "Unknown response", 154 | }, 155 | }, 156 | }, 157 | }, 158 | }, 159 | "swagger": "2.0", 160 | } 161 | `; 162 | 163 | exports[`open api generator routeConfigToOpenApiPathsDefinitions should generate single schema 1`] = ` 164 | Object { 165 | "definitions": Object { 166 | "PostUsersByUserIdInvoicesParams": Object { 167 | "additionalProperties": false, 168 | "properties": Object { 169 | "userId": Object { 170 | "type": "string", 171 | }, 172 | }, 173 | "required": Array [ 174 | "userId", 175 | ], 176 | "type": "object", 177 | }, 178 | "PostUsersByUserIdInvoicesPayload": Object { 179 | "additionalProperties": false, 180 | "properties": Object { 181 | "id": Object { 182 | "type": "string", 183 | }, 184 | "item": Object { 185 | "additionalProperties": false, 186 | "properties": Object { 187 | "price": Object { 188 | "type": "number", 189 | }, 190 | }, 191 | "required": Array [ 192 | "price", 193 | ], 194 | "type": "object", 195 | }, 196 | }, 197 | "required": Array [ 198 | "id", 199 | "item", 200 | ], 201 | "type": "object", 202 | }, 203 | "PostUsersByUserIdInvoicesQuery": Object { 204 | "additionalProperties": false, 205 | "properties": Object { 206 | "category": Object { 207 | "anyOf": Array [ 208 | Object { 209 | "const": "html", 210 | "type": "string", 211 | }, 212 | Object { 213 | "const": "css", 214 | "type": "string", 215 | }, 216 | ], 217 | }, 218 | "isFun": Object { 219 | "type": "boolean", 220 | }, 221 | "search": Object { 222 | "type": "string", 223 | }, 224 | }, 225 | "required": Array [ 226 | "isFun", 227 | "search", 228 | "category", 229 | ], 230 | "type": "object", 231 | }, 232 | "PostUsersByUserIdInvoicesResponse": Object { 233 | "items": Object { 234 | "additionalProperties": false, 235 | "properties": Object { 236 | "id": Object { 237 | "type": "string", 238 | }, 239 | "total": Object { 240 | "type": "number", 241 | }, 242 | }, 243 | "required": Array [ 244 | "id", 245 | "total", 246 | ], 247 | "type": "object", 248 | }, 249 | "type": "array", 250 | }, 251 | }, 252 | "paths": Object { 253 | "/users/{userId}/invoices": Object { 254 | "post": Object { 255 | "operationId": "PostUsersByUserIdInvoices", 256 | "parameters": Array [ 257 | Object { 258 | "in": "path", 259 | "name": "userId", 260 | "required": true, 261 | "type": "string", 262 | }, 263 | Object { 264 | "in": "query", 265 | "name": "isFun", 266 | "required": true, 267 | "type": "boolean", 268 | }, 269 | Object { 270 | "in": "query", 271 | "name": "search", 272 | "required": true, 273 | "type": "string", 274 | }, 275 | Object { 276 | "enum": Array [ 277 | "html", 278 | "css", 279 | ], 280 | "in": "query", 281 | "name": "category", 282 | "required": true, 283 | "type": "string", 284 | }, 285 | Object { 286 | "in": "body", 287 | "name": "payload", 288 | "schema": Object { 289 | "$ref": "#/definitions/PostUsersByUserIdInvoicesPayload", 290 | }, 291 | }, 292 | ], 293 | "responses": Object { 294 | "default": Object { 295 | "description": "PostUsersByUserIdInvoicesResponse", 296 | "schema": Object { 297 | "$ref": "#/definitions/PostUsersByUserIdInvoicesResponse", 298 | }, 299 | }, 300 | }, 301 | }, 302 | }, 303 | }, 304 | } 305 | `; 306 | -------------------------------------------------------------------------------- /__tests__/app.test.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from '../src'; 2 | 3 | describe('cors', () => { 4 | it('should enable cors by default', async () => { 5 | const app = createApp({}); 6 | const result = await app.inject({ 7 | method: 'options', 8 | path: '/', 9 | }); 10 | 11 | expect(result.headers['access-control-allow-credentials']).toEqual('true'); 12 | expect(result.headers['access-control-allow-methods']).toEqual('GET,HEAD,PUT,PATCH,POST,DELETE'); 13 | }); 14 | 15 | it('should accept cors options', async () => { 16 | const ORIGIN = 'lo'; 17 | 18 | const app = createApp({ 19 | cors: { 20 | credentials: false, 21 | origin: ORIGIN, 22 | }, 23 | }); 24 | const result = await app.inject({ 25 | method: 'options', 26 | path: '/', 27 | }); 28 | 29 | expect(result.headers['access-control-allow-credentials']).not.toEqual('true'); 30 | expect(result.headers['access-control-allow-origin']).toEqual(ORIGIN); 31 | expect(result.headers['access-control-allow-methods']).toEqual('GET,HEAD,PUT,PATCH,POST,DELETE'); 32 | }); 33 | 34 | it('should disable cors', async () => { 35 | const app = createApp({ 36 | cors: false, 37 | }); 38 | const result = await app.inject({ 39 | method: 'options', 40 | path: '/', 41 | }); 42 | 43 | expect(result.headers['access-control-allow-credentials']).toEqual(undefined); 44 | expect(result.headers['access-control-allow-methods']).toEqual(undefined); 45 | }); 46 | 47 | it('should accept function for origin', async () => { 48 | const app = createApp({ 49 | cors: { 50 | credentials: true, 51 | origin: (requestOrigin) => { 52 | expect(requestOrigin).toEqual('siema.com'); 53 | return 'lo'; 54 | }, 55 | }, 56 | }); 57 | const result = await app.inject({ 58 | method: 'options', 59 | path: '/', 60 | headers: { 61 | Origin: 'siema.com', 62 | }, 63 | }); 64 | 65 | expect(result.headers['access-control-allow-credentials']).toEqual('true'); 66 | expect(result.headers['access-control-allow-origin']).toEqual('lo'); 67 | expect(result.headers['access-control-allow-methods']).toEqual('GET,HEAD,PUT,PATCH,POST,DELETE'); 68 | }); 69 | 70 | it('should handle function for origin which throws', async () => { 71 | const app = createApp({ 72 | cors: { 73 | credentials: true, 74 | origin: (_requestOrigin) => { 75 | throw new Error('Siema'); 76 | }, 77 | }, 78 | }); 79 | const result = await app.inject({ 80 | method: 'options', 81 | path: '/', 82 | headers: { 83 | Origin: 'siema.com', 84 | }, 85 | }); 86 | 87 | expect(result.statusCode).toEqual(500); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /__tests__/encrypt.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests based on https://github.com/hapijs/iron/blob/93fd15c76e656b1973ba134de64f3aeac66a0405/test/index.js 3 | * Copyright (c) 2012-2020, Sideway Inc, and project contributors 4 | * All rights reserved. 5 | * https://github.com/hapijs/iron/blob/93fd15c76e656b1973ba134de64f3aeac66a0405/LICENSE.md 6 | * 7 | * Rewritten and repurposed by Michał Miszczyszyn 2021 8 | */ 9 | import * as EncryptCookies from '../src/utils/encryptCookies'; 10 | 11 | describe('encrypt', () => { 12 | const secret = 'some_not_random_password_that_is'; 13 | const value = 'test data'; 14 | 15 | it('turns object into a sealed then parses the sealed successfully', async () => { 16 | const sealed = await EncryptCookies.seal({ value, secret }); 17 | const unsealed = await EncryptCookies.unseal({ sealed, secret }); 18 | expect(unsealed).toEqual(value); 19 | }); 20 | 21 | it('unseal and sealed object with expiration', async () => { 22 | const sealed = await EncryptCookies.seal({ value, secret, ttl: 200 }); 23 | const unsealed = await EncryptCookies.unseal({ sealed, secret }); 24 | expect(unsealed).toEqual(value); 25 | }); 26 | 27 | it('fails for too short secret', async () => { 28 | await expect(EncryptCookies.seal({ value, secret: 'too short' })).rejects.toThrowErrorMatchingInlineSnapshot( 29 | `"Secret must be exactly 32 characters long!"`, 30 | ); 31 | }); 32 | 33 | it('unseals a sealed', async () => { 34 | const sealed = 35 | 'Fe26.2**SqhOkY8av81FPay7I60ktrpeOq7SgRNCcNN0rHWAMSg*3xsUfKKg2KiUWhsOmm1Nnw*_MeWO7OhJooR1Jc0cXQ5pp-wrtooQBeZsvNCSF9Yl5mm5xpCr8_SwxPJJkzwxN43**r3lxz-MMOws6YE-lDcXy6rmZc0mHHMVbXsndXmePgnA*JRDpLG7MxvgdoJqTeaTnUEQ-c0E6eyA66hVSr3f4BLmdfzZYU7fWIYGImEpEZgwzp_0jlF44R0Vr8BDQBlJiNw'; 36 | const unsealed = await EncryptCookies.unseal({ sealed, secret }); 37 | expect(JSON.parse(unsealed)).toEqual({ 38 | a: 1, 39 | array: [5, 6, {}], 40 | nested: { 41 | k: true, 42 | }, 43 | }); 44 | }); 45 | 46 | it('returns an error when number of sealed components is wrong', async () => { 47 | const sealed = 48 | 'x*Fe26.2**SqhOkY8av81FPay7I60ktrpeOq7SgRNCcNN0rHWAMSg*3xsUfKKg2KiUWhsOmm1Nnw*_MeWO7OhJooR1Jc0cXQ5pp-wrtooQBeZsvNCSF9Yl5mm5xpCr8_SwxPJJkzwxN43**r3lxz-MMOws6YE-lDcXy6rmZc0mHHMVbXsndXmePgnA*JRDpLG7MxvgdoJqTeaTnUEQ-c0E6eyA66hVSr3f4BLmdfzZYU7fWIYGImEpEZgwzp_0jlF44R0Vr8BDQBlJiNw'; 49 | await expect(EncryptCookies.unseal({ sealed, secret })).rejects.toThrowErrorMatchingInlineSnapshot( 50 | `"Cannot unseal: Incorrect data format."`, 51 | ); 52 | }); 53 | 54 | it('returns an error when mac prefix is wrong', async () => { 55 | const sealed = 56 | 'Fe27.2**SqhOkY8av81FPay7I60ktrpeOq7SgRNCcNN0rHWAMSg*3xsUfKKg2KiUWhsOmm1Nnw*_MeWO7OhJooR1Jc0cXQ5pp-wrtooQBeZsvNCSF9Yl5mm5xpCr8_SwxPJJkzwxN43**r3lxz-MMOws6YE-lDcXy6rmZc0mHHMVbXsndXmePgnA*JRDpLG7MxvgdoJqTeaTnUEQ-c0E6eyA66hVSr3f4BLmdfzZYU7fWIYGImEpEZgwzp_0jlF44R0Vr8BDQBlJiNw'; 57 | await expect(EncryptCookies.unseal({ sealed, secret })).rejects.toThrowErrorMatchingInlineSnapshot( 58 | `"Cannot unseal: Unsupported version."`, 59 | ); 60 | }); 61 | 62 | it('returns an error when integrity check fails', async () => { 63 | const sealed = 64 | 'Fe26.2**SqhOkY8av81FPay7I60ktrpeOq7SgRNCcNN0rHWAMSg*3xsUfKKg2KiUWhsOmm1Nnw*_MeWO7OhJooR1Jc0cXQ5pp-wrtooQBeZsvNCSF9Yl5mm5xpCr8_SwxPJJkzwxN43**r3lxz-MMOws6YE-lDcXy6rmZc0mHHMVbXsndXmePgnA*JRDpLG7MxvgdoJqTeaTnUEQ-c0E6eyA66hVSr3f4BLmdfzZYU7fWIYGImEpEZgwzp_0jlF44R0Vr8BDQBlJiNwLOL'; 65 | await expect(EncryptCookies.unseal({ sealed, secret })).rejects.toThrowErrorMatchingInlineSnapshot( 66 | `"Cannot unseal: Incorrect hmac seal value"`, 67 | ); 68 | }); 69 | 70 | it('returns an error when iv base64 decoding fails', async () => { 71 | const sealed = 72 | 'Fe26.2**0a27f421711152214f2cdd7fd8c515738204828f2d5c1ac50685231d38614de1*hUkUfX6sYUoKXh1QNx8oywLOL*AxjnFXiFUlQqdpNYK9lzAJzfm0S07vKo599fOi1Og7vuPaiQ6z8o487hDrs7xDu0**4eb9bef394dbaffa866f1e4246cf9d8c72a19d403da89760a3fc65c95d82301a*l65Cto8YluxfUbex2aD27hrA9Hccvhcryac0pkHfPvs'; 73 | await expect(EncryptCookies.unseal({ sealed, secret })).rejects.toThrowErrorMatchingInlineSnapshot( 74 | `"Cannot unseal: Incorrect hmac seal value"`, 75 | ); 76 | }); 77 | 78 | it('returns an error when expired', async () => { 79 | const sealed = 80 | 'Fe26.2**552bc79cfa73de9855b539a624c6b404496995f443baf057b95c097f5503f330*sk9We2FqPEyHc5bSzfA1yA*tlyeEmz0jWnaRd4CDmrqeQ*1623946580929*807a2f0ac5aebd5e413e06c52ffbf52158566e73a551d805d3b68164c7869ed8*Y5XBmJC-4QZ4Q1iRUiN2f8SStLL23-57wXNayX-tiF0'; 81 | await expect(EncryptCookies.unseal({ sealed, secret })).rejects.toThrowErrorMatchingInlineSnapshot( 82 | `"Cannot unseal: Expired seal"`, 83 | ); 84 | }); 85 | 86 | it('returns an error when expiration NaN', async () => { 87 | const sealed = 88 | 'Fe26.2**71ccf7404636c565d498200c002837f55ff5a0bf5e9ddecbd93953336709e9a4*JnIlC3F0_AhVSJQ2ALF3ow*A3s_DWrqGwWRjgC6mD5-SQ*1623946786465dupa*0e8513880d1c8410fb0e8a8e0c7ad43285ee67568b80ab2e76721e7381e14a14*iEz4o4dDQirX6Y1x2Om6Lpglg3XtDVjzkZvq3iRtFuM'; 89 | await expect(EncryptCookies.unseal({ sealed, secret })).rejects.toThrowErrorMatchingInlineSnapshot( 90 | `"Cannot unseal: Invalid expiration"`, 91 | ); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /__tests__/events.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import { number, object, string } from '@typeofweb/schema'; 3 | 4 | import { createApp } from '../src'; 5 | declare module '../src' { 6 | interface TypeOfWebEvents { 7 | readonly HELLO_EVENTS_TEST: number; 8 | } 9 | } 10 | 11 | describe('events', () => { 12 | it('should emit system events server, request and response', async () => { 13 | const app = createApp({}).route({ 14 | path: '/users/:userId/invoices/:invoiceId', 15 | method: 'post', 16 | validation: { 17 | params: { 18 | userId: number(), 19 | invoiceId: string(), 20 | }, 21 | }, 22 | handler: () => { 23 | return { message: 'OKEJ' }; 24 | }, 25 | }); 26 | 27 | const onServer = jest.fn(); 28 | const onRequest = jest.fn(); 29 | const onAfterResponse = jest.fn(); 30 | 31 | app.events.on(':server', onServer); 32 | app.events.on(':request', onRequest); 33 | app.events.on(':afterResponse', onAfterResponse); 34 | 35 | await app.inject({ 36 | method: 'post', 37 | path: '/users/123/invoices/bbb', 38 | payload: { test: 'test' }, 39 | }); 40 | 41 | expect(onServer).toHaveBeenCalledTimes(1); 42 | 43 | expect(onRequest).toHaveBeenCalledTimes(1); 44 | expect(onRequest).toHaveBeenCalledWith( 45 | expect.objectContaining({ 46 | params: { userId: 123, invoiceId: 'bbb' }, 47 | query: {}, 48 | payload: { test: 'test' }, 49 | }), 50 | ); 51 | 52 | expect(onAfterResponse).toHaveBeenCalledTimes(1); 53 | expect(onAfterResponse).toHaveBeenCalledWith(expect.objectContaining({ payload: { message: 'OKEJ' } })); 54 | }); 55 | 56 | it('should allow emitting custom events', async () => { 57 | const app = createApp({}).route({ 58 | path: '/users', 59 | method: 'post', 60 | validation: { 61 | payload: object({ 62 | id: number(), 63 | })(), 64 | }, 65 | handler: (request) => { 66 | request.server.events.emit('HELLO_EVENTS_TEST', request.payload.id); 67 | return null; 68 | }, 69 | }); 70 | 71 | const payload = [{ id: Math.random() }, { id: Math.random() }, { id: Math.random() }]; 72 | 73 | // eslint-disable-next-line functional/prefer-readonly-type -- ok 74 | const onHelloEvent = jest.fn(); 75 | 76 | app.events.on('HELLO_EVENTS_TEST', onHelloEvent); 77 | 78 | await app.inject({ 79 | method: 'post', 80 | path: '/users', 81 | payload: payload[0], 82 | }); 83 | await app.inject({ 84 | method: 'post', 85 | path: '/users', 86 | payload: payload[1], 87 | }); 88 | await app.inject({ 89 | method: 'post', 90 | path: '/users', 91 | payload: payload[2], 92 | }); 93 | 94 | expect(onHelloEvent).toHaveBeenCalledTimes(3); 95 | expect(onHelloEvent).toHaveBeenNthCalledWith(1, payload[0]?.id); 96 | expect(onHelloEvent).toHaveBeenNthCalledWith(2, payload[1]?.id); 97 | expect(onHelloEvent).toHaveBeenNthCalledWith(3, payload[2]?.id); 98 | }); 99 | 100 | it('should allow removing listeners', async () => { 101 | const app = createApp({}).route({ 102 | path: '/test', 103 | method: 'get', 104 | validation: {}, 105 | handler: () => { 106 | return null; 107 | }, 108 | }); 109 | 110 | const handler = jest.fn(); 111 | 112 | app.events.on(':afterResponse', handler); 113 | await app.inject({ 114 | method: 'get', 115 | path: '/test', 116 | }); 117 | expect(handler).toHaveBeenCalledTimes(1); 118 | 119 | app.events.off(':afterResponse', handler); 120 | await app.inject({ 121 | method: 'get', 122 | path: '/test', 123 | }); 124 | expect(handler).toHaveBeenCalledTimes(1); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /__tests__/handler.test-d.ts: -------------------------------------------------------------------------------- 1 | import { array, number, object, optional, string } from '@typeofweb/schema'; 2 | import { expectType } from 'tsd'; 3 | 4 | import { createApp } from '../src'; 5 | 6 | const app = createApp({}); 7 | 8 | void app.route({ 9 | path: '/users/:userId/invoices/:invoiceId', 10 | method: 'get', 11 | validation: { 12 | // @ts-expect-error Missing userId and invoiceId 13 | params: {}, 14 | }, 15 | handler: () => null, 16 | }); 17 | 18 | void app.route({ 19 | path: '/users/:userId/invoices/:invoiceId', 20 | method: 'get', 21 | validation: { 22 | // @ts-expect-error Missing invoiceId 23 | params: { 24 | userId: number(), 25 | }, 26 | }, 27 | handler: () => null, 28 | }); 29 | 30 | void app.route({ 31 | path: '/users/:userId/invoices/:invoiceId', 32 | method: 'get', 33 | validation: { 34 | params: { 35 | userId: number(), 36 | invoiceId: string(), 37 | }, 38 | }, 39 | handler: () => null, 40 | }); 41 | 42 | void app.route({ 43 | path: '/dsa', 44 | method: 'get', 45 | validation: { 46 | response: number(), 47 | }, 48 | // @ts-expect-error number 49 | handler: () => null, 50 | }); 51 | 52 | void app.route({ 53 | path: '/users/:userId/invoices/:invoiceId', 54 | method: 'post', 55 | validation: { 56 | params: { 57 | userId: number(), 58 | invoiceId: string(), 59 | }, 60 | query: { 61 | search: optional(string()), 62 | }, 63 | payload: object({ id: optional(number()) })(), 64 | response: array(number())(), 65 | }, 66 | handler(request) { 67 | expectType(request.params.userId); 68 | expectType(request.params.invoiceId); 69 | expectType<{ readonly search: string | undefined }>(request.query); 70 | expectType<{ readonly id?: number | undefined }>(request.payload); 71 | 72 | return [1, 2, 3]; 73 | }, 74 | }); 75 | -------------------------------------------------------------------------------- /__tests__/merge.ts: -------------------------------------------------------------------------------- 1 | import { deepMerge } from '../src/utils/merge'; 2 | 3 | describe('deepMerge', () => { 4 | it('should merge', () => { 5 | const obj1 = { par1: -1, par2: { par2_1: -21, par2_5: -25 }, arr: [0, 1, 2] }; 6 | const obj2 = { par1: 1, par2: { par2_1: 21 }, par3: 3, arr: [3, 4, 5] }; 7 | const obj3 = deepMerge(obj1, obj2); 8 | expect(obj3).toEqual({ par1: -1, par2: { par2_1: -21, par2_5: -25 }, par3: 3, arr: [0, 1, 2] }); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /__tests__/ms.tests.ts: -------------------------------------------------------------------------------- 1 | import { ms } from '../src/utils/ms'; 2 | 3 | describe('ms', () => { 4 | it.each([ 5 | { value: 1, unit: 'ms' }, 6 | { value: 1, unit: 'millisecond' }, 7 | { value: 1, unit: 'milliseconds' }, 8 | { value: 1000, unit: 's' }, 9 | { value: 1000, unit: 'sec' }, 10 | { value: 1000, unit: 'second' }, 11 | { value: 1000, unit: 'seconds' }, 12 | { value: 1000 * 60, unit: 'm' }, 13 | { value: 1000 * 60, unit: 'min' }, 14 | { value: 1000 * 60, unit: 'minute' }, 15 | { value: 1000 * 60, unit: 'minutes' }, 16 | { value: 1000 * 60 * 60, unit: 'h' }, 17 | { value: 1000 * 60 * 60, unit: 'hour' }, 18 | { value: 1000 * 60 * 60, unit: 'hours' }, 19 | { value: 1000 * 60 * 60 * 24, unit: 'd' }, 20 | { value: 1000 * 60 * 60 * 24, unit: 'day' }, 21 | { value: 1000 * 60 * 60 * 24, unit: 'days' }, 22 | ] as const)('%p', ({ value, unit }) => { 23 | const randomVal = Math.round(Math.random() * 1000); 24 | expect(ms(`${randomVal} ${unit}`)).toEqual(randomVal * value); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /__tests__/openapi.test.ts: -------------------------------------------------------------------------------- 1 | import { array, boolean, number, object, oneOf, string } from '@typeofweb/schema'; 2 | 3 | import { getOpenApiForRoutes, routeConfigToOpenApiPathsDefinitions } from '../src/modules/openapi'; 4 | 5 | describe('open api generator', () => { 6 | it('routeConfigToOpenApiPathsDefinitions should generate single schema', async () => { 7 | const openapi = await routeConfigToOpenApiPathsDefinitions({ 8 | path: '/users/:userId/invoices', 9 | method: 'post', 10 | validation: { 11 | params: { 12 | userId: string(), 13 | }, 14 | payload: object({ 15 | id: string(), 16 | item: object({ 17 | price: number(), 18 | })(), 19 | })(), 20 | query: { 21 | isFun: boolean(), 22 | search: string(), 23 | category: oneOf(['html', 'css'])(), 24 | }, 25 | response: array( 26 | object({ 27 | id: string(), 28 | total: number(), 29 | })(), 30 | )(), 31 | }, 32 | }); 33 | 34 | expect(openapi).toMatchSnapshot(); 35 | }); 36 | 37 | it('getOpenApiForRoutes should generate schema for whole app', async () => { 38 | const routes = [ 39 | { 40 | path: '/users/:userId/invoices', 41 | method: 'post', 42 | validation: { 43 | params: { 44 | userId: string(), 45 | }, 46 | payload: object({ 47 | id: string(), 48 | item: object({ 49 | price: number(), 50 | })(), 51 | })(), 52 | query: { 53 | isFun: boolean(), 54 | search: string(), 55 | category: oneOf(['html', 'css'])(), 56 | }, 57 | response: array( 58 | object({ 59 | id: string(), 60 | total: number(), 61 | })(), 62 | )(), 63 | }, 64 | }, 65 | { 66 | path: '/users/:userId/invoices', 67 | method: 'get', 68 | validation: { 69 | params: { 70 | userId: string(), 71 | }, 72 | response: array( 73 | object({ 74 | id: string(), 75 | total: number(), 76 | })(), 77 | )(), 78 | }, 79 | }, 80 | { 81 | // unknown response 82 | path: '/users/:userId/invoices', 83 | method: 'put', 84 | validation: { 85 | params: { 86 | userId: string(), 87 | }, 88 | }, 89 | }, 90 | ] as const; 91 | 92 | const openapi = await getOpenApiForRoutes(routes, { 93 | title: 'Swagger', 94 | description: 'This is Swagger', 95 | version: '1.1.1', 96 | }); 97 | 98 | expect(openapi).toMatchSnapshot(); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /__tests__/plugins.test.ts: -------------------------------------------------------------------------------- 1 | import { performance } from 'perf_hooks'; 2 | 3 | import { jest } from '@jest/globals'; 4 | import { number } from '@typeofweb/schema'; 5 | import { wait } from '@typeofweb/utils'; 6 | 7 | import { createApp, createPlugin } from '../src'; 8 | import { ms } from '../src/utils/ms'; 9 | 10 | declare module '../src' { 11 | interface TypeOfWebServerMeta { 12 | readonly myPlugin: { 13 | readonly someValue: number; 14 | getUserById(id: string): Promise; 15 | }; 16 | } 17 | 18 | interface TypeOfWebRequestMeta { 19 | readonly costam: { 20 | readonly auth: { readonly id: number }; 21 | }; 22 | } 23 | } 24 | 25 | describe('plugins', () => { 26 | it('should return function when cache is used', async () => { 27 | const plugin = createPlugin('myPlugin', (_app) => { 28 | return { 29 | server(_server) { 30 | return { 31 | someValue: 42, 32 | getUserById: { 33 | cache: { 34 | expireIn: ms('1 ms'), 35 | }, 36 | fn: (id) => Promise.resolve(id.split('').map(Number)), 37 | }, 38 | }; 39 | }, 40 | }; 41 | }); 42 | const app = createApp({}).route({ 43 | path: '/cache', 44 | method: 'get', 45 | validation: {}, 46 | handler: (request, _t) => { 47 | return request.server.plugins.myPlugin.getUserById('123'); 48 | }, 49 | }); 50 | await app.plugin(plugin); 51 | 52 | const result = await app.inject({ 53 | method: 'get', 54 | path: '/cache', 55 | }); 56 | expect(result.body).toEqual([1, 2, 3]); 57 | }); 58 | 59 | it('should call the function only once when in cache', async () => { 60 | const fn = jest.fn((id: string) => Promise.resolve(id.split('').map(Number))); 61 | 62 | const plugin = createPlugin('myPlugin', (_app) => { 63 | return { 64 | server(_server) { 65 | return { 66 | someValue: 42, 67 | getUserById: { 68 | cache: { 69 | expireAt: '00:00', 70 | }, 71 | fn, 72 | }, 73 | }; 74 | }, 75 | }; 76 | }); 77 | 78 | const app = createApp({}).route({ 79 | path: '/cache', 80 | method: 'get', 81 | validation: {}, 82 | handler: (request, _t) => { 83 | return request.server.plugins.myPlugin.getUserById('123'); 84 | }, 85 | }); 86 | await app.plugin(plugin); 87 | 88 | await app.inject({ 89 | method: 'get', 90 | path: '/cache', 91 | }); 92 | const result = await app.inject({ 93 | method: 'get', 94 | path: '/cache', 95 | }); 96 | expect(result.body).toEqual([1, 2, 3]); 97 | expect(fn).toHaveBeenCalledTimes(1); 98 | }); 99 | 100 | it('should call the function multiple times when cache expires', async () => { 101 | const fn = jest.fn((id: string) => Promise.resolve(id.split('').map(Number))); 102 | 103 | const plugin = createPlugin('myPlugin', (_app) => { 104 | return { 105 | server(_server) { 106 | return { 107 | someValue: 42, 108 | getUserById: { 109 | cache: { 110 | expireIn: ms('10 ms'), 111 | }, 112 | fn, 113 | }, 114 | }; 115 | }, 116 | }; 117 | }); 118 | 119 | const app = createApp({}).route({ 120 | path: '/cache', 121 | method: 'get', 122 | validation: {}, 123 | handler: (request, _t) => { 124 | return request.server.plugins.myPlugin.getUserById('123'); 125 | }, 126 | }); 127 | await app.plugin(plugin); 128 | 129 | await app.inject({ 130 | method: 'get', 131 | path: '/cache', 132 | }); 133 | await wait(100); 134 | await app.inject({ 135 | method: 'get', 136 | path: '/cache', 137 | }); 138 | expect(fn).toHaveBeenCalledTimes(2); 139 | }); 140 | 141 | it('should differentiate functions by parameters', async () => { 142 | const fn = jest.fn((id: string) => Promise.resolve(id.split('').map(Number))); 143 | 144 | const plugin = createPlugin('myPlugin', (_app) => { 145 | return { 146 | server(_server) { 147 | return { 148 | someValue: 42, 149 | getUserById: { 150 | cache: { 151 | expireIn: ms('1 second'), 152 | }, 153 | fn, 154 | }, 155 | }; 156 | }, 157 | }; 158 | }); 159 | 160 | const app = createApp({}).route({ 161 | path: '/cache/:seed', 162 | method: 'get', 163 | validation: { 164 | params: { 165 | seed: number(), 166 | }, 167 | }, 168 | handler: (request, _t) => { 169 | return request.server.plugins.myPlugin.getUserById(request.params.seed.toString()); 170 | }, 171 | }); 172 | await app.plugin(plugin); 173 | 174 | const result1 = await app.inject({ 175 | method: 'get', 176 | path: '/cache/123', 177 | }); 178 | expect(result1.body).toEqual([1, 2, 3]); 179 | expect(fn).toHaveBeenCalledTimes(1); 180 | 181 | const result2 = await app.inject({ 182 | method: 'get', 183 | path: '/cache/444', 184 | }); 185 | expect(result2.body).toEqual([4, 4, 4]); 186 | expect(fn).toHaveBeenCalledTimes(2); 187 | 188 | const result3 = await app.inject({ 189 | method: 'get', 190 | path: '/cache/123', 191 | }); 192 | expect(result3.body).toEqual([1, 2, 3]); 193 | expect(fn).toHaveBeenCalledTimes(2); 194 | }); 195 | 196 | it('should call the function only once even when multiple requests are in parallel', async () => { 197 | const FUNCTION_STALLING = ms('1 second'); 198 | 199 | const fn = jest.fn(async (id: string) => { 200 | await wait(FUNCTION_STALLING); 201 | return id.split('').map(Number); 202 | }); 203 | 204 | const plugin = createPlugin('myPlugin', (_app) => { 205 | return { 206 | server(_server) { 207 | return { 208 | someValue: 42, 209 | getUserById: { 210 | cache: { 211 | expireIn: ms('1 minute'), 212 | }, 213 | fn, 214 | }, 215 | }; 216 | }, 217 | }; 218 | }); 219 | 220 | const app = createApp({}).route({ 221 | path: '/cache', 222 | method: 'get', 223 | validation: {}, 224 | handler: (request, _t) => { 225 | return request.server.plugins.myPlugin.getUserById('123'); 226 | }, 227 | }); 228 | await app.plugin(plugin); 229 | 230 | const before = performance.now(); 231 | await Promise.all( 232 | Array.from({ length: 20 }).map(() => 233 | app.inject({ 234 | method: 'get', 235 | path: '/cache', 236 | }), 237 | ), 238 | ); 239 | const after = performance.now(); 240 | expect(after - before).toBeLessThan(2 * FUNCTION_STALLING); 241 | expect(fn).toHaveBeenCalledTimes(1); 242 | }); 243 | 244 | it('should work when expireAt is used', async () => { 245 | const FUNCTION_STALLING = ms('1 second'); 246 | 247 | const fn = jest.fn(async (id: string) => { 248 | await wait(FUNCTION_STALLING); 249 | return id.split('').map(Number); 250 | }); 251 | 252 | const plugin = createPlugin('myPlugin', (_app) => { 253 | return { 254 | server(_server) { 255 | return { 256 | someValue: 42, 257 | getUserById: { 258 | cache: { 259 | expireAt: '22:15', 260 | }, 261 | fn, 262 | }, 263 | }; 264 | }, 265 | }; 266 | }); 267 | 268 | const app = createApp({}).route({ 269 | path: '/cache', 270 | method: 'get', 271 | validation: {}, 272 | handler: (request, _t) => { 273 | return request.server.plugins.myPlugin.getUserById('123'); 274 | }, 275 | }); 276 | await app.plugin(plugin); 277 | 278 | const before = performance.now(); 279 | await Promise.all( 280 | Array.from({ length: 20 }).map(() => 281 | app.inject({ 282 | method: 'get', 283 | path: '/cache', 284 | }), 285 | ), 286 | ); 287 | const after = performance.now(); 288 | expect(after - before).toBeLessThan(2 * FUNCTION_STALLING); 289 | expect(fn).toHaveBeenCalledTimes(1); 290 | }); 291 | 292 | it('should work when plugin returns only some properties', async () => { 293 | const plugin = createPlugin('costam', (_app) => { 294 | return { 295 | request() { 296 | return { auth: { id: 123 } }; 297 | }, 298 | }; 299 | }); 300 | 301 | const app = createApp({}).route({ 302 | path: '/test', 303 | method: 'get', 304 | validation: {}, 305 | handler: (request) => { 306 | return request.plugins.costam.auth; 307 | }, 308 | }); 309 | await app.plugin(plugin); 310 | 311 | const result = await app.inject({ path: '/test', method: 'get' }); 312 | expect(result.body).toEqual({ id: 123 }); 313 | }); 314 | }); 315 | -------------------------------------------------------------------------------- /__tests__/routeSpecificity.test.ts: -------------------------------------------------------------------------------- 1 | import { calculateSpecificity } from '../src/utils/routeSpecificity'; 2 | 3 | describe('routeSpecificity', () => { 4 | test.each( 5 | // prettier-ignore 6 | [ 7 | ['/', '0'], 8 | ['/a', '1'], 9 | ['/b', '1'], 10 | ['/ab', '1'], 11 | ['/:p', '2'], 12 | ['/a/b', '11'], 13 | ['/a/:p', '12'], 14 | ['/b/', '10'], 15 | ['/a/b/c', '111'], 16 | ['/a/b/:p', '112'], 17 | ['/a/:p/b', '121'], 18 | ['/a/:p/c', '121'], 19 | ['/a/b/c/d', '1111'], 20 | ['/a/:p/b/:x', '1212'], 21 | ['/a/b/*', '113'], 22 | ['/:a/b/*', '213'], 23 | ['/*', '3'], 24 | ['/m/n/*', '113'], 25 | ['/m/:n/:o', '122'], 26 | ['/n/:p/*', '123'], 27 | ], 28 | )('calculateSpecificity(%s)', (route, expected) => { 29 | expect(calculateSpecificity(route)).toEqual(expected); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /__tests__/router.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'tsd'; 2 | 3 | import type { ParseRouteParams } from '../src/modules/router'; 4 | 5 | { 6 | const path = '/users/:userId/books/:bookId' as const; 7 | // const url = 'http://localhost:3000/users/34/books/8989'; 8 | const params = { userId: '34', bookId: '8989' }; 9 | const parsed = '' as ParseRouteParams; 10 | expectType(parsed); 11 | } 12 | 13 | // NOT SUPPORTED 14 | // { 15 | // const path = '/flights/:from-:to' as const; 16 | // // const url = 'http://localhost:3000/flights/LAX-SFO'; 17 | // const params = { from: 'LAX', to: 'SFO' }; 18 | // const parsed = '' as ParseRouteParams; 19 | // expectType(parsed); 20 | // } 21 | 22 | // NOT SUPPORTED 23 | // { 24 | // const path = '/plantae/:genus.:species' as const; 25 | // // const url = 'http://localhost:3000/plantae/Prunus.persica'; 26 | // const params = { genus: 'Prunus', species: 'persica' }; 27 | // const parsed = '' as ParseRouteParams; 28 | // expectType(parsed); 29 | // } 30 | 31 | // NOT SUPPORTED 32 | // { 33 | // const path = '/user/:userId(d+)' as const; 34 | // // const url = 'http://localhost:3000/user/42'; 35 | // const params = { userId: '42' }; 36 | // const parsed = '' as ParseRouteParams; 37 | // expectType(parsed); 38 | // } 39 | -------------------------------------------------------------------------------- /__tests__/serializeObject.test.ts: -------------------------------------------------------------------------------- 1 | import { stableJsonStringify } from '../src/utils/serializeObject'; 2 | 3 | describe('stableJsonStringify', () => { 4 | it('should stable sort regardless of properties order', () => { 5 | expect(stableJsonStringify({ a: 123, b: 444 })).toEqual(stableJsonStringify({ b: 444, a: 123 })); 6 | }); 7 | 8 | it('should stable stringify when mutating objects', () => { 9 | const obj: Record = { a: 123, c: 444, d: 0 }; 10 | obj.b = 333; 11 | obj.e = 222; 12 | delete obj.c; 13 | 14 | expect(stableJsonStringify(obj)).toEqual(stableJsonStringify({ a: 123, b: 333, e: 222, d: 0 })); 15 | }); 16 | 17 | it('should stable sort nested objects', () => { 18 | expect(stableJsonStringify({ a: 123, c: { a: 1, c: 2, b: 3 }, b: 444 })).toEqual( 19 | stableJsonStringify({ a: 123, b: 444, c: { a: 1, b: 3, c: 2 } }), 20 | ); 21 | }); 22 | 23 | it('should work for primitives', () => { 24 | expect(stableJsonStringify(1)).toEqual('1'); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /__tests__/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["."] 4 | } 5 | -------------------------------------------------------------------------------- /__tests__/types.spec.ts: -------------------------------------------------------------------------------- 1 | import Path, { join } from 'path'; 2 | import Url from 'url'; 3 | 4 | import Globby from 'globby'; 5 | import TsdModule from 'tsd'; 6 | 7 | import type { Diagnostic } from 'tsd/dist/lib/interfaces'; 8 | 9 | // @ts-expect-error @todo 10 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- ok 11 | const tsd = (TsdModule as { readonly default: typeof TsdModule }).default; 12 | 13 | const assertTsd = (diagnostics: readonly Diagnostic[]) => { 14 | if (diagnostics.length > 0) { 15 | const errorMessage = diagnostics.map((test) => { 16 | return ( 17 | [test.fileName, test.line, test.column].filter((x) => !!x).join(':') + ` - ${test.severity} - ${test.message}` 18 | ); 19 | }); 20 | throw new Error('\n' + errorMessage.join('\n') + '\n'); 21 | } 22 | }; 23 | 24 | describe('@typeofweb/schema', () => { 25 | const typesTests = Globby.sync(['./__tests__/*.test-d.ts']); 26 | it.concurrent.each( 27 | typesTests.map((path) => ({ path, name: path.replace('./__tests__/', '').replace('.test-d.ts', '') })), 28 | )('tsd $name', async (dir) => { 29 | assertTsd( 30 | await tsd({ 31 | cwd: join(Path.dirname(Url.fileURLToPath(import.meta.url)), '..'), 32 | typingsFile: './dist/index.d.ts', 33 | testFiles: [dir.path], 34 | }), 35 | ); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /__tests__/uniqueId.test.ts: -------------------------------------------------------------------------------- 1 | import Os from 'os'; 2 | 3 | import { jest } from '@jest/globals'; 4 | 5 | import { generateRequestId, generateServerId, parseRequestId, parseServerId } from '../src/utils/uniqueId'; 6 | 7 | import type { RequestId, ServerId } from '../src/utils/uniqueId'; 8 | 9 | describe('uniqueId', () => { 10 | it('should generate unique request ids', () => { 11 | const ids = Array.from({ length: 10000 }).map(() => generateRequestId()); 12 | expect(new Set(ids).size).toEqual(10000); 13 | }); 14 | 15 | it('should generate unique server ids', () => { 16 | const ids = Array.from({ length: 10000 }).map(() => generateServerId()); 17 | expect(new Set(ids).size).toEqual(10000); 18 | }); 19 | 20 | it('should parse request id', () => { 21 | const id = generateRequestId(); 22 | expect(parseRequestId(id)).toMatchObject({ 23 | requestReceivedAt: expect.any(Date), 24 | requestCounter: expect.any(Number), 25 | }); 26 | 27 | expect(parseRequestId('60c8dabfefb893' as RequestId)).toEqual({ 28 | requestCounter: 15710355, 29 | requestReceivedAt: new Date('2021-06-15T16:52:15.000Z'), 30 | }); 31 | }); 32 | 33 | it('should parse server id', () => { 34 | const id = generateServerId(); 35 | expect(parseServerId(id)).toMatchObject({ 36 | serverStartedAt: expect.any(Date), 37 | machineId: expect.any(String), 38 | processId: expect.any(Number), 39 | serverCounter: expect.any(Number), 40 | }); 41 | 42 | expect(parseServerId('60c8dad85f3d1184a9efb894' as ServerId)).toEqual({ 43 | machineId: '5f3d11', 44 | processId: 33961, 45 | serverCounter: 15710356, 46 | serverStartedAt: new Date('2021-06-15T16:52:40.000Z'), 47 | }); 48 | }); 49 | 50 | it('should fallback to randomized mac', () => { 51 | jest.spyOn(Os, 'networkInterfaces').mockImplementation(() => { 52 | return {}; 53 | }); 54 | const ids = Array.from({ length: 10000 }).map(() => generateServerId()); 55 | expect(new Set(ids).size).toEqual(10000); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /acceptance_tests/dist/index.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @typeofweb/server_acceptance_tests@1.0.0 3 | * Copyright (c) 2021 Type of Web - Michał Miszczyszyn 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | */ 8 | 9 | 'use strict'; 10 | 11 | var schema = require('@typeofweb/schema'); 12 | var server = require('@typeofweb/server'); 13 | var sqlite3 = require('sqlite3'); 14 | var sqlite = require('sqlite'); 15 | 16 | function _interopDefaultLegacy(e) { 17 | return e && typeof e === 'object' && 'default' in e ? e : { default: e }; 18 | } 19 | 20 | var sqlite3__default = /*#__PURE__*/ _interopDefaultLegacy(sqlite3); 21 | 22 | const usersServicePlugin = server.createPlugin('usersService', (app) => { 23 | return { 24 | async server(server) { 25 | await server.plugins.db.run(`CREATE TABLE IF NOT EXISTS users ( 26 | id INTEGER PRIMARY KEY, 27 | name TEXT, 28 | age INTEGER 29 | );`); 30 | return { 31 | findUserById(id) { 32 | return server.plugins.db.get('SELECT * FROM users WHERE id = :id', { 33 | ':id': id, 34 | }); 35 | }, 36 | findAllUsers(args) { 37 | console.log(server.plugins.db); 38 | if (args) { 39 | return server.plugins.db.all('SELECT * FROM users LIMIT :limit OFFSET :skip', { 40 | ':limit': args.limit, 41 | ':skip': args.skip, 42 | }); 43 | } 44 | return server.plugins.db.all('SELECT * FROM users'); 45 | }, 46 | async createUser(data) { 47 | const res = await server.plugins.db.run('INSERT INTO users VALUES(?,?,?)', undefined, data.name, data.age); 48 | if (!res.lastID) { 49 | throw new Error(`Couldn't insert user into the database`); 50 | } 51 | return res.lastID; 52 | }, 53 | }; 54 | }, 55 | }; 56 | }); 57 | 58 | const userInputSchema = schema.object({ 59 | name: schema.string(), 60 | age: schema.number(), 61 | })(); 62 | const userSchema = schema.object({ 63 | id: schema.number(), 64 | name: schema.string(), 65 | age: schema.number(), 66 | })(); 67 | 68 | const usersPlugin = server.createPlugin('users', async (app) => { 69 | await app.plugin(usersServicePlugin); 70 | app.route({ 71 | method: 'get', 72 | path: '/users', 73 | validation: { 74 | query: { 75 | limit: schema.number(), 76 | skip: schema.number(), 77 | }, 78 | response: schema.array(userSchema)(), 79 | }, 80 | handler(request) { 81 | return request.server.plugins.usersService.findAllUsers(request.query); 82 | }, 83 | }); 84 | app.route({ 85 | method: 'post', 86 | path: '/users', 87 | validation: { 88 | payload: userInputSchema, 89 | response: userSchema, 90 | }, 91 | async handler(request) { 92 | const userId = await request.server.plugins.usersService.createUser(request.payload); 93 | const user = await request.server.plugins.usersService.findUserById(userId); 94 | if (!user) { 95 | throw new server.HttpError(server.HttpStatusCode.InternalServerError); 96 | } 97 | request.server.events.emit('user-created', user); 98 | return user; 99 | }, 100 | }); 101 | app.route({ 102 | method: 'get', 103 | path: '/users/:userId', 104 | validation: { 105 | params: { 106 | userId: schema.number(), 107 | }, 108 | response: userSchema, 109 | }, 110 | async handler(request) { 111 | const { userId } = request.params; 112 | const user = await request.server.plugins.usersService.findUserById(userId); 113 | if (!user) { 114 | throw new server.HttpError(server.HttpStatusCode.NotFound, `User with id=${userId} not found!`); 115 | } 116 | return user; 117 | }, 118 | }); 119 | }); 120 | 121 | const dbPlugin = server.createPlugin('db', () => { 122 | return { 123 | async server() { 124 | const db = await sqlite.open({ 125 | filename: '/tmp/database.db', 126 | driver: sqlite3__default['default'].Database, 127 | }); 128 | return db; 129 | }, 130 | }; 131 | }); 132 | 133 | const loggerPlugin = server.createPlugin('logger', (app) => { 134 | const requestsMap = new WeakMap(); 135 | app.events.on(':server', () => console.info('Server started!')); 136 | app.events.on(':request', (r) => { 137 | console.info(`Request coming through!`, r.id); 138 | requestsMap.set(r, r.timestamp); 139 | }); 140 | app.events.on(':afterResponse', (r) => { 141 | const requestTimestamp = requestsMap.get(r.request); 142 | console.info(`The server has responded.`, r.timestamp - (requestTimestamp ?? 0)); 143 | }); 144 | }); 145 | 146 | const getAppWithPlugins = async () => { 147 | const app = server.createApp({ 148 | openapi: { 149 | title: 'Example API', 150 | description: '', 151 | version: '0.0.1', 152 | path: '/documentation', 153 | }, 154 | }); 155 | await app.plugin(loggerPlugin); 156 | await app.plugin(dbPlugin); 157 | await app.plugin(usersPlugin); 158 | app.route({ 159 | path: '/health-check/:count', 160 | method: 'get', 161 | validation: { 162 | params: { 163 | count: schema.number(), 164 | }, 165 | response: schema.number(), 166 | }, 167 | handler(request) { 168 | const { params } = request; 169 | return params.count; 170 | }, 171 | }); 172 | return app; 173 | }; 174 | 175 | const init = async () => { 176 | const app = await getAppWithPlugins(); 177 | const server = await app.start(); 178 | console.log(`🙌 Server started at ${server.address?.toString()}`); 179 | }; 180 | init().catch(console.error); 181 | -------------------------------------------------------------------------------- /acceptance_tests/dist/index.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @typeofweb/server_acceptance_tests@1.0.0 3 | * Copyright (c) 2021 Type of Web - Michał Miszczyszyn 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | */ 8 | 9 | import { object, string, number, array } from '@typeofweb/schema'; 10 | import { createPlugin, HttpError, HttpStatusCode, createApp } from '@typeofweb/server'; 11 | import sqlite3 from 'sqlite3'; 12 | import { open } from 'sqlite'; 13 | 14 | const usersServicePlugin = createPlugin('usersService', (app) => { 15 | return { 16 | async server(server) { 17 | await server.plugins.db.run(`CREATE TABLE IF NOT EXISTS users ( 18 | id INTEGER PRIMARY KEY, 19 | name TEXT, 20 | age INTEGER 21 | );`); 22 | return { 23 | findUserById(id) { 24 | return server.plugins.db.get('SELECT * FROM users WHERE id = :id', { 25 | ':id': id, 26 | }); 27 | }, 28 | findAllUsers(args) { 29 | console.log(server.plugins.db); 30 | if (args) { 31 | return server.plugins.db.all('SELECT * FROM users LIMIT :limit OFFSET :skip', { 32 | ':limit': args.limit, 33 | ':skip': args.skip, 34 | }); 35 | } 36 | return server.plugins.db.all('SELECT * FROM users'); 37 | }, 38 | async createUser(data) { 39 | const res = await server.plugins.db.run('INSERT INTO users VALUES(?,?,?)', undefined, data.name, data.age); 40 | if (!res.lastID) { 41 | throw new Error(`Couldn't insert user into the database`); 42 | } 43 | return res.lastID; 44 | }, 45 | }; 46 | }, 47 | }; 48 | }); 49 | 50 | const userInputSchema = object({ 51 | name: string(), 52 | age: number(), 53 | })(); 54 | const userSchema = object({ 55 | id: number(), 56 | name: string(), 57 | age: number(), 58 | })(); 59 | 60 | const usersPlugin = createPlugin('users', async (app) => { 61 | await app.plugin(usersServicePlugin); 62 | app.route({ 63 | method: 'get', 64 | path: '/users', 65 | validation: { 66 | query: { 67 | limit: number(), 68 | skip: number(), 69 | }, 70 | response: array(userSchema)(), 71 | }, 72 | handler(request) { 73 | return request.server.plugins.usersService.findAllUsers(request.query); 74 | }, 75 | }); 76 | app.route({ 77 | method: 'post', 78 | path: '/users', 79 | validation: { 80 | payload: userInputSchema, 81 | response: userSchema, 82 | }, 83 | async handler(request) { 84 | const userId = await request.server.plugins.usersService.createUser(request.payload); 85 | const user = await request.server.plugins.usersService.findUserById(userId); 86 | if (!user) { 87 | throw new HttpError(HttpStatusCode.InternalServerError); 88 | } 89 | request.server.events.emit('user-created', user); 90 | return user; 91 | }, 92 | }); 93 | app.route({ 94 | method: 'get', 95 | path: '/users/:userId', 96 | validation: { 97 | params: { 98 | userId: number(), 99 | }, 100 | response: userSchema, 101 | }, 102 | async handler(request) { 103 | const { userId } = request.params; 104 | const user = await request.server.plugins.usersService.findUserById(userId); 105 | if (!user) { 106 | throw new HttpError(HttpStatusCode.NotFound, `User with id=${userId} not found!`); 107 | } 108 | return user; 109 | }, 110 | }); 111 | }); 112 | 113 | const dbPlugin = createPlugin('db', () => { 114 | return { 115 | async server() { 116 | const db = await open({ 117 | filename: '/tmp/database.db', 118 | driver: sqlite3.Database, 119 | }); 120 | return db; 121 | }, 122 | }; 123 | }); 124 | 125 | const loggerPlugin = createPlugin('logger', (app) => { 126 | const requestsMap = new WeakMap(); 127 | app.events.on(':server', () => console.info('Server started!')); 128 | app.events.on(':request', (r) => { 129 | console.info(`Request coming through!`, r.id); 130 | requestsMap.set(r, r.timestamp); 131 | }); 132 | app.events.on(':afterResponse', (r) => { 133 | const requestTimestamp = requestsMap.get(r.request); 134 | console.info(`The server has responded.`, r.timestamp - (requestTimestamp ?? 0)); 135 | }); 136 | }); 137 | 138 | const getAppWithPlugins = async () => { 139 | const app = createApp({ 140 | openapi: { 141 | title: 'Example API', 142 | description: '', 143 | version: '0.0.1', 144 | path: '/documentation', 145 | }, 146 | }); 147 | await app.plugin(loggerPlugin); 148 | await app.plugin(dbPlugin); 149 | await app.plugin(usersPlugin); 150 | app.route({ 151 | path: '/health-check/:count', 152 | method: 'get', 153 | validation: { 154 | params: { 155 | count: number(), 156 | }, 157 | response: number(), 158 | }, 159 | handler(request) { 160 | const { params } = request; 161 | return params.count; 162 | }, 163 | }); 164 | return app; 165 | }; 166 | 167 | const init = async () => { 168 | const app = await getAppWithPlugins(); 169 | const server = await app.start(); 170 | console.log(`🙌 Server started at ${server.address?.toString()}`); 171 | }; 172 | init().catch(console.error); 173 | -------------------------------------------------------------------------------- /acceptance_tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@typeofweb/server_acceptance_tests", 3 | "private": true, 4 | "version": "1.0.0", 5 | "license": "MIT", 6 | "type": "module", 7 | "dependencies": { 8 | "@typeofweb/schema": "0.8.0-6", 9 | "@typeofweb/server": "0.1.1", 10 | "sqlite": "4.0.23", 11 | "sqlite3": "5.0.2", 12 | "typescript": "4.3.4" 13 | }, 14 | "devDependencies": { 15 | "@rollup/plugin-commonjs": "19.0.0", 16 | "@rollup/plugin-json": "4.1.0", 17 | "@rollup/plugin-typescript": "8.2.1", 18 | "builtin-modules": "3.2.0", 19 | "concurrently": "6.2.0", 20 | "nodemon": "2.0.7", 21 | "prettier": "2.3.2", 22 | "rimraf": "3.0.2", 23 | "rollup": "2.52.3", 24 | "rollup-plugin-license": "2.5.0", 25 | "rollup-plugin-prettier": "2.1.0", 26 | "wait-on": "6.0.0" 27 | }, 28 | "scripts": { 29 | "dev:cjs": "yarn build && concurrently 'npm:rollup:watch' 'nodemon dist/index.cjs --on-change-only'", 30 | "dev:esm": "yarn build && concurrently 'npm:rollup:watch' 'nodemon dist/index.mjs --on-change-only'", 31 | "build": "rimraf dist && rollup --config", 32 | "rollup:watch": "rollup --config --watch" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /acceptance_tests/rollup.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import BuiltinModules from 'builtin-modules'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import typescript from '@rollup/plugin-typescript'; 5 | import prettier from 'rollup-plugin-prettier'; 6 | import license from 'rollup-plugin-license'; 7 | import json from '@rollup/plugin-json'; 8 | 9 | import pkg from './package.json'; 10 | 11 | const dependencies = Object.keys(pkg.dependencies); 12 | 13 | /** 14 | * @type {import('rollup').RollupOptions[]} 15 | */ 16 | const rollupConfig = [ 17 | { 18 | input: 'src/index.ts', 19 | output: [ 20 | { 21 | name: '@typeofweb/server', 22 | format: 'es', 23 | dir: './dist', 24 | entryFileNames: 'index.mjs', 25 | }, 26 | { 27 | name: '@typeofweb/server', 28 | format: 'cjs', 29 | dir: './dist', 30 | entryFileNames: 'index.cjs', 31 | }, 32 | ], 33 | plugins: [ 34 | json(), 35 | commonjs({ 36 | include: 'node_modules/**', 37 | }), 38 | typescript({ 39 | tsconfig: 'tsconfig.json', 40 | rootDir: 'src/', 41 | include: ['src/**/*.ts'], 42 | }), 43 | license({ 44 | banner: ` 45 | <%= pkg.name %>@<%= pkg.version %> 46 | Copyright (c) <%= moment().format('YYYY') %> Type of Web - Michał Miszczyszyn 47 | 48 | This source code is licensed under the MIT license found in the 49 | LICENSE file in the root directory of this source tree. 50 | `.trim(), 51 | }), 52 | prettier({ 53 | parser: 'typescript', 54 | }), 55 | ], 56 | external: [...dependencies, ...BuiltinModules], 57 | }, 58 | ]; 59 | // eslint-disable-next-line import/no-default-export 60 | export default rollupConfig; 61 | -------------------------------------------------------------------------------- /acceptance_tests/src/app.ts: -------------------------------------------------------------------------------- 1 | import { number } from '@typeofweb/schema'; 2 | import { createApp } from '@typeofweb/server'; 3 | import { usersPlugin } from './modules/users/usersPlugin'; 4 | import { dbPlugin } from './plugins/db/dbPlugin'; 5 | import { loggerPlugin } from './plugins/logger/loggerPlugin'; 6 | 7 | export const getAppWithPlugins = async () => { 8 | const app = createApp({ 9 | openapi: { 10 | title: 'Example API', 11 | description: '', 12 | version: '0.0.1', 13 | path: '/documentation', 14 | }, 15 | }); 16 | 17 | await app.plugin(loggerPlugin); 18 | await app.plugin(dbPlugin); 19 | await app.plugin(usersPlugin); 20 | 21 | app.route({ 22 | path: '/health-check/:count', 23 | method: 'get', 24 | validation: { 25 | params: { 26 | count: number(), 27 | }, 28 | response: number(), 29 | }, 30 | handler(request) { 31 | const { params } = request; 32 | return params.count; 33 | }, 34 | }); 35 | 36 | return app; 37 | }; 38 | -------------------------------------------------------------------------------- /acceptance_tests/src/index.ts: -------------------------------------------------------------------------------- 1 | import { getAppWithPlugins } from './app'; 2 | 3 | const init = async () => { 4 | const app = await getAppWithPlugins(); 5 | const server = await app.start(); 6 | console.log(`🙌 Server started at ${server.address?.toString()}`); 7 | }; 8 | 9 | init().catch(console.error); 10 | -------------------------------------------------------------------------------- /acceptance_tests/src/modules/users/usersPlugin.ts: -------------------------------------------------------------------------------- 1 | import { array, number, object, string } from '@typeofweb/schema'; 2 | import { createPlugin, HttpError, HttpStatusCode } from '@typeofweb/server'; 3 | import { usersServicePlugin } from './usersServicePlugin'; 4 | import { userInputSchema, userSchema } from './usersValidators'; 5 | 6 | export const usersPlugin = createPlugin('users', async (app) => { 7 | await app.plugin(usersServicePlugin); 8 | 9 | app.route({ 10 | method: 'get', 11 | path: '/users', 12 | validation: { 13 | query: { 14 | limit: number(), 15 | skip: number(), 16 | }, 17 | response: array(userSchema)(), 18 | }, 19 | handler(request) { 20 | return request.server.plugins.usersService.findAllUsers(request.query); 21 | }, 22 | }); 23 | 24 | app.route({ 25 | method: 'post', 26 | path: '/users', 27 | validation: { 28 | payload: userInputSchema, 29 | response: userSchema, 30 | }, 31 | async handler(request) { 32 | const userId = await request.server.plugins.usersService.createUser(request.payload); 33 | const user = await request.server.plugins.usersService.findUserById(userId); 34 | 35 | if (!user) { 36 | throw new HttpError(HttpStatusCode.InternalServerError); 37 | } 38 | 39 | request.server.events.emit('user-created', user); 40 | 41 | return user; 42 | }, 43 | }); 44 | 45 | app.route({ 46 | method: 'get', 47 | path: '/users/:userId', 48 | validation: { 49 | params: { 50 | userId: number(), 51 | }, 52 | response: userSchema, 53 | }, 54 | async handler(request) { 55 | const { userId } = request.params; 56 | const user = await request.server.plugins.usersService.findUserById(userId); 57 | 58 | if (!user) { 59 | throw new HttpError(HttpStatusCode.NotFound, `User with id=${userId} not found!`); 60 | } 61 | return user; 62 | }, 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /acceptance_tests/src/modules/users/usersServicePlugin.ts: -------------------------------------------------------------------------------- 1 | import { createPlugin } from '@typeofweb/server'; 2 | 3 | declare module '@typeofweb/server' { 4 | interface TypeOfWebServerMeta { 5 | usersService: { 6 | findUserById: (id: User['id']) => Promise; 7 | findAllUsers: (args?: { skip: number; limit: number }) => Promise; 8 | createUser: (data: Omit) => Promise; 9 | }; 10 | } 11 | 12 | interface TypeOfWebEvents { 13 | 'user-created': User; 14 | } 15 | } 16 | 17 | interface User { 18 | id: number; 19 | name: string; 20 | age: number; 21 | } 22 | 23 | export const usersServicePlugin = createPlugin('usersService', (app) => { 24 | return { 25 | async server(server) { 26 | await server.plugins.db.run(`CREATE TABLE IF NOT EXISTS users ( 27 | id INTEGER PRIMARY KEY, 28 | name TEXT, 29 | age INTEGER 30 | );`); 31 | 32 | return { 33 | findUserById(id) { 34 | return server.plugins.db.get('SELECT * FROM users WHERE id = :id', { 35 | ':id': id, 36 | }); 37 | }, 38 | findAllUsers(args) { 39 | console.log(server.plugins.db); 40 | if (args) { 41 | return server.plugins.db.all('SELECT * FROM users LIMIT :limit OFFSET :skip', { 42 | ':limit': args.limit, 43 | ':skip': args.skip, 44 | }); 45 | } 46 | return server.plugins.db.all('SELECT * FROM users'); 47 | }, 48 | async createUser(data) { 49 | const res = await server.plugins.db.run('INSERT INTO users VALUES(?,?,?)', undefined, data.name, data.age); 50 | 51 | if (!res.lastID) { 52 | throw new Error(`Couldn't insert user into the database`); 53 | } 54 | 55 | return res.lastID; 56 | }, 57 | }; 58 | }, 59 | }; 60 | }); 61 | -------------------------------------------------------------------------------- /acceptance_tests/src/modules/users/usersValidators.ts: -------------------------------------------------------------------------------- 1 | import { object, string, number } from '@typeofweb/schema'; 2 | 3 | export const userInputSchema = object({ 4 | name: string(), 5 | age: number(), 6 | })(); 7 | 8 | export const userSchema = object({ 9 | id: number(), 10 | name: string(), 11 | age: number(), 12 | })(); 13 | -------------------------------------------------------------------------------- /acceptance_tests/src/plugins/db/dbPlugin.ts: -------------------------------------------------------------------------------- 1 | import { createPlugin } from '@typeofweb/server'; 2 | import sqlite3 from 'sqlite3'; 3 | import { open, Database } from 'sqlite'; 4 | 5 | declare module '@typeofweb/server' { 6 | interface TypeOfWebServerMeta { 7 | readonly db: Database; 8 | } 9 | } 10 | 11 | export const dbPlugin = createPlugin('db', () => { 12 | return { 13 | async server() { 14 | const db = await open({ 15 | filename: '/tmp/database.db', 16 | driver: sqlite3.Database, 17 | }); 18 | return db; 19 | }, 20 | }; 21 | }); 22 | -------------------------------------------------------------------------------- /acceptance_tests/src/plugins/logger/loggerPlugin.ts: -------------------------------------------------------------------------------- 1 | import { createPlugin, TypeOfWebRequest } from '@typeofweb/server'; 2 | 3 | export const loggerPlugin = createPlugin('logger', (app) => { 4 | const requestsMap = new WeakMap(); 5 | 6 | app.events.on(':server', () => console.info('Server started!')); 7 | 8 | app.events.on(':request', (r) => { 9 | console.info(`Request coming through!`, r.id); 10 | requestsMap.set(r, r.timestamp); 11 | }); 12 | 13 | app.events.on(':afterResponse', (r) => { 14 | const elapsed = r.timestamp - r.request.timestamp; 15 | console.info(`The server has responded.`, elapsed); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /acceptance_tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "sourceMap": false, 6 | "isolatedModules": true, 7 | "strict": true, 8 | "moduleResolution": "node", 9 | "esModuleInterop": true, 10 | "skipLibCheck": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /docs/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "next" 4 | } 5 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /docs/md-layout.js: -------------------------------------------------------------------------------- 1 | const GrayMatter = require('gray-matter'); 2 | 3 | // makes mdx in next.js suck less by injecting necessary exports so that 4 | // the docs are still readable on github 5 | // (Shamelessly stolen from React Query docs) 6 | // (Which was shamelessly stolen from Expo.io docs) 7 | // @see https://github.com/tannerlinsley/react-query/blob/16b7d290c70639b627d9ada32951d211eac3adc3/docs/src/lib/docs/md-loader.js 8 | // @see https://github.com/expo/expo/blob/303cb7b689603223401c091c6a2e1e01f182d355/docs/common/md-loader.js 9 | 10 | module.exports = function addLayoutToMdx(source) { 11 | const callback = this.async(); 12 | 13 | const { content, data } = GrayMatter(source); 14 | const code = 15 | `import { Layout } from '/src/components/Layout'; 16 | export const meta = ${JSON.stringify(data)}; 17 | export default ({ children, ...props }) => ( 18 | {children} 19 | ); 20 | ` + content.replace(//g, ''); 21 | 22 | return callback(null, code); 23 | }; 24 | -------------------------------------------------------------------------------- /docs/md-loader.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const GrayMatter = require('gray-matter'); 3 | const Unified = require('unified'); 4 | const RemarkParse = require('remark-parse'); 5 | const RemarkRehype = require('remark-rehype'); 6 | const RehypeStringify = require('rehype-stringify'); 7 | const RehypeRaw = require('rehype-raw'); 8 | 9 | /** 10 | * @typedef {{remarkPlugins: import('unified').PluggableList, rehypePlugins: import('unified').PluggableList}} MdLoaderOptions 11 | */ 12 | 13 | /** 14 | * @param {string} source 15 | * @param {MdLoaderOptions} options 16 | * @returns {import('vfile').VFile} 17 | */ 18 | function toHtmlString(source, options) { 19 | const processor = Unified() 20 | .use(RemarkParse) 21 | .use(options.remarkPlugins) 22 | .use(RemarkRehype, { allowDangerousHtml: true }) 23 | .use(RehypeRaw) 24 | .use(options.rehypePlugins) 25 | .use(RehypeStringify); 26 | 27 | return processor.processSync(source); 28 | } 29 | 30 | /** 31 | * @type {import('webpack').LoaderDefinitionFunction} 32 | * @this {import('webpack').LoaderContext} 33 | */ 34 | module.exports = function loadMarkdownWithHtml(source, sourceMap, additionalData) { 35 | const callback = this.async(); 36 | 37 | const { content, data } = GrayMatter(source); 38 | 39 | const html = toHtmlString(content, this.getOptions()).contents; 40 | 41 | const code = ` 42 | import { ReferenceLayout } from '/src/components/Layout'; 43 | export const meta = ${JSON.stringify(data)}; 44 | const Page = ({ children, ...props }) => ( 45 |
48 | ); 49 | export default Page; 50 | `.trim(); 51 | 52 | return callback(null, code); 53 | }; 54 | -------------------------------------------------------------------------------- /docs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | -------------------------------------------------------------------------------- /docs/next.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const Path = require('path'); 4 | 5 | /** 6 | * @type {import('unified').PluggableList} 7 | */ 8 | const remarkPlugins = [ 9 | require('remark-gfm'), 10 | require('remark-frontmatter'), 11 | // require('remark-slug'), 12 | // require('remark-autolink-headings'), 13 | // require('remark-emoji'), 14 | // require('remark-images'), 15 | [require('remark-github'), { repository: 'typeofweb/server' }], 16 | // require('remark-unwrap-images'), 17 | // [require('remark-prism'), { plugins: ['inline-color'] }], 18 | // require('remark-toc'), 19 | ]; 20 | 21 | const rehypePlugins = [ 22 | require('rehype-slug'), 23 | require('rehype-autolink-headings'), 24 | require('@jsdevtools/rehype-toc'), 25 | require('@mapbox/rehype-prism'), 26 | ]; 27 | 28 | /** 29 | * @type {Partial} 30 | */ 31 | const config = { 32 | pageExtensions: ['tsx', 'ts', 'mdx', 'md'], 33 | webpack: (config, { dev, isServer, ...options }) => { 34 | config.module.rules.push({ 35 | test: /.md$/, 36 | use: [ 37 | options.defaultLoaders.babel, 38 | { 39 | loader: Path.join(__dirname, './md-loader'), 40 | options: { 41 | remarkPlugins, 42 | rehypePlugins, 43 | }, 44 | }, 45 | ], 46 | }); 47 | 48 | config.module.rules.push({ 49 | test: /.mdx$/, 50 | use: [ 51 | options.defaultLoaders.babel, 52 | { 53 | loader: '@mdx-js/loader', 54 | options: { 55 | remarkPlugins, 56 | rehypePlugins, 57 | }, 58 | }, 59 | Path.join(__dirname, './md-layout'), 60 | ], 61 | }); 62 | 63 | return config; 64 | }, 65 | }; 66 | 67 | config.redirects = async () => { 68 | return [ 69 | { 70 | source: '/:paths(.*).m(dx?)', 71 | destination: '/:paths', 72 | permanent: false, 73 | }, 74 | { 75 | source: '/reference', 76 | destination: '/reference/index', 77 | permanent: false, 78 | }, 79 | ]; 80 | }; 81 | 82 | config.rewrites = async () => { 83 | return [ 84 | { 85 | source: '/reference/index', 86 | destination: '/reference', 87 | }, 88 | ]; 89 | }; 90 | 91 | module.exports = config; 92 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "commonjs", 6 | "scripts": { 7 | "dev": "next dev", 8 | "build": "yarn build:api && yarn build:next", 9 | "start": "next start", 10 | "build:next": "next build", 11 | "build:api": "yarn ts-node --compiler-options '{\"module\":\"commonjs\"}' tools/apiExtractorUtils.ts" 12 | }, 13 | "dependencies": { 14 | "@jsdevtools/rehype-toc": "3.0.2", 15 | "@mapbox/rehype-prism": "0.7.0", 16 | "@types/github-slugger": "1.3.0", 17 | "@types/prettier": "2.3.2", 18 | "github-slugger": "1.3.0", 19 | "htmltojsx": "0.3.0", 20 | "markdown-to-jsx": "7.1.3", 21 | "mdast-builder": "1.1.1", 22 | "next": "11.0.1", 23 | "prettier": "2.3.2", 24 | "react": "17.0.2", 25 | "react-dom": "17.0.2", 26 | "sass": "^1.35.1", 27 | "rehype-autolink-headings": "5.1.0", 28 | "rehype-document": "5.1.0", 29 | "rehype-format": "3.1.0", 30 | "rehype-raw": "5.1.0", 31 | "rehype-slug": "4.0.1", 32 | "rehype-stringify": "8.0.0", 33 | "remark-frontmatter": "3.0.0", 34 | "remark-gfm": "1.0.0", 35 | "remark-parse": "9.0.0", 36 | "remark-rehype": "8.1.0", 37 | "remark-stringify": "9.0.1", 38 | "typescript": "4.3.4", 39 | "unified": "9.2.1" 40 | }, 41 | "devDependencies": { 42 | "@mdx-js/loader": "1.6.22", 43 | "@microsoft/api-documenter": "7.13.26", 44 | "@microsoft/api-extractor-model": "7.13.3", 45 | "@next/mdx": "11.0.1", 46 | "@types/mdx-js__react": "1.5.3", 47 | "@types/react": "17.0.13", 48 | "@types/react-dom": "17.0.8", 49 | "eslint": "7.29.0", 50 | "eslint-config-next": "11.0.1", 51 | "gray-matter": "4.0.3", 52 | "next-optimized-images": "2.6.2", 53 | "rehype-retext": "2.0.4", 54 | "remark-autolink-headings": "6.0.1", 55 | "remark-emoji": "2.2.0", 56 | "remark-footnotes": "3.0.0", 57 | "remark-github": "10.1.0", 58 | "remark-images": "2.0.0", 59 | "remark-prism": "1.3.6", 60 | "remark-remove-comments": "0.2.0", 61 | "remark-retext": "4.0.0", 62 | "remark-slug": "6.0.0", 63 | "remark-toc": "7.2.0", 64 | "remark-unwrap-images": "2.1.0", 65 | "ts-node": "10.0.0", 66 | "ts-node-dev": "1.1.8" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/server/e789666a8b0ccd82b71633e5abfdff62b7447bf2/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/icons/chevron-down.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/public/icons/chevron-up.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/public/icons/menu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /docs/remark-plugins.js: -------------------------------------------------------------------------------- 1 | const visit = require('unist-util-visit'); 2 | const is = require('unist-util-is'); 3 | 4 | // Shamelessly stolen from React Query docs 5 | // @see https://github.com/tannerlinsley/react-query/blob/16b7d290c70639b627d9ada32951d211eac3adc3/docs/src/lib/docs/remark-paragraph-alerts.js 6 | module.exports.remarkParagraphAlerts = function remarkParagraphAlerts() { 7 | const sigils = { 8 | '==': 'success', 9 | '--': 'info', 10 | '^^': 'warning', 11 | '!!': 'danger', 12 | }; 13 | 14 | return function transformer(tree) { 15 | visit(tree, 'paragraph', (pNode, _, parent) => { 16 | visit(pNode, 'text', (textNode) => { 17 | Object.keys(sigils).forEach((symbol) => { 18 | if (textNode.value.startsWith(`${symbol} `)) { 19 | // Remove the literal sigil symbol from string contents 20 | textNode.value = textNode.value.replace(`${symbol} `, ''); 21 | 22 | // Wrap matched nodes with
(containing proper attributes) 23 | parent.children = parent.children.map((node) => { 24 | return is(pNode, node) 25 | ? { 26 | type: 'wrapper', 27 | children: [node], 28 | data: { 29 | hName: 'div', 30 | hProperties: { 31 | className: ['alert', `alert-${sigils[symbol]}`, 'g-type-body'], 32 | role: 'alert', 33 | }, 34 | }, 35 | } 36 | : node; 37 | }); 38 | } 39 | }); 40 | }); 41 | }); 42 | }; 43 | }; 44 | 45 | module.exports.fixMarkdownLinks = function remarkParagraphAlerts() { 46 | return function transformer(tree) { 47 | visit(tree, ['link', 'linkReference'], (node) => { 48 | if (typeof node.url === 'string') { 49 | if (node.url.startsWith('./') && node.url.endsWith('.md')) { 50 | node.url = node.url.replace(/\.md$/, ''); 51 | } 52 | } 53 | }); 54 | }; 55 | }; 56 | 57 | module.exports.toc = function toc(options) { 58 | const util = require('mdast-util-toc'); 59 | const settings = options || {}; 60 | 61 | return function transformer(node) { 62 | const result = util(node, settings); 63 | if (!result.map) { 64 | return; 65 | } 66 | 67 | result.map.data = result.map.data || {}; 68 | result.map.data.hProperties = result.map.data.hProperties || {}; 69 | result.map.data.hProperties.className = result.map.data.hProperties.className || []; 70 | result.map.data.hProperties.className.push('typeofweb-toc'); 71 | node.children = [].concat(node.children, result.map); 72 | }; 73 | }; 74 | -------------------------------------------------------------------------------- /docs/src/components/Accordion/Accordion.module.scss: -------------------------------------------------------------------------------- 1 | .accordionWrapper { 2 | margin-bottom: 0.45rem; 3 | } 4 | 5 | .accordionHeader { 6 | display: flex; 7 | justify-content: space-between; 8 | align-items: center; 9 | cursor: pointer; 10 | 11 | &:hover { 12 | .accordionHeading { 13 | color: var(--active); 14 | } 15 | } 16 | } 17 | 18 | .accordionHeading { 19 | font-size: 0.875rem; 20 | color: var(--gray-900); 21 | font-weight: 600; 22 | text-transform: uppercase; 23 | transition: 300ms color ease-in; 24 | 25 | &Active { 26 | color: var(--active); 27 | } 28 | } 29 | 30 | .accordionChevron { 31 | transform: rotate(0); 32 | 33 | &Expanded { 34 | transform: rotate(180deg); 35 | } 36 | } 37 | 38 | .accordionSectionList { 39 | padding: 0 0.75rem; 40 | list-style: none; 41 | } 42 | 43 | .accordionSection { 44 | padding: 0.125rem 0; 45 | } 46 | 47 | .accordionSectionAnchor { 48 | font-size: 0.75rem; 49 | color: var(--gray-700); 50 | } 51 | -------------------------------------------------------------------------------- /docs/src/components/Accordion/Accordion.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useCallback, useState } from 'react'; 2 | import styles from './Accordion.module.scss'; 3 | 4 | type AccordionProps = { 5 | heading: string; 6 | isActive: boolean; 7 | sections?: string[]; 8 | }; 9 | export const Accordion = memo(({ heading, isActive, sections }) => { 10 | const [expanded, setExpanded] = useState(false); 11 | const hasSections = sections && sections.length > 0; 12 | const toggleExpanded = useCallback(() => setExpanded((prev) => !prev), []); 13 | return ( 14 |
15 |
16 |

{heading}

17 | {hasSections && ( 18 | {expanded 23 | )} 24 |
25 | {hasSections && expanded && ( 26 | 35 | )} 36 |
37 | ); 38 | }); 39 | Accordion.displayName = 'Accordion'; 40 | -------------------------------------------------------------------------------- /docs/src/components/Header/Header.module.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | position: sticky; 3 | top: 0; 4 | width: 100%; 5 | padding: 1.35rem 1.25rem; 6 | background-color: var(--nav-background); 7 | min-height: var(--header-height); 8 | z-index: 100; 9 | display: flex; 10 | justify-content: flex-start; 11 | align-items: center; 12 | 13 | @media screen and (min-width: 55rem) { 14 | padding: 0; 15 | } 16 | } 17 | 18 | .srOnly { 19 | position: absolute; 20 | left: -10000px; 21 | top: auto; 22 | width: 1px; 23 | height: 1px; 24 | overflow: hidden; 25 | } 26 | 27 | .toggleButton { 28 | background-color: transparent; 29 | border: none; 30 | @media screen and (min-width: 55rem) { 31 | display: flex; 32 | justify-content: center; 33 | align-items: center; 34 | width: var(--reference-width); 35 | } 36 | } 37 | 38 | .menuIcon { 39 | cursor: pointer; 40 | filter: invert(100%); 41 | } 42 | 43 | .nav { 44 | display: none; 45 | 46 | @media screen and (min-width: 75rem) { 47 | display: block; 48 | width: 100%; 49 | max-width: calc(100% - var(--reference-width) - var(--table-of-contents-width)); 50 | margin: 0 var(--table-of-contents-width) 0 auto; 51 | background-color: var(--nav-background); 52 | } 53 | } 54 | 55 | .navList { 56 | display: flex; 57 | justify-content: flex-end; 58 | list-style: none; 59 | } 60 | 61 | .navItem { 62 | padding: 0.25rem 1rem; 63 | } 64 | 65 | .navAnchor { 66 | font-size: 1rem; 67 | font-weight: 400; 68 | color: var(--white); 69 | } 70 | -------------------------------------------------------------------------------- /docs/src/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | 3 | import styles from './Header.module.scss'; 4 | 5 | type HeaderProps = { 6 | toggleMenuOpened: () => void; 7 | }; 8 | 9 | export const Header = memo(({ toggleMenuOpened }) => { 10 | return ( 11 |
12 | 16 | 35 |
36 | ); 37 | }); 38 | Header.displayName = 'Header'; 39 | -------------------------------------------------------------------------------- /docs/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react'; 2 | import { memo } from 'react'; 3 | import Head from 'next/head'; 4 | import { FrontmatterMeta } from '../../tools/types'; 5 | 6 | interface LayoutProps { 7 | meta?: FrontmatterMeta; 8 | } 9 | 10 | export const ReferenceLayout = memo>(({ meta, children }) => { 11 | const titleSuffix = meta?.title ? ` · ${meta.title}` : ''; 12 | return ( 13 | <> 14 | @typeofweb/server{titleSuffix} 15 | 27 | 28 | ); 29 | }); 30 | ReferenceLayout.displayName = 'Layout'; 31 | -------------------------------------------------------------------------------- /docs/src/components/Reference/Reference.module.scss: -------------------------------------------------------------------------------- 1 | .reference { 2 | position: fixed; 3 | top: 0; 4 | width: 100%; 5 | height: 100vh; 6 | background-color: var(--section-background); 7 | padding: 1.25rem; 8 | padding-top: calc(2rem + var(--header-height)); 9 | transform: translateX(-100%); 10 | transition: 200ms transform ease-in; 11 | 12 | &Opened { 13 | transform: translateX(0); 14 | } 15 | 16 | @media screen and (min-width: 55rem) { 17 | // padding-top: 2rem; 18 | // height: auto; 19 | // max-height: calc(100vh - var(--header-height)); 20 | // transform: translateX(0); 21 | // position: sticky; 22 | // top: var(--header-height); 23 | width: var(--reference-width); 24 | // background-color: var(--section-background); 25 | // flex-shrink: 0; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /docs/src/components/Reference/Reference.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { Accordion } from '../Accordion/Accordion'; 3 | import styles from './Reference.module.scss'; 4 | 5 | export type ReferenceType = { 6 | heading: string; 7 | sections?: string[]; 8 | }[]; 9 | 10 | type ReferenceProps = { 11 | reference: ReferenceType; 12 | menuOpened: boolean; 13 | }; 14 | 15 | export const Reference = memo(({ reference, menuOpened }) => ( 16 |
17 | {reference.map(({ heading, sections }) => ( 18 | // isActive should be derived from the current url 19 | 20 | ))} 21 |
22 | )); 23 | Reference.displayName = 'Reference'; 24 | -------------------------------------------------------------------------------- /docs/src/components/TableOfContents/TableOfContents.module.scss: -------------------------------------------------------------------------------- 1 | .tableOfContentsWrapper { 2 | display: none; 3 | 4 | @media screen and (min-width: 75rem) { 5 | display: block; 6 | width: var(--table-of-contents-width); 7 | flex-shrink: 0; 8 | padding: 3rem 2rem; 9 | } 10 | } 11 | 12 | .list { 13 | display: flex; 14 | flex-direction: column; 15 | list-style: none; 16 | } 17 | 18 | .listItem { 19 | position: relative; 20 | padding: 0.25rem 1rem; 21 | } 22 | 23 | .anchor { 24 | position: relative; 25 | font-size: 0.875rem; 26 | font-weight: 400; 27 | text-decoration: none; 28 | color: var(--text-700); 29 | 30 | &::before { 31 | content: ''; 32 | position: absolute; 33 | left: -1.5rem; 34 | top: 50%; 35 | width: 0.5rem; 36 | height: 0.5rem; 37 | transform: translateY(-50%); 38 | border: 1px solid var(--gray-500); 39 | border-radius: 50%; 40 | } 41 | 42 | &Active { 43 | font-weight: 600; 44 | color: var(--active); 45 | 46 | &::before { 47 | border: 1px solid var(--active); 48 | background-color: var(--active); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /docs/src/components/TableOfContents/TableOfContents.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import styles from './TableOfContents.module.scss'; 3 | 4 | type TableOfContentsProps = { 5 | headings: string[]; 6 | activeHeading: string; 7 | }; 8 | export const TableOfContents = memo(({ headings, activeHeading }) => { 9 | return ( 10 |
11 |
    12 | {headings.map((heading) => ( 13 |
  1. 14 | {heading} 15 |
  2. 16 | ))} 17 |
18 |
19 | ); 20 | }); 21 | TableOfContents.displayName = 'TableOfContents'; 22 | -------------------------------------------------------------------------------- /docs/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppComponent } from 'next/dist/next-server/lib/router/router'; 2 | 3 | import '../styles/globals.scss'; 4 | import '../styles/prism.css'; 5 | 6 | const App: AppComponent = ({ Component, pageProps }) => { 7 | return ; 8 | }; 9 | 10 | export default App; 11 | -------------------------------------------------------------------------------- /docs/src/pages/layout.tsx: -------------------------------------------------------------------------------- 1 | import styles from '../styles/Layout.module.scss'; 2 | import { Header } from '../components/Header/Header'; 3 | import { Reference, ReferenceType } from '../components/Reference/Reference'; 4 | import { TableOfContents } from '../components/TableOfContents/TableOfContents'; 5 | import { useCallback, useState } from 'react'; 6 | 7 | const reference: ReferenceType = [ 8 | { 9 | heading: 'Introduction', 10 | }, 11 | { 12 | heading: 'Overview', 13 | sections: ['first', 'second', 'third', 'forth'], 14 | }, 15 | { 16 | heading: 'Introduction', 17 | sections: ['first', 'second', 'third', 'forth'], 18 | }, 19 | { 20 | heading: 'Introduction', 21 | sections: ['first', 'second', 'third', 'forth'], 22 | }, 23 | { 24 | heading: 'Introduction', 25 | sections: ['first', 'second', 'third', 'forth'], 26 | }, 27 | ]; 28 | 29 | export default function Layout() { 30 | const [menuOpened, setMenuOpened] = useState(false); 31 | const toggleMenuOpened = useCallback(() => setMenuOpened((prev) => !prev), []); 32 | 33 | return ( 34 |
35 |
36 |
37 | 38 |
39 |

Getting started

40 |

41 | Lorem ipsum dolor sit amet consectetur adipisicing elit. Nihil modi nobis, accusantium ipsum quis aspernatur 42 | deleniti blanditiis distinctio illo repudiandae commodi aperiam officiis consequatur eius. Quos saepe 43 | adipisci quidem delectus. Lorem ipsum, dolor sit amet consectetur adipisicing elit. Voluptates facere vero 44 | alias repellat quisquam suscipit, mollitia dolorem in at tempora nemo dicta hic doloremque sunt eos error ea 45 | consectetur. Corporis! 46 |

47 |

Hello from the moon

48 |

49 | Lorem ipsum, dolor sit amet consectetur adipisicing elit. Aspernatur voluptates nostrum in architecto illo 50 | voluptatum sunt iusto alias quibusdam labore molestiae quo omnis maiores obcaecati dolorem atque, mollitia 51 | consequatur quisquam fuga eius commodi quasi cumque! Alias maxime veniam nobis impedit quia nemo maiores, 52 | aliquam itaque perferendis ad doloribus corporis velit eius beatae inventore magni, adipisci incidunt 53 | praesentium laudantium asperiores esse quisquam! Beatae odio doloribus dolore molestiae sequi a accusantium 54 | nulla nihil voluptatum quam, iure, consectetur qui ipsam ea illum itaque aperiam. Neque consectetur ipsa 55 | adipisci tenetur hic architecto commodi, libero quis accusantium facilis quos natus rerum sapiente sed eius 56 | repellat dolor, fugit maiores eum. Vel ex asperiores esse ratione beatae! Veritatis, natus pariatur 57 | perspiciatis fuga nisi tempore harum fugiat quas, dicta eum recusandae quibusdam! Incidunt maxime quidem 58 | eveniet voluptate praesentium nesciunt, eaque sit harum labore aut sapiente voluptas, magni, atque 59 | consectetur dolore! Ipsa, vel. Animi dolorum debitis nam nesciunt eaque sapiente quos deleniti natus 60 | voluptates earum officiis nemo velit, similique praesentium eligendi reiciendis ducimus autem harum officia 61 | ipsum eos et. Qui ducimus praesentium sequi sapiente dicta quam, hic doloremque ipsum iusto temporibus 62 | excepturi ipsa voluptatibus quidem nam voluptates maiores debitis dignissimos totam autem, laudantium 63 | adipisci consectetur. Similique dolor mollitia nesciunt! 64 |

65 |

I love the stars

66 |

67 | Lorem ipsum, dolor sit amet consectetur adipisicing elit. Repellendus quisquam aliquid distinctio fugit 68 | totam laboriosam reiciendis quod. Ratione sequi laborum aspernatur quidem eius corrupti earum quis rerum 69 | debitis eos amet voluptatum vel officia enim veniam dolorem, reprehenderit cum quae maxime deleniti dolorum 70 | necessitatibus a nulla. Quae rerum corrupti ipsam? 71 |

72 |

It's too late

73 |

74 | Lorem ipsum dolor sit amet consectetur adipisicing elit. Earum atque labore, quos non veniam placeat ut eius 75 | nulla delectus dicta voluptatibus eveniet consequatur minima doloremque repudiandae numquam quod mollitia 76 | consequuntur temporibus quam aperiam velit odit alias! Voluptate, quam voluptas at, nihil animi sapiente 77 | tempore cum iste consectetur fugit recusandae incidunt reprehenderit quasi nam enim. Laboriosam iste, 78 | nesciunt, sunt quis perspiciatis repellendus assumenda a amet ad fuga quia adipisci error eum hic officia? 79 | Ducimus consequuntur illo aperiam repellat distinctio eaque excepturi eveniet adipisci! Facilis reiciendis 80 | nobis quod. Facilis recusandae nobis numquam sunt harum consectetur voluptatem id? Laborum possimus rerum 81 | est nulla facilis, minima non corrupti quod! Dolorum maiores unde, perspiciatis repellat mollitia, nam 82 | veniam doloremque nostrum impedit ab, nisi sed! Eveniet praesentium repellat quas consectetur neque 83 | molestias velit, tempore facilis adipisci iusto deserunt. Libero, incidunt tempore recusandae ducimus iste 84 | sequi ipsum saepe quibusdam consequatur id hic? Voluptatem deleniti deserunt eos vitae labore rerum unde 85 | aspernatur ipsa molestias, maiores ipsam eaque, sunt eveniet quo error sequi? Totam temporibus ipsam tempora 86 | placeat, reiciendis suscipit praesentium pariatur alias amet ab, doloremque non molestiae unde repudiandae? 87 | Velit tempore ex nam at harum, perferendis, temporibus asperiores quibusdam rerum esse perspiciatis ullam 88 | qui adipisci, error aliquid reprehenderit earum reiciendis assumenda quaerat quo repellendus accusantium 89 | atque doloremque voluptate? Obcaecati error incidunt provident sapiente ea nobis quos animi hic praesentium 90 | ipsam aperiam voluptate quaerat, tempore facere. Enim temporibus aut corrupti inventore! Error officia 91 | dolorem culpa dignissimos, qui id nulla? Magni sapiente beatae velit nostrum illum vitae explicabo quod, 92 | officia voluptatem, molestias animi quisquam ex, aliquid consequatur ipsam odio laborum atque illo 93 | voluptates non deserunt ut qui quis nam! Molestiae fuga, minus ad quia voluptates expedita nemo illo fugit 94 | labore hic magni laboriosam velit eos unde nisi nam officia delectus ipsum deleniti eaque cumque itaque quos 95 | quibusdam eius. Magni alias laboriosam quidem ipsum, ullam nihil laudantium explicabo eligendi autem! 96 |

97 |
98 | 108 |
109 |
110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /docs/src/pages/reference/AppOptions.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/shared.ts#L41 4 | title: AppOptions (Beta) 5 | --- 6 | 7 | # AppOptions 8 | 9 | ## Summary 10 | 11 | Options you can provide when creating your app. 12 | 13 | ## Signatures 14 | 15 |
PropertyTypeDescription
cookies
AppOptionsCookies

Mind that cookies cannot be encrypted unless you provide a random 32 characters value as secret.

Beta
default: 16 | 17 |
{
18 |   encrypted: true,
19 |   secure: true,
20 |   httpOnly: true,
21 |   sameSite: 'lax',
22 | }
23 | 
24 | 
25 | 26 |
cors
    {
27 |         readonly origin: CorsOrigin | CorsOriginFunction;
28 |         readonly credentials: boolean;
29 |     } | false

CORS is enabled by default for all origins. Set to false to disable it completely.

Beta
default: 30 | { origin: true, credentials: true }
hostname
string
Beta
default: 31 | "localhost"
openapi
    {
32 |         readonly title: string;
33 |         readonly description: string;
34 |         readonly version: string;
35 |         readonly path?: string;
36 |     } | false

Whether automatic generation of Swagger (OpenAPI) definitions should be enabled. It also includes a UI for testing requests.

Beta
default: 37 | false
port
number
Beta
default: 38 | 3000
router
    {
39 |         readonly strictTrailingSlash: boolean;
40 |     }
Beta
41 | -------------------------------------------------------------------------------- /docs/src/pages/reference/AppOptionsCookies.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/shared.ts#L101 4 | title: AppOptionsCookies (Beta) 5 | --- 6 | 7 | # AppOptionsCookies 8 | 9 | ## Signatures 10 | 11 |
PropertyTypeDescription
secret
string
Beta
12 | -------------------------------------------------------------------------------- /docs/src/pages/reference/CorsOrigin.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/shared.ts#L35 4 | title: CorsOrigin (Beta) 5 | --- 6 | 7 | # CorsOrigin 8 | 9 | ## Summary 10 | 11 | Origin which is accepted in CORS. \- `true` means all origins are allowed (`*`) \- string or an array os strings means only given origins are allowed \- regular expression or an array of regular expressions means any matching origins are allowed 12 | -------------------------------------------------------------------------------- /docs/src/pages/reference/CorsOriginFunction.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/shared.ts#L16 4 | title: CorsOriginFunction (Beta) 5 | --- 6 | 7 | # CorsOriginFunction 8 | 9 | ## Signatures 10 | 11 |
PropertyTypeDescription
(call)
(requestOrigin: string | undefined) => MaybeAsync<CorsOrigin>
Beta
12 | -------------------------------------------------------------------------------- /docs/src/pages/reference/EventBus.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/shared.ts#L270 4 | title: EventBus (Beta) 5 | --- 6 | 7 | # EventBus 8 | 9 | ## Signatures 10 | 11 |
PropertyTypeDescription
emit
<Name extends keyof TypeOfWebEvents>(name: Name, ...arg: undefined extends TypeOfWebEvents[Name] ? readonly [arg?: TypeOfWebEvents[Name]] : readonly [arg: TypeOfWebEvents[Name]]) => void
Beta
off
<Name extends keyof TypeOfWebEvents>(name: Name, cb: Callback<TypeOfWebEvents[Name]>) => void
Beta
on
<Name extends keyof TypeOfWebEvents>(name: Name, cb: Callback<TypeOfWebEvents[Name]>) => void
Beta
12 | -------------------------------------------------------------------------------- /docs/src/pages/reference/HttpError.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/utils/errors.ts#L50 4 | title: HttpError (Beta) 5 | --- 6 | 7 | # HttpError 8 | 9 | ## Summary 10 | 11 | `HttpError` should be used for returning erroneous HTTP responses. `HttpError` can be thrown synchronously or asynchronously inside the handler. It'll be caught and automatically turned into a proper Node.js HTTP response. 12 | 13 | ## Signatures 14 | 15 |
PropertyTypeDescription
(constructor)
(statusCode: HttpStatusCode, message: string, body: unknown)

Constructs a new instance of the HttpError class

Beta
body
unknown
Beta
statusCode
HttpStatusCode
Beta
16 | 17 | ## Examples 18 | 19 | ### Example 1 20 | 21 | ```ts 22 | import { HttpError, HttpStatusCode } from '@typeofweb/server'; 23 | 24 | import { app } from './app'; 25 | 26 | app.route({ 27 | path: '/teapot/coffe', 28 | method: 'get', 29 | validation: {}, 30 | handler() { 31 | throw new HttpError(HttpStatusCode.ImaTeapot, 'Try using the coffe machine instead!'); 32 | }, 33 | }); 34 | ``` 35 | -------------------------------------------------------------------------------- /docs/src/pages/reference/HttpMethod.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/httpStatusCodes.ts#L4 4 | title: HttpMethod (Beta) 5 | --- 6 | 7 | # HttpMethod 8 | -------------------------------------------------------------------------------- /docs/src/pages/reference/ParseRouteParams.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/router.ts#L331 4 | title: ParseRouteParams (Beta) 5 | --- 6 | 7 | # ParseRouteParams 8 | -------------------------------------------------------------------------------- /docs/src/pages/reference/RequestId.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/utils/uniqueId.ts#L15 4 | title: RequestId (Beta) 5 | --- 6 | 7 | # RequestId 8 | -------------------------------------------------------------------------------- /docs/src/pages/reference/RouteConfig.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/shared.ts#L286 4 | title: RouteConfig (Beta) 5 | --- 6 | 7 | # RouteConfig 8 | 9 | ## Signatures 10 | 11 |
PropertyTypeDescription
handler
(request: TypeOfWebRequest<Path, TypeOfRecord<Params>, TypeOfRecord<Query>, Pretty<TypeOf<Payload>>>, toolkit: TypeOfWebRequestToolkit) => MaybeAsync<TypeOf<Response>>

Handler should be a sync or async function and must return a value or throw an error.

12 |

- Any value returned for the handler will be used as the response body. HTTP status code 200 is used by default.

13 |

- Return null for an empty response and 204 HTTP status code.

14 |

- Throwing an object compatible with the StatusError interface (an instance of HttpError class in particular) will result in returning an HTTP error with the given status code.

15 |

- Throwing any other value will result in a generic 500 error being returned.

16 |

^^ Returning undefined is also allowed but not recommended and will issue a runtime warning.

Beta
method
HttpMethod
Beta
path
Path
Beta
validation
    {
17 |         readonly params?: Params;
18 |         readonly query?: Query;
19 |         readonly payload?: Payload;
20 |         readonly response?: Response;
21 |     }
Beta
22 | -------------------------------------------------------------------------------- /docs/src/pages/reference/ServerId.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/utils/uniqueId.ts#L10 4 | title: ServerId (Beta) 5 | --- 6 | 7 | # ServerId 8 | -------------------------------------------------------------------------------- /docs/src/pages/reference/SetCookieOptions.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/shared.ts#L133 4 | title: SetCookieOptions (Beta) 5 | --- 6 | 7 | # SetCookieOptions 8 | 9 | ## Signatures 10 | 11 |
PropertyTypeDescription
domain
string
Beta
encrypted
boolean
Beta
expires
Date
Beta
httpOnly
boolean
Beta
maxAge
number
Beta
path
string
Beta
sameSite
boolean | 'lax' | 'strict' | 'none'
Beta
secure
boolean
Beta
12 | -------------------------------------------------------------------------------- /docs/src/pages/reference/StatusError.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/utils/errors.ts#L20 4 | title: StatusError (Beta) 5 | --- 6 | 7 | # StatusError 8 | 9 | ## Summary 10 | 11 | Shape of an object which can be used to produce erroneous HTTP responses. 12 | 13 | ## Signatures 14 | 15 |
PropertyTypeDescription
statusCode
HttpStatusCode
Beta
16 | -------------------------------------------------------------------------------- /docs/src/pages/reference/TypeOfWebApp.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/shared.ts#L329 4 | title: TypeOfWebApp (Beta) 5 | --- 6 | 7 | # TypeOfWebApp 8 | 9 | ## Signatures 10 | 11 |
PropertyTypeDescription
events
EventBus
Beta
inject
    (injection: {
12 |         readonly method: HttpMethod;
13 |         readonly path: string;
14 |         readonly payload?: string | object | undefined;
15 |         readonly headers?: Record<string, string>;
16 |         readonly cookies?: readonly `${string}=${string}`[];
17 |     }) => Promise<Superagent.Response>
Beta
plugin
(plugin: TypeOfWebPlugin<string>) => MaybeAsync<TypeOfWebApp>
Beta
route
(config: RouteConfig<Path, ParamsKeys, Params, Query, Payload, Response>) => TypeOfWebApp
Beta
start
() => Promise<TypeOfWebServer>
Beta
stop
() => Promise<void>
Beta
18 | -------------------------------------------------------------------------------- /docs/src/pages/reference/TypeOfWebEvents.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/augment.ts#L16 4 | title: TypeOfWebEvents (Beta) 5 | --- 6 | 7 | # TypeOfWebEvents 8 | 9 | ## Signatures 10 | 11 |
PropertyTypeDescription
":afterResponse"
TypeOfWebResponse
Beta
":error"
unknown
Beta
":request"
TypeOfWebRequest
Beta
":server"
TypeOfWebServer
Beta
12 | -------------------------------------------------------------------------------- /docs/src/pages/reference/TypeOfWebPlugin.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/plugins.ts#L31 4 | title: TypeOfWebPlugin (Beta) 5 | --- 6 | 7 | # TypeOfWebPlugin 8 | 9 | ## Signatures 10 | 11 |
PropertyTypeDescription
cb
(app: TypeOfWebApp) => PluginName extends PluginName_ ? MaybeAsync<PluginCallbackReturnValue<PluginName>> : MaybeAsync<undefined | void>
Beta
name
PluginName
Beta
12 | -------------------------------------------------------------------------------- /docs/src/pages/reference/TypeOfWebRequest.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/shared.ts#L147 4 | title: TypeOfWebRequest (Beta) 5 | --- 6 | 7 | # TypeOfWebRequest 8 | 9 | ## Signatures 10 | 11 |
PropertyTypeDescription
cookies
Record<string, string>

Record of cookies in the request. Encrypted cookies are automatically validated, deciphered, and included in this object.

Beta
id
RequestId

RequestId is a unique number consisting of:

12 |

- a 4-byte timestamp when the request was initiated in seconds

13 |

- a 3-byte incrementing counter, initialized to a random value when the process started

14 |

Use `parseRequestId` to retrieve the timestamp.

Beta
params
Params

Parameters from the URL.

Beta
path
Path
Beta
payload
Payload

Payload is always a valid JSON or null. Only present for POST, PUT, and PATCH requests.

Beta
plugins
TypeOfWebRequestMeta
Beta
query
Query

An object which a result of parsing and validating the query string.

Beta
server
TypeOfWebServer

A reference to the server instance. Useful for accessing server.plugins or server.events.

Beta
timestamp
ReturnType<typeof performance.now>

This is NOT a standard Unix timestamp. request.timestamp is a result of calling require('perf_hooks').performance.now() and should only be used for measuring performance.

15 |

If you're looking for a Unix timestamp see TypeOfWebRequest.id and parseRequestId().

Beta
16 | -------------------------------------------------------------------------------- /docs/src/pages/reference/TypeOfWebRequestMeta.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/augment.ts#L11 4 | title: TypeOfWebRequestMeta (Beta) 5 | --- 6 | 7 | # TypeOfWebRequestMeta 8 | -------------------------------------------------------------------------------- /docs/src/pages/reference/TypeOfWebRequestToolkit.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/shared.ts#L123 4 | title: TypeOfWebRequestToolkit (Beta) 5 | --- 6 | 7 | # TypeOfWebRequestToolkit 8 | 9 | ## Summary 10 | 11 | Request Toolkit is a set of functions used to modify resulting http response. 12 | 13 | ## Signatures 14 | 15 |
PropertyTypeDescription
removeCookie
(name: string, options: SetCookieOptions) => MaybeAsync<void>
Beta
setCookie
(name: string, value: string, options: SetCookieOptions) => MaybeAsync<void>
Beta
setHeader
(headerName: string, value: string) => MaybeAsync<void>
Beta
setStatus
(statusCode: HttpStatusCode) => MaybeAsync<void>
Beta
16 | 17 | ## Examples 18 | 19 | ### Example 1 20 | 21 | ```ts 22 | app.route({ 23 | path: '/actionable', 24 | method: 'post', 25 | validation: {}, 26 | handler: async (req, t) => { 27 | await t.setStatus(HttpStatusCode.Accepted); 28 | return null; 29 | }, 30 | }); 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/src/pages/reference/TypeOfWebResponse.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/shared.ts#L220 4 | title: TypeOfWebResponse (Beta) 5 | --- 6 | 7 | # TypeOfWebResponse 8 | 9 | ## Signatures 10 | 11 |
PropertyTypeDescription
payload
Json | null

Response body. It should be always a valid JSON or null.

Beta
request
TypeOfWebRequest

A reference to the related request. Useful during the :afterResponse event.

Beta
statusCode
number

HTTP status code. Could be any number between 100 and 599.

Beta
timestamp
ReturnType<typeof performance.now>

This is NOT a standard Unix timestamp. response.timestamp is a result of calling require('perf_hooks').performance.now() and should only be used for measuring performance.

Example 1 12 | 13 |
app.events.on(':afterResponse', (response) => {
14 |   const elapsed = response.timestamp - response.request.timestamp;
15 |   console.info(`The server has responded in:`, elapsed);
16 | });
17 | 
18 | 
19 | 20 |
Beta
21 | -------------------------------------------------------------------------------- /docs/src/pages/reference/TypeOfWebServer.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/shared.ts#L260 4 | title: TypeOfWebServer (Beta) 5 | --- 6 | 7 | # TypeOfWebServer 8 | 9 | ## Signatures 10 | 11 |
PropertyTypeDescription
address
URL | null
Beta
events
EventBus
Beta
id
ServerId
Beta
plugins
TypeOfWebServerMeta
Beta
12 | -------------------------------------------------------------------------------- /docs/src/pages/reference/TypeOfWebServerMeta.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/augment.ts#L6 4 | title: TypeOfWebServerMeta (Beta) 5 | --- 6 | 7 | # TypeOfWebServerMeta 8 | -------------------------------------------------------------------------------- /docs/src/pages/reference/createApp.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/app.ts#L49 4 | title: createApp(opts) (Beta) 5 | --- 6 | 7 | # createApp(opts) 8 | 9 | ## Summary 10 | 11 | Function for creating an instance of [`TypeOfWebApp`](TypeOfWebApp.md). Takes [`AppOptions`](AppOptions.md) as an argument, however, all the properties are optional and will be filled with the defaults if omitted. 12 | -------------------------------------------------------------------------------- /docs/src/pages/reference/createPlugin.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/modules/plugins.ts#L48 4 | title: createPlugin(name, cb) (Beta) 5 | --- 6 | 7 | # createPlugin(name, cb) 8 | -------------------------------------------------------------------------------- /docs/src/pages/reference/isStatusError.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/utils/errors.ts#L77 4 | title: isStatusError(err) (Beta) 5 | --- 6 | 7 | # isStatusError(err) 8 | -------------------------------------------------------------------------------- /docs/src/pages/reference/parseRequestId.md: -------------------------------------------------------------------------------- 1 | --- 2 | releaseTag: Beta 3 | fileDestination: src/utils/uniqueId.ts#L89 4 | title: parseRequestId(id) (Beta) 5 | --- 6 | 7 | # parseRequestId(id) 8 | -------------------------------------------------------------------------------- /docs/src/styles/Layout.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | min-height: 100vh; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .main { 8 | display: flex; 9 | flex-grow: 1; 10 | } 11 | 12 | .article { 13 | flex-grow: 1; 14 | padding: 2rem 1.25rem; 15 | transition: 200ms padding ease-in; 16 | 17 | &WithMenuOpened { 18 | padding: 2rem 1.25rem 2rem calc(1.25rem + var(--reference-width)); 19 | } 20 | 21 | @media screen and (min-width: 55rem) { 22 | padding: 2rem 4rem; 23 | 24 | &WithMenuOpened { 25 | padding: 2rem 4rem 2rem calc(4rem + var(--reference-width)); 26 | } 27 | } 28 | } 29 | 30 | .articleHeading { 31 | color: var(--gray-900); 32 | margin-top: 1.6rem; 33 | 34 | &:first-child { 35 | margin-top: 0; 36 | } 37 | } 38 | 39 | .articleParagraph { 40 | color: var(--gray-700); 41 | margin: 0.35rem 0; 42 | line-height: 1.65; 43 | } 44 | -------------------------------------------------------------------------------- /docs/src/styles/globals.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, 6 | Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | font-family: 'Poppins', sans-serif; 17 | margin: 0; 18 | padding: 0; 19 | } 20 | 21 | :root { 22 | --active: #006d77; 23 | --section-background: #f5f5f5; 24 | --section-background-tint: #fdfdfd; 25 | --nav-background: #151515; 26 | 27 | --white: #ffffff; 28 | 29 | --gray-100: #939393; 30 | --gray-300: #6a6a6a; 31 | --gray-500: #5e5e5e; 32 | --gray-700: #323232; 33 | --gray-900: #2e2e2e; 34 | 35 | --reference-width: 16rem; 36 | --table-of-contents-width: 20rem; 37 | --header-height: 5rem; 38 | 39 | // The default breakpoints are the following: 40 | // mobile 0 - 55rem; 41 | // tablet 55rem - 70rem; 42 | // desktop 70 rem - Infinity; 43 | } 44 | 45 | :target { 46 | background-color: gold; 47 | } 48 | -------------------------------------------------------------------------------- /docs/src/styles/prism.css: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.24.1 2 | https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript+abap+abnf+actionscript+ada+agda+al+antlr4+apacheconf+apex+apl+applescript+aql+arduino+arff+asciidoc+aspnet+asm6502+autohotkey+autoit+bash+basic+batch+bbcode+birb+bison+bnf+brainfuck+brightscript+bro+bsl+c+csharp+cpp+cfscript+chaiscript+cil+clojure+cmake+cobol+coffeescript+concurnas+csp+coq+crystal+css-extras+csv+cypher+d+dart+dataweave+dax+dhall+diff+django+dns-zone-file+docker+dot+ebnf+editorconfig+eiffel+ejs+elixir+elm+etlua+erb+erlang+excel-formula+fsharp+factor+false+firestore-security-rules+flow+fortran+ftl+gml+gcode+gdscript+gedcom+gherkin+git+glsl+go+graphql+groovy+haml+handlebars+haskell+haxe+hcl+hlsl+hoon+http+hpkp+hsts+ichigojam+icon+icu-message-format+idris+ignore+inform7+ini+io+j+java+javadoc+javadoclike+javastacktrace+jexl+jolie+jq+jsdoc+js-extras+json+json5+jsonp+jsstacktrace+js-templates+julia+keyman+kotlin+kumir+latex+latte+less+lilypond+liquid+lisp+livescript+llvm+log+lolcode+lua+makefile+markdown+markup-templating+matlab+mel+mizar+mongodb+monkey+moonscript+n1ql+n4js+nand2tetris-hdl+naniscript+nasm+neon+nevod+nginx+nim+nix+nsis+objectivec+ocaml+opencl+openqasm+oz+parigp+parser+pascal+pascaligo+psl+pcaxis+peoplecode+perl+php+phpdoc+php-extras+plsql+powerquery+powershell+processing+prolog+promql+properties+protobuf+pug+puppet+pure+purebasic+purescript+python+qsharp+q+qml+qore+r+racket+jsx+tsx+reason+regex+rego+renpy+rest+rip+roboconf+robotframework+ruby+rust+sas+sass+scss+scala+scheme+shell-session+smali+smalltalk+smarty+sml+solidity+solution-file+soy+sparql+splunk-spl+sqf+sql+squirrel+stan+iecst+stylus+swift+t4-templating+t4-cs+t4-vb+tap+tcl+tt2+textile+toml+turtle+twig+typescript+typoscript+unrealscript+uri+v+vala+vbnet+velocity+verilog+vhdl+vim+visual-basic+warpscript+wasm+wiki+wolfram+xeora+xml-doc+xojo+xquery+yaml+yang+zig */ 3 | /** 4 | * prism.js default theme for JavaScript, CSS and HTML 5 | * Based on dabblet (http://dabblet.com) 6 | * @author Lea Verou 7 | */ 8 | 9 | code[class*='language-'], 10 | pre[class*='language-'] { 11 | color: black; 12 | background: none; 13 | text-shadow: 0 1px white; 14 | font-family: 'Fira Code', Fira, Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 15 | font-size: 1em; 16 | text-align: left; 17 | white-space: pre; 18 | word-spacing: normal; 19 | word-break: normal; 20 | word-wrap: normal; 21 | line-height: 1.5; 22 | 23 | -moz-tab-size: 2; 24 | -o-tab-size: 2; 25 | tab-size: 2; 26 | 27 | -webkit-hyphens: none; 28 | -moz-hyphens: none; 29 | -ms-hyphens: none; 30 | hyphens: none; 31 | } 32 | 33 | pre[class*='language-']::-moz-selection, 34 | pre[class*='language-'] ::-moz-selection, 35 | code[class*='language-']::-moz-selection, 36 | code[class*='language-'] ::-moz-selection { 37 | text-shadow: none; 38 | background: #b3d4fc; 39 | } 40 | 41 | pre[class*='language-']::selection, 42 | pre[class*='language-'] ::selection, 43 | code[class*='language-']::selection, 44 | code[class*='language-'] ::selection { 45 | text-shadow: none; 46 | background: #b3d4fc; 47 | } 48 | 49 | @media print { 50 | code[class*='language-'], 51 | pre[class*='language-'] { 52 | text-shadow: none; 53 | } 54 | } 55 | 56 | /* Code blocks */ 57 | pre[class*='language-'] { 58 | padding: 1em; 59 | margin: 0.5em 0; 60 | overflow: auto; 61 | } 62 | 63 | :not(pre) > code[class*='language-'], 64 | pre[class*='language-'] { 65 | /* background: #f5f2f0; */ 66 | background: #fafbfc; 67 | } 68 | 69 | /* Inline code */ 70 | :not(pre) > code[class*='language-'] { 71 | padding: 0.1em; 72 | border-radius: 0.3em; 73 | white-space: normal; 74 | } 75 | 76 | .token.comment, 77 | .token.prolog, 78 | .token.doctype, 79 | .token.cdata { 80 | color: slategray; 81 | } 82 | 83 | .token.punctuation { 84 | color: #999; 85 | } 86 | 87 | .token.namespace { 88 | opacity: 0.7; 89 | } 90 | 91 | .token.property, 92 | .token.tag, 93 | .token.boolean, 94 | .token.number, 95 | .token.constant, 96 | .token.symbol, 97 | .token.deleted { 98 | color: #905; 99 | } 100 | 101 | .token.selector, 102 | .token.attr-name, 103 | .token.string, 104 | .token.char, 105 | .token.builtin, 106 | .token.inserted { 107 | color: #690; 108 | } 109 | 110 | .token.operator, 111 | .token.entity, 112 | .token.url, 113 | .language-css .token.string, 114 | .style .token.string { 115 | color: #9a6e3a; 116 | } 117 | 118 | .token.atrule, 119 | .token.attr-value, 120 | .token.keyword { 121 | color: #07a; 122 | } 123 | 124 | .token.function, 125 | .token.class-name { 126 | color: #dd4a68; 127 | } 128 | 129 | .token.regex, 130 | .token.important, 131 | .token.variable { 132 | color: #e90; 133 | } 134 | 135 | .token.important, 136 | .token.bold { 137 | font-weight: bold; 138 | } 139 | .token.italic { 140 | font-style: italic; 141 | } 142 | 143 | .token.entity { 144 | cursor: help; 145 | } 146 | -------------------------------------------------------------------------------- /docs/tools/constants.ts: -------------------------------------------------------------------------------- 1 | import Path from 'path'; 2 | 3 | export const UNSAFE_FILENAME = /[^a-z0-9_\-\.]/gi; 4 | export const BASE_PATH = ''; 5 | export const OUTDIR = Path.join('src', 'pages', 'reference'); 6 | -------------------------------------------------------------------------------- /docs/tools/files.ts: -------------------------------------------------------------------------------- 1 | import { DeclarationReference } from '@microsoft/tsdoc/lib-commonjs/beta/DeclarationReference'; 2 | import { DocDeclarationReference } from '@microsoft/tsdoc'; 3 | import { Context } from './types'; 4 | import { ApiItem } from '@microsoft/api-extractor-model'; 5 | import { UNSAFE_FILENAME, BASE_PATH, OUTDIR } from './constants'; 6 | import Path from 'path'; 7 | import Fs from 'fs/promises'; 8 | import { last } from './utils'; 9 | import GithubSlugger from 'github-slugger'; 10 | 11 | export function referenceToLink( 12 | context: Context, 13 | reference?: DeclarationReference | DocDeclarationReference, 14 | linkTxt?: string, 15 | ) { 16 | if (!reference) { 17 | console.warn('Empty reference'); 18 | return null; 19 | } 20 | 21 | const ref = context.apiModel.resolveDeclarationReference(reference, context.apiItem); 22 | if (ref.errorMessage || !ref.resolvedApiItem) { 23 | // console.warn( 24 | // reference instanceof DeclarationReference ? reference.symbol?.componentPath?.component.toString() : '', 25 | // ref.errorMessage, 26 | // ); 27 | return null; 28 | } 29 | 30 | const linkText = linkTxt || ref.resolvedApiItem.getScopedNameWithinPackage(); 31 | const url = getFileUrl(ref.resolvedApiItem); 32 | return { url, linkText }; 33 | } 34 | 35 | export function getSafeFilename(name: string) { 36 | return name.replace(/^@typeofweb\//, '').replace(UNSAFE_FILENAME, '_'); 37 | } 38 | 39 | export function getApiItemName(apiItem: ApiItem): string { 40 | if (apiItem.kind === 'Model') { 41 | return 'index.md'; 42 | } 43 | 44 | const hierarchy = apiItem.getHierarchy(); 45 | 46 | if (apiItem.kind === 'PropertySignature' && hierarchy.length > 0) { 47 | const anchor = getHashLink(last(hierarchy)); 48 | const filename = getApiItemName(hierarchy[hierarchy.length - 2]); 49 | return `${filename}#${anchor}`; 50 | } 51 | 52 | const segments: string[] = hierarchy 53 | .map((hierarchyItem) => { 54 | switch (hierarchyItem.kind) { 55 | case 'Model': 56 | case 'EntryPoint': 57 | case 'Package': 58 | return ''; 59 | default: 60 | return getSafeFilename(hierarchyItem.displayName); 61 | } 62 | }) 63 | .map((x) => x.trim()) 64 | .filter((x) => x.length > 0); 65 | 66 | const baseName = segments.join('.'); 67 | const filename = baseName || 'index'; 68 | return `${filename}.md`; 69 | } 70 | 71 | export function getHashLink(apiItem: ApiItem): string { 72 | const slugger = new GithubSlugger(); 73 | return slugger.slug(apiItem.displayName); 74 | } 75 | 76 | export function getFileUrl(apiItem: ApiItem) { 77 | const name = getApiItemName(apiItem); 78 | return Path.join(BASE_PATH, name); 79 | } 80 | 81 | export function getFilePath(apiItem: ApiItem) { 82 | const name = getApiItemName(apiItem); 83 | return Path.join(OUTDIR, name); 84 | } 85 | 86 | export async function rimraf() { 87 | await Fs.readdir(OUTDIR); 88 | await Fs.rm(OUTDIR, { recursive: true }); 89 | await Fs.mkdir(OUTDIR); 90 | } 91 | -------------------------------------------------------------------------------- /docs/tools/html.ts: -------------------------------------------------------------------------------- 1 | type Html = string | (string | undefined | null | false)[]; 2 | 3 | export function toString(c: Html) { 4 | return typeof c === 'string' ? c : c.filter((x) => !!x).join(''); 5 | } 6 | 7 | const INDENT = ' '; 8 | 9 | export function code(c: Html) { 10 | const val = toString(c); 11 | if (val.trim().includes('\n')) { 12 | return `
${INDENT}${val}
`; 13 | } 14 | return `
${val.trim()}
`; 15 | } 16 | 17 | export function anchor(fragment: string) { 18 | return ``; 19 | } 20 | 21 | export function a(href: string, text?: string) { 22 | return `${getHtmlEscapedText(text || href)}`; 23 | } 24 | 25 | export function table(c: Html) { 26 | return `${toString(c)}
`; 27 | } 28 | 29 | export function em(c: Html) { 30 | return `${toString(c)}`; 31 | } 32 | 33 | export function strong(c: Html) { 34 | return `${toString(c)}`; 35 | } 36 | 37 | export function thead(c: Html) { 38 | return `${toString(c)}`; 39 | } 40 | 41 | export function tbody(c: Html) { 42 | return `${toString(c)}`; 43 | } 44 | 45 | export function th(c: Html) { 46 | return `${toString(c)}`; 47 | } 48 | 49 | export function tr(c: Html, attributes = '') { 50 | attributes = attributes ? ' ' + attributes.trim() : attributes; 51 | return `${toString(c)}`; 52 | } 53 | 54 | export function td(c: Html) { 55 | return `${toString(c)}`; 56 | } 57 | 58 | export function p(c: Html) { 59 | return `

${toString(c)}

`; 60 | } 61 | 62 | export function div(c: Html) { 63 | return `
${toString(c)}
`; 64 | } 65 | 66 | export function h3(c: Html, attributes = '') { 67 | attributes = attributes ? ' ' + attributes.trim() : attributes; 68 | return `${toString(c)}`; 69 | } 70 | 71 | export function getHtmlEscapedText(text: string) { 72 | return text 73 | .replace(/&/g, '&') 74 | .replace(/"/g, '"') 75 | .replace(//g, '>') 77 | .replace(/\|/g, '|') 78 | .replace(/{/g, '{') 79 | .replace(/}/g, '}'); 80 | } 81 | -------------------------------------------------------------------------------- /docs/tools/stringify.ts: -------------------------------------------------------------------------------- 1 | import RemarkStringify from 'remark-stringify'; 2 | import RemarkRehype from 'remark-rehype'; 3 | import RehypeStringify from 'rehype-stringify'; 4 | import Gfm from 'remark-gfm'; 5 | import RemarkFrontmatter from 'remark-frontmatter'; 6 | import Unified from 'unified'; 7 | import { Node, Parent, Literal } from 'unist'; 8 | 9 | export function toMarkdownString(tree: Node): string { 10 | const processor = Unified().use(RemarkFrontmatter, ['yaml']).use(Gfm).use(RemarkStringify, { 11 | fences: true, 12 | }); 13 | 14 | return processor.stringify(tree); 15 | } 16 | 17 | export function toHtmlString(tree: Node) { 18 | const processor = Unified().use(RemarkFrontmatter, ['yaml']).use(Gfm).use(RemarkRehype).use(RehypeStringify); 19 | 20 | return processor.stringify(processor.runSync(tree)); 21 | } 22 | -------------------------------------------------------------------------------- /docs/tools/types.ts: -------------------------------------------------------------------------------- 1 | import { ApiModel, ApiItem } from '@microsoft/api-extractor-model'; 2 | 3 | export type Context = { 4 | apiModel: ApiModel; 5 | apiItem: ApiItem; 6 | }; 7 | 8 | export interface FrontmatterMeta { 9 | fileDestination?: string | null; 10 | releaseTag?: string | null; 11 | title?: string | null; 12 | } 13 | 14 | export enum ApiItemKind { 15 | CallSignature = 'CallSignature', 16 | Class = 'Class', 17 | Constructor = 'Constructor', 18 | ConstructSignature = 'ConstructSignature', 19 | EntryPoint = 'EntryPoint', 20 | Enum = 'Enum', 21 | EnumMember = 'EnumMember', 22 | Function = 'Function', 23 | IndexSignature = 'IndexSignature', 24 | Interface = 'Interface', 25 | Method = 'Method', 26 | MethodSignature = 'MethodSignature', 27 | Model = 'Model', 28 | Namespace = 'Namespace', 29 | Package = 'Package', 30 | Property = 'Property', 31 | PropertySignature = 'PropertySignature', 32 | TypeAlias = 'TypeAlias', 33 | Variable = 'Variable', 34 | None = 'None', 35 | } 36 | 37 | export enum DocNodeKind { 38 | Block = 'Block', 39 | BlockTag = 'BlockTag', 40 | Excerpt = 'Excerpt', 41 | FencedCode = 'FencedCode', 42 | CodeSpan = 'CodeSpan', 43 | Comment = 'Comment', 44 | DeclarationReference = 'DeclarationReference', 45 | ErrorText = 'ErrorText', 46 | EscapedText = 'EscapedText', 47 | HtmlAttribute = 'HtmlAttribute', 48 | HtmlEndTag = 'HtmlEndTag', 49 | HtmlStartTag = 'HtmlStartTag', 50 | InheritDocTag = 'InheritDocTag', 51 | InlineTag = 'InlineTag', 52 | LinkTag = 'LinkTag', 53 | MemberIdentifier = 'MemberIdentifier', 54 | MemberReference = 'MemberReference', 55 | MemberSelector = 'MemberSelector', 56 | MemberSymbol = 'MemberSymbol', 57 | Paragraph = 'Paragraph', 58 | ParamBlock = 'ParamBlock', 59 | ParamCollection = 'ParamCollection', 60 | PlainText = 'PlainText', 61 | Section = 'Section', 62 | SoftBreak = 'SoftBreak', 63 | } 64 | -------------------------------------------------------------------------------- /docs/tools/utils.ts: -------------------------------------------------------------------------------- 1 | export function as(d: unknown): T { 2 | return d as T; 3 | } 4 | 5 | export function last(array: readonly T[]): T { 6 | return array[array.length - 1]; 7 | } 8 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "skipLibCheck": true, 5 | "checkJs": true, 6 | "allowJs": true, 7 | "noEmit": true, 8 | "jsx": "preserve", 9 | "target": "es2020", 10 | "lib": ["dom", "dom.iterable", "esnext"], 11 | "forceConsistentCasingInFileNames": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true 17 | }, 18 | "exclude": ["node_modules"], 19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] 20 | } 21 | -------------------------------------------------------------------------------- /examples/simple.ts: -------------------------------------------------------------------------------- 1 | import { number } from '@typeofweb/schema'; 2 | 3 | import { createApp, createPlugin } from '../dist/index'; 4 | 5 | declare function findOne(): unknown; 6 | declare function findMany(): 123; 7 | declare module '../dist/index' { 8 | interface TypeOfWebServerMeta { 9 | readonly db: { 10 | readonly findOne: typeof findOne; 11 | readonly findMany: typeof findMany; 12 | }; 13 | } 14 | 15 | interface TypeOfWebEvents { 16 | readonly 'health-check': number; 17 | } 18 | } 19 | 20 | // plugins 21 | export const dbPlugin = createPlugin('db', () => { 22 | return { 23 | server() { 24 | return { findOne, findMany: { cache: {}, fn: findMany } }; 25 | }, 26 | }; 27 | }); 28 | 29 | const loggerPlugin = createPlugin('logger', (app) => { 30 | app.events.on(':afterResponse', () => console.info(`The server has responded.`)); 31 | app.events.on(':server', () => console.info('Server started!')); 32 | app.events.on(':request', () => console.info(`Request coming through!`)); 33 | }); 34 | 35 | // app 36 | const app = createApp({}); 37 | 38 | void app.plugin(loggerPlugin); 39 | void app.plugin(dbPlugin); 40 | 41 | void app.route({ 42 | path: '/health-check/:count', 43 | method: 'get', 44 | validation: { 45 | params: { 46 | count: number(), 47 | }, 48 | // response: number(), 49 | }, 50 | handler(_request) { 51 | // const { query, params } = request; 52 | _request.server.plugins.db.findMany(); 53 | // request.server.events.emit('health-check', 123); 54 | return 1; 55 | }, 56 | }); 57 | 58 | app 59 | .start() 60 | .then((server) => { 61 | console.log(`🙌 Server started at ${server.address?.toString()}`); 62 | }) 63 | .catch(console.error); 64 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["."] 4 | } 5 | -------------------------------------------------------------------------------- /jest-setup-after-env.ts: -------------------------------------------------------------------------------- 1 | beforeAll(() => { 2 | process.on('unhandledRejection', (err) => fail(err)); 3 | process.on('uncaughtException', (err) => fail(err)); 4 | }); 5 | afterAll(() => { 6 | process.removeListener('unhandledRejection', (err) => fail(err)); 7 | process.removeListener('uncaughtException', (err) => fail(err)); 8 | }); 9 | export {}; 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | import Defaults from 'jest-config'; 2 | 3 | /** @type {import('@jest/types').Config.InitialOptions} */ 4 | const config = { 5 | roots: [''], 6 | preset: 'ts-jest', 7 | // preset: 'ts-jest/presets/default-esm', 8 | testPathIgnorePatterns: ['[/\\\\](node_modules)[/\\\\]'], 9 | transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.ts$'], 10 | transform: { 11 | // '^.+\\.ts$': 'ts-jest', 12 | }, 13 | testMatch: ['**/?(*.)+(spec|test).ts'], 14 | setupFilesAfterEnv: ['./jest-setup-after-env.ts'], 15 | extensionsToTreatAsEsm: ['.ts'], 16 | moduleFileExtensions: [...Defaults.defaults.moduleFileExtensions, 'ts', 'tsx'], 17 | globals: { 18 | 'ts-jest': { 19 | useESM: true, 20 | }, 21 | }, 22 | }; 23 | 24 | export default config; 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@typeofweb/server", 3 | "version": "0.2.2", 4 | "type": "module", 5 | "exports": { 6 | "require": "./dist/index.cjs", 7 | "import": "./dist/index.js", 8 | "default": "./dist/index.js" 9 | }, 10 | "main": "./dist/index.cjs", 11 | "module": "./dist/index.js", 12 | "types": "dist/index.d.ts", 13 | "sideEffects": false, 14 | "repository": "git://github.com/typeofweb/server", 15 | "bugs": { 16 | "url": "https://github.com/typeofweb/server/issues" 17 | }, 18 | "homepage": "https://github.com/typeofweb/server#readme", 19 | "author": "Michał Miszczyszyn - Type of Web (https://typeofweb.com/)", 20 | "license": "MIT", 21 | "engines": { 22 | "node": "^12.20.0 || ^14.13.1 || >=16.0.0" 23 | }, 24 | "publishConfig": { 25 | "access": "public" 26 | }, 27 | "files": [ 28 | "package.json", 29 | "dist", 30 | "LICENSE" 31 | ], 32 | "keywords": [ 33 | "typescript", 34 | "server", 35 | "api" 36 | ], 37 | "dependencies": { 38 | "@typeofweb/schema": "0.8.0-8", 39 | "@typeofweb/utils": "0.3.0", 40 | "@types/cache-manager": "3.4.2", 41 | "@types/cookie-parser": "1.4.2", 42 | "@types/cors": "2.8.12", 43 | "@types/express": "4.17.13", 44 | "@types/supertest": "2.0.11", 45 | "@types/swagger-ui-express": "4.1.3", 46 | "body-parser": "1.19.0", 47 | "cache-manager": "3.4.4", 48 | "cookie-parser": "1.4.5", 49 | "cors": "2.8.5", 50 | "express": "4.17.1", 51 | "openapi-types": "9.1.0", 52 | "prettier": "2.3.2", 53 | "stoppable": "1.1.0", 54 | "supertest": "6.1.3", 55 | "swagger-ui-express": "4.1.6", 56 | "typeconv": "1.4.1" 57 | }, 58 | "peerDependencies": {}, 59 | "devDependencies": { 60 | "@microsoft/api-extractor": "7.18.4", 61 | "@rollup/plugin-commonjs": "19.0.1", 62 | "@rollup/plugin-json": "4.1.0", 63 | "@rollup/plugin-typescript": "8.2.3", 64 | "@tsconfig/node12": "1.0.9", 65 | "@tsconfig/node14": "1.0.1", 66 | "@typeofweb/eslint-plugin": "0.2.2", 67 | "@types/jest": "26.0.24", 68 | "@types/node": "12", 69 | "@types/stoppable": "1.1.1", 70 | "all-contributors-cli": "6.20.0", 71 | "builtin-modules": "3.2.0", 72 | "eslint": "7.30.0", 73 | "fast-check": "2.17.0", 74 | "globby": "11.0.4", 75 | "husky": "6.0.0", 76 | "jest": "27.0.6", 77 | "lint-staged": "11.0.1", 78 | "nodemon": "2.0.12", 79 | "rimraf": "3.0.2", 80 | "rollup": "2.53.2", 81 | "rollup-plugin-filesize": "9.1.1", 82 | "rollup-plugin-license": "2.5.0", 83 | "rollup-plugin-prettier": "2.1.0", 84 | "rollup-plugin-terser": "7.0.2", 85 | "ts-jest": "27.0.3", 86 | "ts-node": "10.1.0", 87 | "tsd": "0.17.0", 88 | "tslib": "2.3.0", 89 | "typescript": "4.3.5" 90 | }, 91 | "scripts": { 92 | "pretest": "yarn build", 93 | "jest": "NODE_OPTIONS=--experimental-vm-modules jest", 94 | "test": "yarn jest --detectOpenHandles --forceExit --coverage", 95 | "build": "yarn rollup:build && yarn api-extractor:build", 96 | "test:dev": "yarn jest --watch", 97 | "rollup:build": "rimraf dist && rollup --config", 98 | "api-extractor:build": "api-extractor run --verbose", 99 | "build:watch": "rollup --config --watch", 100 | "prepublishOnly": "yarn build", 101 | "prepare": "husky install", 102 | "ts-node": "nodemon -w dist -e js -x 'node --loader ts-node/esm'" 103 | }, 104 | "lint-staged": { 105 | "**/*.ts": [ 106 | "yarn jest --passWithNoTests", 107 | "yarn eslint --fix", 108 | "yarn prettier --write" 109 | ], 110 | "**/*.{md,js,json}": [ 111 | "yarn prettier --write" 112 | ] 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import BuiltinModules from 'builtin-modules'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import typescript from '@rollup/plugin-typescript'; 5 | import prettier from 'rollup-plugin-prettier'; 6 | import { terser } from 'rollup-plugin-terser'; 7 | import filesize from 'rollup-plugin-filesize'; 8 | import license from 'rollup-plugin-license'; 9 | import json from '@rollup/plugin-json'; 10 | 11 | import pkg from './package.json'; 12 | 13 | const shouldCompress = process.env.COMPRESS_BUNDLES ? true : false; 14 | const shouldPrettify = !shouldCompress && (process.env.PRETTIFY ? true : false); 15 | 16 | const dependencies = [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})]; 17 | 18 | const rollupConfig = [ 19 | { 20 | input: 'src/index.ts', 21 | output: [ 22 | { 23 | name: '@typeofweb/server', 24 | format: 'es', 25 | dir: './', 26 | entryFileNames: pkg.exports.import.replace(/^\.\//, ''), 27 | sourcemap: true, 28 | plugins: [ 29 | ...(shouldCompress 30 | ? [ 31 | terser({ 32 | compress: true, 33 | mangle: true, 34 | ecma: 2020, 35 | }), 36 | ] 37 | : []), 38 | ...(shouldPrettify 39 | ? [ 40 | prettier({ 41 | parser: 'typescript', 42 | }), 43 | ] 44 | : []), 45 | ], 46 | }, 47 | { 48 | name: '@typeofweb/server', 49 | format: 'cjs', 50 | dir: './', 51 | entryFileNames: pkg.exports.require.replace(/^\.\//, ''), 52 | sourcemap: true, 53 | plugins: [ 54 | ...(shouldCompress 55 | ? [ 56 | terser({ 57 | compress: true, 58 | mangle: true, 59 | ecma: 2020, 60 | }), 61 | ] 62 | : []), 63 | ...(shouldPrettify 64 | ? [ 65 | prettier({ 66 | parser: 'typescript', 67 | }), 68 | ] 69 | : []), 70 | ], 71 | }, 72 | ], 73 | plugins: [ 74 | json(), 75 | commonjs({ 76 | include: 'node_modules/**', 77 | }), 78 | typescript({ 79 | tsconfig: 'tsconfig.json', 80 | declaration: true, 81 | declarationDir: 'dist/', 82 | rootDir: 'src/', 83 | include: ['src/**/*.ts'], 84 | }), 85 | filesize({}), 86 | license({ 87 | banner: ` 88 | <%= pkg.name %>@<%= pkg.version %> 89 | Copyright (c) <%= moment().format('YYYY') %> Type of Web - Michał Miszczyszyn 90 | 91 | This source code is licensed under the MIT license found in the 92 | LICENSE file in the root directory of this source tree. 93 | `.trim(), 94 | }), 95 | ], 96 | external: [...dependencies, ...BuiltinModules], 97 | }, 98 | ]; 99 | // eslint-disable-next-line import/no-default-export 100 | export default rollupConfig; 101 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { HttpError, isStatusError } from './utils/errors'; 2 | export { createApp } from './modules/app'; 3 | export { HttpStatusCode } from './modules/httpStatusCodes'; 4 | export { createPlugin } from './modules/plugins'; 5 | export { parseRequestId } from './utils/uniqueId'; 6 | 7 | export type { ParseRouteParams } from './modules/router'; 8 | export type { TypeOfWebPlugin } from './modules/plugins'; 9 | export type { HttpMethod } from './modules/httpStatusCodes'; 10 | export type { 11 | AppOptions, 12 | AppOptionsCookies, 13 | CorsOrigin, 14 | CorsOriginFunction, 15 | EventBus, 16 | RouteConfig, 17 | SetCookieOptions, 18 | TypeOfWebApp, 19 | TypeOfWebRequest, 20 | TypeOfWebRequestToolkit, 21 | TypeOfWebResponse, 22 | TypeOfWebServer, 23 | } from './modules/shared'; 24 | 25 | export type { TypeOfWebServerMeta, TypeOfWebRequestMeta, TypeOfWebEvents } from './modules/augment'; 26 | 27 | export type { StatusError } from './utils/errors'; 28 | export type { ServerId, RequestId } from './utils/uniqueId'; 29 | 30 | export type { MaybeAsync } from '@typeofweb/utils'; 31 | -------------------------------------------------------------------------------- /src/modules/app.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'url'; 2 | 3 | import CacheManager from 'cache-manager'; 4 | import CookieParser from 'cookie-parser'; 5 | import Cors from 'cors'; 6 | import Supertest from 'supertest'; 7 | 8 | import { deepMerge } from '../utils/merge'; 9 | import { promiseOriginFnToNodeCallback } from '../utils/node'; 10 | import { generateServerId } from '../utils/uniqueId'; 11 | 12 | import { createCachedFunction } from './cache'; 13 | import { createEventBus } from './events'; 14 | import { initApp, listenExpressServer } from './http'; 15 | import { getOpenApiForRoutes } from './openapi'; 16 | import { initRouter, validateRoute } from './router'; 17 | 18 | import type { TypeOfWebServerMeta } from './augment'; 19 | import type { TypeOfWebPluginInternal } from './plugins'; 20 | import type { AppOptions, TypeOfWebRoute, TypeOfWebApp, TypeOfWebServer, TypeOfWebCacheConfig } from './shared'; 21 | import type { AnyFunction, DeepPartial, DeepWritable, JsonPrimitive, MaybeAsync } from '@typeofweb/utils'; 22 | 23 | const defaultAppOptions: AppOptions = { 24 | hostname: 'localhost', 25 | port: 3000, 26 | cors: { 27 | origin: true, 28 | credentials: true, 29 | }, 30 | cookies: { 31 | encrypted: true, 32 | secure: true, 33 | httpOnly: true, 34 | sameSite: 'lax', 35 | secret: '', 36 | }, 37 | router: { 38 | strictTrailingSlash: false, 39 | }, 40 | openapi: false, 41 | }; 42 | 43 | /** 44 | * Function for creating an instance of {@link TypeOfWebApp}. 45 | * Takes {@link AppOptions} as an argument, however, all the properties are optional and will be filled with the defaults if omitted. 46 | * 47 | * @beta 48 | */ 49 | export function createApp(opts: DeepPartial): TypeOfWebApp { 50 | const options = deepMerge(opts, defaultAppOptions); 51 | const memoryCache = CacheManager.caching({ store: 'memory', ttl: 0 }); 52 | 53 | const server: DeepWritable = { 54 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- these properties are supposed to be added by the plugins inside `async start()` 55 | plugins: {} as TypeOfWebServer['plugins'], 56 | events: createEventBus(), 57 | /* istanbul ignore next */ 58 | get address() { 59 | /* istanbul ignore next */ 60 | return null; 61 | }, 62 | 63 | id: generateServerId(), 64 | }; 65 | 66 | /* eslint-disable functional/prefer-readonly-type -- ok */ 67 | const routes: Array = []; 68 | /* eslint-disable functional/prefer-readonly-type -- ok */ 69 | const plugins: Array> = []; 70 | 71 | let mutableIsInitialized = false; 72 | 73 | function initServerPlugins() { 74 | return plugins.reduce(async (acc, plugin) => { 75 | if (!plugin?.value || typeof plugin?.value.server !== 'function') { 76 | return acc; 77 | } 78 | 79 | await acc; 80 | 81 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- ok 82 | const pluginServer = plugin.value.server as unknown as (server: TypeOfWebServer) => MaybeAsync< 83 | Record< 84 | string, 85 | | JsonPrimitive 86 | | AnyFunction 87 | | { 88 | readonly cache: TypeOfWebCacheConfig; 89 | readonly fn: AnyFunction; 90 | } 91 | > 92 | >; 93 | 94 | const result = await pluginServer(server); 95 | const serverMetadata = !result 96 | ? null 97 | : // skip iterating over instances of custom classes 98 | result.constructor !== Object 99 | ? result 100 | : Object.fromEntries( 101 | Object.entries(result).map(([key, val]) => { 102 | if (typeof val === 'object' && val && 'cache' in val) { 103 | return [key, createCachedFunction({ ...val, cacheInstance: memoryCache })]; 104 | } 105 | return [key, val]; 106 | }), 107 | ); 108 | 109 | if (serverMetadata) { 110 | // @ts-expect-error 111 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- if serverMetadata exists then plugin name is keyof TypeOfWebServerMeta 112 | server.plugins[plugin.name as keyof TypeOfWebServerMeta] = serverMetadata; 113 | } 114 | }, Promise.resolve()); 115 | } 116 | 117 | async function initialize() { 118 | if (mutableIsInitialized) { 119 | return; 120 | } 121 | 122 | if (options.cors) { 123 | const origin = 124 | typeof options.cors.origin === 'function' 125 | ? promiseOriginFnToNodeCallback(options.cors.origin) 126 | : options.cors.origin; 127 | app._rawExpressApp.use( 128 | Cors({ 129 | origin, 130 | credentials: options.cors.credentials, 131 | }), 132 | ); 133 | } 134 | 135 | app._rawExpressApp.use(CookieParser('')); 136 | 137 | await initServerPlugins(); 138 | 139 | app._rawExpressRouter = initRouter({ server, appOptions: options, routes, plugins }); 140 | app._rawExpressApp.use(app._rawExpressRouter); 141 | 142 | /* istanbul ignore if */ 143 | if (options.openapi) { 144 | const SwaggerUiExpress = await import('swagger-ui-express'); 145 | const openapi = await getOpenApiForRoutes(routes, options.openapi); 146 | const path = options.openapi.path ?? '/documentation'; 147 | 148 | app._rawExpressRouter.use(path, SwaggerUiExpress.serve); 149 | app._rawExpressRouter.get(path, SwaggerUiExpress.setup(openapi)); 150 | } 151 | 152 | mutableIsInitialized = true; 153 | server.events.emit(':server', server); 154 | } 155 | 156 | const app: DeepWritable = { 157 | _rawExpressApp: initApp(), 158 | events: server.events, 159 | 160 | async plugin(plugin) { 161 | const pluginDefinition = { 162 | name: plugin.name, 163 | value: await plugin.cb(app), 164 | }; 165 | plugins.push(pluginDefinition); 166 | return app; 167 | }, 168 | 169 | route(route) { 170 | validateRoute(route); 171 | routes.push(route); 172 | return app; 173 | }, 174 | 175 | async inject(injection) { 176 | await initialize(); 177 | 178 | let mutableTest = Supertest(app._rawExpressApp)[injection.method](injection.path); 179 | 180 | if (injection.payload) { 181 | mutableTest = mutableTest.send(injection.payload); 182 | } 183 | 184 | if (injection.headers) { 185 | mutableTest = Object.entries(injection.headers).reduce( 186 | (acc, [header, value]) => acc.set(header, value), 187 | mutableTest, 188 | ); 189 | } 190 | 191 | if (injection.cookies) { 192 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- string[] 193 | mutableTest = mutableTest.set('Cookie', injection.cookies as string[]); 194 | } 195 | 196 | const result = await mutableTest; 197 | return result; 198 | }, 199 | 200 | /* istanbul ignore next */ 201 | async start() { 202 | await initialize(); 203 | app._rawExpressServer = await listenExpressServer(app._rawExpressApp, options); 204 | 205 | Object.defineProperty(server, 'address', { 206 | get() { 207 | const address = app._rawExpressServer?.address(); 208 | if (typeof address !== 'object' || !address) { 209 | return null; 210 | } 211 | 212 | const host = address.family === 'IPv6' ? `[${address.address}]` : address.address; 213 | 214 | return new URL(`http://${host}:${address.port}`); 215 | }, 216 | }); 217 | 218 | return server; 219 | }, 220 | 221 | /* istanbul ignore next */ 222 | stop() { 223 | return new Promise((resolve, reject) => { 224 | if (!app._rawExpressServer) { 225 | return resolve(); 226 | } 227 | return app._rawExpressServer.stop((err) => { 228 | app._rawExpressServer = undefined; 229 | if (err) { 230 | return reject(err); 231 | } 232 | return resolve(); 233 | }); 234 | }); 235 | }, 236 | }; 237 | 238 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- readonly 239 | return app as TypeOfWebApp; 240 | } 241 | -------------------------------------------------------------------------------- /src/modules/augment.ts: -------------------------------------------------------------------------------- 1 | import type { TypeOfWebRequest, TypeOfWebServer, TypeOfWebResponse } from './shared'; 2 | 3 | /** 4 | * @beta 5 | */ 6 | export interface TypeOfWebServerMeta {} 7 | 8 | /** 9 | * @beta 10 | */ 11 | export interface TypeOfWebRequestMeta {} 12 | 13 | /** 14 | * @beta 15 | */ 16 | export interface TypeOfWebEvents { 17 | readonly ':afterResponse': TypeOfWebResponse; 18 | readonly ':server': TypeOfWebServer; 19 | readonly ':request': TypeOfWebRequest; 20 | readonly ':error': unknown; 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/cache.ts: -------------------------------------------------------------------------------- 1 | import { invariant } from '@typeofweb/utils'; 2 | 3 | import { stableJsonStringify } from '../utils/serializeObject'; 4 | 5 | import type { TypeOfWebCacheConfig } from './shared'; 6 | import type { Json } from '@typeofweb/utils'; 7 | import type CacheManager from 'cache-manager'; 8 | 9 | const serializeArgs = (args: Json): string => stableJsonStringify(args); 10 | 11 | export const createCachedFunction = any>({ 12 | fn, 13 | cache, 14 | cacheInstance, 15 | }: { 16 | readonly fn: Fn; 17 | readonly cache: TypeOfWebCacheConfig; 18 | readonly cacheInstance: CacheManager.Cache; 19 | }): Fn => { 20 | const ttlMs = 'expireIn' in cache ? cache.expireIn : expireAtToTtlMs(cache.expireAt); 21 | 22 | invariant(ttlMs, 'TTL is undefined - something went wrong'); 23 | invariant(ttlMs > 0, 'TTL<=0 - something went wrong'); 24 | 25 | // cache-manager requires ttl in seconds 26 | const ttl = ttlMs / 1000; 27 | 28 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- ok 29 | return function (...args) { 30 | const id = serializeArgs(args); 31 | return cacheInstance.wrap>( 32 | id, 33 | () => { 34 | return fn(...args); 35 | }, 36 | { ttl }, 37 | ); 38 | } as Fn; 39 | }; 40 | 41 | function expireAtToTtlMs(expireAt: Exclude) { 42 | /* istanbul ignore next */ 43 | const [hours = '00', minutes = '00'] = expireAt.split(':'); 44 | const expireAtDate = new Date(); 45 | expireAtDate.setHours(Number.parseInt(hours)); 46 | expireAtDate.setMinutes(Number.parseInt(minutes)); 47 | const now = new Date(); 48 | 49 | if (expireAtDate <= now) { 50 | // if expire at is in the past then surely it was meant to be the next day 51 | expireAtDate.setDate(expireAtDate.getDate() + 1); 52 | } 53 | 54 | return expireAtDate.getTime() - now.getTime(); 55 | } 56 | -------------------------------------------------------------------------------- /src/modules/events.ts: -------------------------------------------------------------------------------- 1 | import Events from 'events'; 2 | 3 | import type { EventBus } from './shared'; 4 | 5 | export const createEventBus = (): EventBus => { 6 | const eventEmitter = new Events(); 7 | 8 | return { 9 | emit(name, ...args) { 10 | eventEmitter.emit(name, ...args); 11 | }, 12 | on(name, cb) { 13 | eventEmitter.addListener(name, cb); 14 | }, 15 | off(name, cb) { 16 | eventEmitter.removeListener(name, cb); 17 | }, 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /src/modules/http.ts: -------------------------------------------------------------------------------- 1 | import Bodyparser from 'body-parser'; 2 | import Express from 'express'; 3 | import Stoppable from 'stoppable'; 4 | 5 | import type { AppOptions } from './shared'; 6 | 7 | export const initApp = () => { 8 | const app = Express(); 9 | 10 | app.use(Bodyparser.json()); 11 | app.use(Bodyparser.urlencoded({ extended: true })); 12 | app.disable('x-powered-by'); 13 | 14 | return app; 15 | }; 16 | 17 | /* istanbul ignore next */ 18 | export const listenExpressServer = (app: Express.Application, { port, hostname }: AppOptions) => { 19 | return new Promise((resolve, reject) => { 20 | app.once('error', reject); 21 | 22 | const server = Stoppable( 23 | app.listen(port, hostname, () => { 24 | app.off('error', reject); 25 | resolve(server); 26 | }), 27 | 1000, 28 | ); 29 | 30 | const stopServer = (_signal: 'SIGTERM' | 'SIGINT') => { 31 | server.stop(); 32 | }; 33 | 34 | process.on('SIGTERM', () => stopServer('SIGTERM')); 35 | process.on('SIGINT', () => stopServer('SIGINT')); 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /src/modules/openapi.ts: -------------------------------------------------------------------------------- 1 | import { isSchema, object } from '@typeofweb/schema'; 2 | import Prettier from 'prettier'; 3 | import { getTypeScriptReader, getOpenApiWriter, makeConverter } from 'typeconv'; 4 | 5 | import type { TypeOfWebRoute } from './shared'; 6 | import type { SomeSchema } from '@typeofweb/schema'; 7 | import type { IJsonSchema, OpenAPIV2 } from 'openapi-types'; 8 | 9 | type RouteSubset = Pick; 10 | 11 | export const routeConfigToOpenApiPathsDefinitions = async ( 12 | route: RouteSubset, 13 | ): Promise> => { 14 | const identifier = routeToIdentifier(route); 15 | const definitions = (await getOpenApiResultForRoute(identifier, route)).components.schemas; 16 | const paths = getOpenApiPathForRoute(identifier, route, definitions); 17 | 18 | return { 19 | definitions, 20 | paths, 21 | }; 22 | }; 23 | 24 | export const getOpenApiForRoutes = async ( 25 | routes: readonly RouteSubset[], 26 | options: { readonly title: string; readonly description?: string; readonly version: string }, 27 | ): Promise => { 28 | const openApiForRoutes = await Promise.all(routes.map(routeConfigToOpenApiPathsDefinitions)); 29 | const { paths, definitions } = openApiForRoutes.reduce((acc, el) => { 30 | return { 31 | definitions: { 32 | ...acc.definitions, 33 | ...el.definitions, 34 | }, 35 | paths: { 36 | ...acc.paths, 37 | ...el.paths, 38 | }, 39 | }; 40 | }); 41 | return { 42 | info: { 43 | title: options.title, 44 | version: options.version, 45 | }, 46 | swagger: '2.0', 47 | paths, 48 | definitions, 49 | }; 50 | }; 51 | 52 | const capitelizeFirst = (word: Word) => 53 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Capitalize first letter 54 | (word.slice(0, 1).toUpperCase() + word.slice(1)) as Capitalize; 55 | 56 | const pathToIdentifier = (path: string): string => { 57 | return capitelizeFirst( 58 | path 59 | .replace(/[\/\-](\w?)/g, (_, l: string) => l.toUpperCase()) 60 | .replace(/:(\w?)/g, (_, l: string) => 'By' + l.toUpperCase()), 61 | ); 62 | }; 63 | 64 | const routeToIdentifier = (route: RouteSubset) => { 65 | return capitelizeFirst(route.method) + pathToIdentifier(route.path); 66 | }; 67 | 68 | const getOpenApiPathForRoute = ( 69 | identifier: string, 70 | route: RouteSubset, 71 | definitions: OpenAPIV2.DefinitionsObject, 72 | ): OpenAPIV2.PathsObject => { 73 | const operation: OpenAPIV2.OperationObject = { 74 | operationId: identifier, 75 | parameters: [ 76 | ...schemaParamToOpenApi(route, 'params', identifier, definitions), 77 | ...schemaParamToOpenApi(route, 'query', identifier, definitions), 78 | ...(route.validation.payload 79 | ? [ 80 | { 81 | name: 'payload', 82 | in: 'body', 83 | schema: { 84 | $ref: `#/definitions/${schemaToName(identifier, 'payload')}`, 85 | }, 86 | }, 87 | ] 88 | : []), 89 | ], 90 | responses: { 91 | default: route.validation.response 92 | ? { 93 | description: schemaToName(identifier, 'response'), 94 | schema: { $ref: `#/definitions/${schemaToName(identifier, 'response')}` }, 95 | } 96 | : { 97 | description: 'Unknown response', 98 | }, 99 | }, 100 | }; 101 | const pathItemObject: OpenAPIV2.PathItemObject = { 102 | [route.method]: operation, 103 | }; 104 | const pathsObject: OpenAPIV2.PathsObject = { 105 | [expressRoutePathToSwaggerPath(route.path)]: pathItemObject, 106 | }; 107 | return pathsObject; 108 | }; 109 | 110 | const schemaParamToOpenApi = ( 111 | route: RouteSubset, 112 | kind: 'query' | 'params', 113 | identifier: string, 114 | definitions: OpenAPIV2.DefinitionsObject, 115 | ) => { 116 | const schema = route.validation[kind]; 117 | if (!schema) { 118 | return []; 119 | } 120 | return Object.keys(schema) 121 | .map((paramName: string): OpenAPIV2.GeneralParameterObject | undefined => { 122 | const openApiParamIn = kind === 'params' ? 'path' : kind; 123 | const schema = definitions[schemaToName(identifier, kind)]; 124 | const type = schema?.properties?.[paramName]?.type; 125 | const enumType = schema?.properties?.[paramName]?.anyOf; 126 | 127 | /* istanbul ignore if */ 128 | if (Array.isArray(type)) { 129 | // @todo 130 | return; 131 | } 132 | 133 | return { 134 | name: paramName, 135 | in: openApiParamIn, 136 | required: schema?.required?.includes(paramName) ?? true, 137 | type: type ?? 'string', 138 | ...(enumType && { 139 | enum: enumType.map((v: IJsonSchema & { readonly const?: string }) => v.const).filter((x): x is string => !!x), 140 | }), 141 | }; 142 | }) 143 | .filter((x): x is OpenAPIV2.GeneralParameterObject => typeof x !== 'undefined'); 144 | }; 145 | 146 | const getOpenApiResultForRoute = async ( 147 | identifier: string, 148 | route: RouteSubset, 149 | ): Promise<{ readonly components: { readonly schemas: OpenAPIV2.DefinitionsObject } }> => { 150 | const data = typesForRoute(route, identifier); 151 | 152 | const reader = getTypeScriptReader({ 153 | nonExported: 'include', 154 | unsupported: 'warn', 155 | }); 156 | const writer = getOpenApiWriter({ format: 'json', title: 'My API', version: 'v1', schemaVersion: '2.0' }); 157 | const converter = makeConverter(reader, writer); 158 | 159 | return JSON.parse((await converter.convert({ data })).data); 160 | }; 161 | 162 | const schemaToName = (identifier: string, kind: keyof RouteSubset['validation']) => { 163 | const suffix = capitelizeFirst(kind); 164 | return `${identifier}${suffix}`; 165 | }; 166 | 167 | const typesForRoute = (route: RouteSubset, identifier: string): string => { 168 | const typeScriptCode = Object.entries(route.validation).reduce((acc, [kind, maybeSchema]) => { 169 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- it's true 170 | const name = schemaToName(identifier, kind as keyof RouteSubset['validation']); 171 | const schema: SomeSchema = isSchema(maybeSchema) ? maybeSchema : object(maybeSchema)(); 172 | return acc + '\n' + `export type ${name} = ${schema.toString()};\n`; 173 | }, ''); 174 | return Prettier.format(typeScriptCode, { parser: 'typescript' }); 175 | }; 176 | 177 | const expressRoutePathToSwaggerPath = (expressPath: string): string => { 178 | return expressPath.replace(/:(\w+)/g, '{$1}'); 179 | }; 180 | -------------------------------------------------------------------------------- /src/modules/plugins.ts: -------------------------------------------------------------------------------- 1 | import type { TypeOfWebServerMeta, TypeOfWebRequestMeta } from './augment'; 2 | import type { TypeOfWebApp, TypeOfWebServer, HandlerArguments, TypeOfWebCacheConfig } from './shared'; 3 | import type { AnyAsyncFunction, MaybeAsync } from '@typeofweb/utils'; 4 | 5 | type PluginName_ = keyof TypeOfWebServerMeta | keyof TypeOfWebRequestMeta; 6 | 7 | export type TypeOfWebServerMetaWithCachedFunctions = { 8 | readonly [K in keyof TypeOfWebServerMeta[PluginName]]: TypeOfWebServerMeta[PluginName][K] extends AnyAsyncFunction 9 | ? 10 | | TypeOfWebServerMeta[PluginName][K] 11 | | { readonly cache: TypeOfWebCacheConfig; readonly fn: TypeOfWebServerMeta[PluginName][K] } 12 | : TypeOfWebServerMeta[PluginName][K]; 13 | }; 14 | 15 | type PluginCallbackReturnServer = PluginName extends keyof TypeOfWebServerMeta 16 | ? { readonly server: (server: TypeOfWebServer) => MaybeAsync> } 17 | : { readonly server?: never }; 18 | 19 | type PluginCallbackReturnRequest = PluginName extends keyof TypeOfWebRequestMeta 20 | ? { 21 | readonly request: (...args: HandlerArguments) => MaybeAsync; 22 | } 23 | : { readonly request?: never }; 24 | 25 | export type PluginCallbackReturnValue = PluginCallbackReturnServer & 26 | PluginCallbackReturnRequest; 27 | 28 | /** 29 | * @beta 30 | */ 31 | export interface TypeOfWebPlugin { 32 | readonly name: PluginName; 33 | readonly cb: ( 34 | app: TypeOfWebApp, 35 | ) => PluginName extends PluginName_ 36 | ? MaybeAsync> 37 | : MaybeAsync; 38 | } 39 | 40 | export interface TypeOfWebPluginInternal { 41 | readonly name: PluginName; 42 | readonly value: void | undefined | PluginCallbackReturnValue; 43 | } 44 | 45 | /** 46 | * @beta 47 | */ 48 | export function createPlugin( 49 | name: PluginName, 50 | cb: TypeOfWebPlugin['cb'], 51 | ): TypeOfWebPlugin { 52 | return { 53 | name, 54 | cb, 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /src/utils/encryptCookies.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Implementation based on https://github.com/hapijs/iron/blob/93fd15c76e656b1973ba134de64f3aeac66a0405/lib/index.js 3 | * Copyright (c) 2012-2020, Sideway Inc, and project contributors 4 | * All rights reserved. 5 | * https://github.com/hapijs/iron/blob/93fd15c76e656b1973ba134de64f3aeac66a0405/LICENSE.md 6 | * 7 | * Rewritten and repurposed by Michał Miszczyszyn 2021 8 | */ 9 | import Crypto from 'crypto'; 10 | import Util from 'util'; 11 | 12 | import { invariant } from '@typeofweb/utils'; 13 | 14 | const asyncPbkdf2 = Util.promisify(Crypto.pbkdf2); 15 | 16 | const PREFIX = 'Fe26.2' as const; 17 | const SEPARATOR = '*' as const; 18 | const KEY_LEN = 32 as const; 19 | const PBKDF2_ITERATIONS = 10 as const; 20 | const SEALED_CONTENT_LENGTH: SealedContent['length'] = 8; 21 | 22 | type BaseContent = readonly [ 23 | prefix: string, 24 | passwordId: string, 25 | salt: string, 26 | iv: string, 27 | encrypted: string, 28 | expiration: string, 29 | ]; 30 | type SignatureContent = readonly [hmacSalt: string, hmacDigest: string]; 31 | type SealedContent = readonly [...BaseContent, ...SignatureContent]; 32 | 33 | export const isSealed = (value: string) => { 34 | return value.startsWith(PREFIX) && value.split('*').length === SEALED_CONTENT_LENGTH; 35 | }; 36 | 37 | // export const encryptCookie = ({ value, secret }: { readonly value: string; readonly secret: string }) => { 38 | // if (secret.length !== 32) { 39 | // throw new Error('Secret must be exactly 32 characters long!'); 40 | // } 41 | 42 | // const iv = Crypto.randomBytes(16); 43 | // const cipher = Crypto.createCipheriv('aes256', secret, iv); 44 | 45 | // const encrypted = [iv.toString('base64'), ':', cipher.update(value, 'utf8', 'base64'), cipher.final('base64')].join( 46 | // '', 47 | // ); 48 | // return encrypted; 49 | // }; 50 | 51 | export const seal = async ({ 52 | value, 53 | secret, 54 | ttl, 55 | }: { 56 | readonly value: string; 57 | readonly secret: string; 58 | readonly ttl?: number; 59 | }) => { 60 | invariant(secret.length === KEY_LEN, `Secret must be exactly ${KEY_LEN} characters long!`); 61 | 62 | const key = await generateKey(secret); 63 | 64 | const cipher = Crypto.createCipheriv('aes-256-cbc', key.key, key.iv); 65 | const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); 66 | 67 | const encryptedB64 = base64urlEncode(encrypted); 68 | const ivB64 = base64urlEncode(key.iv); 69 | const keySaltB64 = base64urlEncode(key.salt); 70 | const expiration = ttl ? Date.now() + ttl : ''; 71 | const baseContent: BaseContent = [PREFIX, '', keySaltB64, ivB64, encryptedB64, String(expiration)]; 72 | 73 | const baseString = baseContent.join(SEPARATOR); 74 | const hmac = await hmacWithPassword(secret, baseString); 75 | 76 | const signature: SignatureContent = [base64urlEncode(hmac.salt), hmac.digest]; 77 | const sealedContent: SealedContent = [...baseContent, ...signature]; 78 | 79 | return sealedContent.join(SEPARATOR); 80 | }; 81 | 82 | export const unseal = async ({ sealed, secret }: { readonly sealed: string; readonly secret: string }) => { 83 | const sealedContent = sealed.split(SEPARATOR); 84 | invariant(sealedContent.length === SEALED_CONTENT_LENGTH, 'Cannot unseal: Incorrect data format.'); 85 | 86 | const [prefix, _passwordId, keySalt64, ivB64, encryptedB64, expiration, hmacSaltB64, hmacDigest] = 87 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- must assert 88 | sealedContent as unknown as SealedContent; 89 | 90 | invariant(prefix === PREFIX, 'Cannot unseal: Unsupported version.'); 91 | 92 | if (expiration) { 93 | invariant(Number.isInteger(Number(expiration)), 'Cannot unseal: Invalid expiration'); 94 | 95 | const exp = Number.parseInt(expiration, 10); 96 | invariant(exp > Date.now(), 'Cannot unseal: Expired seal'); 97 | } 98 | 99 | const baseContent: BaseContent = [PREFIX, '', keySalt64, ivB64, encryptedB64, expiration]; 100 | const baseString = baseContent.join(SEPARATOR); 101 | 102 | const hmacSalt = base64urlDecode(hmacSaltB64); 103 | const mac = await hmacWithPassword(secret, baseString, hmacSalt); 104 | 105 | invariant(timingSafeEqual(mac.digest, hmacDigest), 'Cannot unseal: Incorrect hmac seal value'); 106 | 107 | const encrypted = base64urlDecode(encryptedB64); 108 | const iv = base64urlDecode(ivB64); 109 | const keySalt = base64urlDecode(keySalt64); 110 | 111 | const key = await generateKey(secret, keySalt, iv); 112 | const decipher = Crypto.createDecipheriv('aes-256-cbc', key.key, key.iv); 113 | return decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8'); 114 | }; 115 | 116 | const generateKey = async (secret: string, maybeSalt?: Buffer, maybeIv?: Buffer) => { 117 | const salt = maybeSalt ?? Crypto.randomBytes(KEY_LEN); 118 | const iv = maybeIv ?? Crypto.randomBytes(KEY_LEN / 2); 119 | const key = await asyncPbkdf2(secret, salt, PBKDF2_ITERATIONS, KEY_LEN, 'sha512'); 120 | 121 | return { key, salt, iv }; 122 | }; 123 | 124 | const base64urlEncode = (value: Buffer) => { 125 | return base64ToUrlEncode(value.toString('base64')); 126 | }; 127 | 128 | const base64urlDecode = (base64: string) => { 129 | return Buffer.from(base64, 'base64'); 130 | }; 131 | 132 | const base64ToUrlEncode = (base64: string) => { 133 | return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/\=/g, ''); 134 | }; 135 | 136 | const hmacWithPassword = async (secret: string, baseString: string, maybeSalt?: Buffer) => { 137 | const key = await generateKey(secret, maybeSalt); 138 | const hmac = Crypto.createHmac('sha512', key.key).update(baseString); 139 | const digest = base64ToUrlEncode(hmac.digest('base64')); 140 | 141 | return { 142 | digest, 143 | salt: key.salt, 144 | }; 145 | }; 146 | 147 | const timingSafeEqual = (a: Parameters[0], b: Parameters[0]) => { 148 | try { 149 | return Crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); 150 | } catch (err) { 151 | return false; 152 | } 153 | }; 154 | -------------------------------------------------------------------------------- /src/utils/errors.ts: -------------------------------------------------------------------------------- 1 | import { left, right } from '@typeofweb/schema'; 2 | 3 | import { HttpStatusCode } from '../modules/httpStatusCodes'; 4 | 5 | import type { Either } from '@typeofweb/schema'; 6 | 7 | export function tryCatch(fn: () => T): Either { 8 | try { 9 | return right(fn()); 10 | } catch (err) { 11 | return left(err); 12 | } 13 | } 14 | 15 | /** 16 | * Shape of an object which can be used to produce erroneous HTTP responses. 17 | * 18 | * @beta 19 | */ 20 | export interface StatusError { 21 | readonly statusCode: HttpStatusCode; 22 | } 23 | 24 | /** 25 | * `HttpError` should be used for returning erroneous HTTP responses. 26 | * `HttpError` can be thrown synchronously or asynchronously inside the handler. It'll be caught and automatically turned into a proper Node.js HTTP response. 27 | * 28 | * @example 29 | * ```ts 30 | * import { HttpError, HttpStatusCode } from "@typeofweb/server"; 31 | * 32 | * import { app } from "./app"; 33 | * 34 | * app.route({ 35 | * path: "/teapot/coffe", 36 | * method: "get", 37 | * validation: {}, 38 | * handler() { 39 | * throw new HttpError( 40 | * HttpStatusCode.ImaTeapot, 41 | * "Try using the coffe machine instead!" 42 | * ); 43 | * }, 44 | * }); 45 | * ``` 46 | * 47 | * @beta 48 | */ 49 | /* eslint-disable functional/no-this-expression -- need to set properties in error classes */ 50 | export class HttpError extends Error implements StatusError { 51 | /** 52 | * @param statusCode - HTTP Status code. If an invalid code is provided, 500 will be used instead. Use {@link HttpStatusCode} for readability. 53 | * @param message - Message to be included in the response. If not provided, name of HttpStatusCode is used by default. 54 | * @param body - Additional data to be included in the response. Useful for returning nested errors or details. 55 | */ 56 | constructor( 57 | public readonly statusCode: HttpStatusCode, 58 | message?: string, 59 | public readonly body?: Record | readonly unknown[] | unknown, 60 | ) { 61 | super(message); 62 | if (statusCode < 100 || statusCode >= 600) { 63 | this.statusCode = HttpStatusCode.InternalServerError; 64 | } 65 | this.name = 'HttpError'; 66 | if (message) { 67 | this.message = message; 68 | } 69 | Object.setPrototypeOf(this, HttpError.prototype); 70 | } 71 | } 72 | /* eslint-enable functional/no-this-expression */ 73 | 74 | /** 75 | * @beta 76 | */ 77 | export function isStatusError(err: unknown): err is StatusError { 78 | return typeof err === 'object' && !!err && 'statusCode' in err; 79 | } 80 | -------------------------------------------------------------------------------- /src/utils/merge.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from '@typeofweb/utils'; 2 | 3 | /* eslint-disable @typescript-eslint/consistent-type-assertions -- :| */ 4 | export const deepMerge = (overrides: T, defaults: O): T & O => 5 | (Object.keys(defaults) as unknown as readonly (keyof O)[]).reduce( 6 | (overrides, key) => { 7 | const defaultValue = defaults[key]; 8 | return { 9 | ...overrides, 10 | [key]: 11 | isObject(overrides[key]) && isObject(defaultValue) 12 | ? deepMerge(overrides[key] as unknown as object, defaultValue as unknown as object) 13 | : overrides[key] === undefined 14 | ? defaultValue 15 | : overrides[key], 16 | }; 17 | }, 18 | { ...overrides } as T & O, 19 | ); 20 | /* eslint-enable @typescript-eslint/consistent-type-assertions */ 21 | -------------------------------------------------------------------------------- /src/utils/ms.ts: -------------------------------------------------------------------------------- 1 | import { invariant } from '@typeofweb/utils'; 2 | 3 | const units = [ 4 | { value: 1, dictionary: ['ms', 'millisecond', 'milliseconds'] }, 5 | { value: 1000, dictionary: ['s', 'sec', 'second', 'seconds'] }, 6 | { value: 1000 * 60, dictionary: ['m', 'min', 'minute', 'minutes'] }, 7 | { value: 1000 * 60 * 60, dictionary: ['h', 'hour', 'hours'] }, 8 | { value: 1000 * 60 * 60 * 24, dictionary: ['d', 'day', 'days'] }, 9 | ] as const; 10 | 11 | type Unit = typeof units[number]['dictionary'][number]; 12 | type ValidArg = `${number} ${Unit}`; 13 | 14 | /* istanbul ignore next */ 15 | export const ms = (arg: ValidArg): number => { 16 | const [value, unit] = arg.split(/\s+/); 17 | invariant(value != null, 'Missing value'); 18 | invariant(unit != null, 'Missing unit'); 19 | 20 | const parsedValue = Number.parseFloat(value); 21 | invariant(!Number.isNaN(parsedValue), 'Not a valid number'); 22 | 23 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- ok 24 | const config = units.find((config) => (config.dictionary as readonly string[]).includes(unit)); 25 | invariant(config, `Not a valid unit ${unit}`); 26 | return parsedValue * config.value; 27 | }; 28 | -------------------------------------------------------------------------------- /src/utils/node.ts: -------------------------------------------------------------------------------- 1 | import type { CorsOriginFunction, CorsOriginNodeCallback } from '../modules/shared'; 2 | 3 | export const promiseOriginFnToNodeCallback = (fn: CorsOriginFunction): CorsOriginNodeCallback => { 4 | return (origin, callback) => { 5 | async function run() { 6 | try { 7 | const data = await fn(origin); 8 | callback(null, data); 9 | } catch (err) { 10 | callback(err); 11 | } 12 | } 13 | void run(); 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/utils/routeSpecificity.ts: -------------------------------------------------------------------------------- 1 | enum Specificity { 2 | TRAILING_SLASH = '0', 3 | LITERAL = '1', 4 | PARAM = '2', 5 | WILDCARD = '3', 6 | } 7 | 8 | const segmentToSpecificity = (segment: string): Specificity => { 9 | if (segment === '') { 10 | return Specificity.TRAILING_SLASH; 11 | } 12 | if (segment.startsWith(':')) { 13 | return Specificity.PARAM; 14 | } 15 | if (segment.includes('*')) { 16 | return Specificity.WILDCARD; 17 | } 18 | return Specificity.LITERAL; 19 | }; 20 | 21 | export const calculateSpecificity = (path: string) => { 22 | return path.replace(/^\//g, '').split('/').map(segmentToSpecificity).join(''); 23 | }; 24 | -------------------------------------------------------------------------------- /src/utils/serializeObject.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from '@typeofweb/utils'; 2 | 3 | import type { Json, JsonObject } from '@typeofweb/utils'; 4 | 5 | export const stableJsonStringify = (arg: Json): string => { 6 | return JSON.stringify(isObject(arg) ? sortObjProperties(arg) : arg); 7 | }; 8 | 9 | const sortObjProperties = (arg: T): T => { 10 | return Object.fromEntries( 11 | Object.entries(arg) 12 | .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) 13 | .map(([key, val]) => (isObject(val) ? [key, sortObjProperties(val)] : [key, val])), 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | import type { SomeSchema } from '@typeofweb/schema'; 2 | import type { JsonPrimitive } from '@typeofweb/utils'; 3 | 4 | interface SchemaJsonRecord { 5 | readonly [Key: string]: SomeSchema; 6 | readonly [Key: number]: SomeSchema; 7 | } 8 | export interface SchemaJsonArray extends ReadonlyArray {} 9 | export type SchemaJson = SomeSchema; 10 | -------------------------------------------------------------------------------- /src/utils/uniqueId.ts: -------------------------------------------------------------------------------- 1 | import Os from 'os'; 2 | 3 | import type { Nominal } from '@typeofweb/utils'; 4 | 5 | const MAX_NUM = 2 ** 24; 6 | 7 | /** 8 | * @beta 9 | */ 10 | export type ServerId = Nominal; 11 | 12 | /** 13 | * @beta 14 | */ 15 | export type RequestId = Nominal; 16 | 17 | export const uniqueCounter = (() => { 18 | let mutableNum = Math.floor(Math.random() * MAX_NUM); 19 | 20 | return () => { 21 | /* istanbul ignore if */ 22 | if (mutableNum >= MAX_NUM) { 23 | mutableNum = 0; 24 | } 25 | return mutableNum++; 26 | }; 27 | })(); 28 | 29 | export function getMachineId() { 30 | const address = 31 | Object.values(Os.networkInterfaces()) 32 | .flat() 33 | .find((i) => !i.internal)?.mac ?? 34 | // fallback to random 35 | Array.from({ length: 16 }) 36 | .map(() => Math.floor(Math.random() * 256).toString(16)) 37 | .join(':'); 38 | 39 | return Number.parseInt(address.split(':').join('').substr(6), 16); 40 | } 41 | 42 | function toBytes(value: number, bytes: 4 | 3 | 2): string { 43 | return value 44 | .toString(16) 45 | .substr(-bytes * 2) 46 | .padStart(bytes * 2, '0'); 47 | } 48 | 49 | export function generateServerId() { 50 | // a 4-byte timestamp value in seconds 51 | // a 3-byte machine id (mac-based) 52 | // a 2-byte process.pid 53 | // a 3-byte incrementing counter, initialized to a random value 54 | 55 | const timestamp = toBytes(Math.floor(Date.now() / 1000), 4); 56 | const machineId = toBytes(getMachineId(), 3); 57 | const processId = toBytes(process.pid, 2); 58 | const c = toBytes(uniqueCounter(), 3); 59 | 60 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- ServerId 61 | return (timestamp + machineId + processId + c) as ServerId; 62 | } 63 | 64 | /** 65 | * @beta 66 | */ 67 | export function parseServerId(id: ServerId) { 68 | const serverStartedAt = new Date(Number.parseInt(id.substr(0, 4 * 2), 16) * 1000); 69 | const machineId = Number.parseInt(id.substr(0 + 4 * 2, 3 * 2), 16).toString(16); 70 | const processId = Number.parseInt(id.substr(0 + 4 * 2 + 3 * 2, 2 * 2), 16); 71 | const serverCounter = Number.parseInt(id.substr(0 + 4 * 2 + 2 * 2 + 3 * 2, 3 * 2), 16); 72 | 73 | return { serverStartedAt, machineId, processId, serverCounter }; 74 | } 75 | 76 | export function generateRequestId() { 77 | // a 4-byte timestamp value in seconds 78 | // a 3-byte incrementing counter, initialized to a random value 79 | const received = toBytes(Math.floor(Date.now() / 1000), 4); 80 | const requestCounter = toBytes(uniqueCounter(), 3); 81 | 82 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- RequestId 83 | return (received + requestCounter) as RequestId; 84 | } 85 | 86 | /** 87 | * @beta 88 | */ 89 | export function parseRequestId(id: RequestId) { 90 | const requestReceivedAt = new Date(Number.parseInt(id.substr(0, 4 * 2), 16) * 1000); 91 | const requestCounter = Number.parseInt(id.substr(0 + 4 * 2, 3 * 2), 16); 92 | 93 | return { requestReceivedAt, requestCounter }; 94 | } 95 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src", "__tests__", "examples"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node12/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "ES2020", 5 | "moduleResolution": "node", 6 | "isolatedModules": true, 7 | "strict": true, 8 | "alwaysStrict": true, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noUncheckedIndexedAccess": true, 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "skipLibCheck": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "resolveJsonModule": true, 19 | "allowUnreachableCode": true, 20 | "stripInternal": false, 21 | "declaration": true, 22 | "declarationMap": true, 23 | "sourceMap": true, 24 | "outDir": "dist" 25 | }, 26 | "include": ["src"] 27 | } 28 | -------------------------------------------------------------------------------- /tsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", 3 | "extends": ["@microsoft/api-extractor/extends/tsdoc-base.json"] 4 | } 5 | --------------------------------------------------------------------------------