├── .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 | [](#contributors-)
5 |
6 |
7 | [](https://codecov.io/gh/typeofweb/server)
8 | [](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 |
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 |
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 |
13 | Toggle reference sidebar
14 |
15 |
16 |
17 |
34 |
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 |
16 | {meta?.fileDestination && (
17 |
22 | Edit this file
23 |
24 | )}
25 | {children}
26 |
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 |
14 | {heading}
15 |
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 | Property Type Description cookies 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 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 hostname
string
Beta
default:
31 | "localhost"
openapi 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 port
number
Beta
default:
38 | 3000
router 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 | Property Type Description secret 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 | Property Type Description (call) (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 |
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 | Property Type Description (constructor) (constructor)
(statusCode: HttpStatusCode , message: string, body: unknown)
Constructs a new instance of the HttpError
class
Beta
body body
unknown
Beta
statusCode 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 | Property Type Description handler 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 method
HttpMethod
Beta
path path
Path
Beta
validation 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 | Property Type Description domain domain
string
Beta
encrypted encrypted
boolean
Beta
expires expires
Date
Beta
httpOnly httpOnly
boolean
Beta
maxAge maxAge
number
Beta
path path
string
Beta
sameSite sameSite
boolean | 'lax' | 'strict' | 'none'
Beta
secure 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 |
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 | Property Type Description events events
EventBus
Beta
inject 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
(plugin: TypeOfWebPlugin <string>) => MaybeAsync<TypeOfWebApp >
Beta
route route
(config: RouteConfig <Path, ParamsKeys, Params, Query, Payload, Response>) => TypeOfWebApp
Beta
start start
() => Promise<TypeOfWebServer >
Beta
stop 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 |
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 | Property Type Description cb cb
(app: TypeOfWebApp ) => PluginName extends PluginName_ ? MaybeAsync<PluginCallbackReturnValue<PluginName>> : MaybeAsync<undefined | void>
Beta
name 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 | Property Type Description cookies cookies
Record<string, string>
Record of cookies in the request. Encrypted cookies are automatically validated, deciphered, and included in this object.
Beta
id 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
Params
Beta
path path
Path
Beta
payload payload
Payload
Payload is always a valid JSON or null
. Only present for POST, PUT, and PATCH requests.
Beta
plugins plugins
TypeOfWebRequestMeta
Beta
query query
Query
An object which a result of parsing and validating the query string.
Beta
server server
TypeOfWebServer
A reference to the server instance. Useful for accessing server.plugins
or server.events
.
Beta
timestamp 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 | Property Type Description removeCookie removeCookie
(name: string, options: SetCookieOptions ) => MaybeAsync<void>
Beta
setCookie setCookie
(name: string, value: string, options: SetCookieOptions ) => MaybeAsync<void>
Beta
setStatus 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 | Property Type Description payload payload
Json | null
Response body. It should be always a valid JSON or null
.
Beta
request request
TypeOfWebRequest
A reference to the related request. Useful during the :afterResponse
event.
Beta
statusCode statusCode
number
HTTP status code. Could be any number between 100 and 599.
Beta
timestamp 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 |
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 ``;
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 |
--------------------------------------------------------------------------------