├── .eslintrc.json ├── .github ├── CODE_OF_CONDUCT.md └── workflows │ ├── node.yml │ └── npm-publish.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── changelog.md ├── package-lock.json ├── package.json ├── src ├── application.ts ├── index.ts └── node │ ├── http-utils.ts │ ├── push.ts │ ├── request.ts │ ├── response-headers.ts │ └── response.ts ├── test ├── application.ts ├── conditional.ts ├── context.ts ├── headers-interface-tests.ts ├── headers.ts ├── memory-request.ts ├── memory-response.ts ├── node │ ├── push.ts │ ├── request.ts │ └── response.ts ├── polyfills.mjs ├── request.ts └── websocket.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es6": true, 6 | "mocha": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "globals": { 14 | "Atomics": "readonly", 15 | "SharedArrayBuffer": "readonly" 16 | }, 17 | "parser": "@typescript-eslint/parser", 18 | "parserOptions": { 19 | "ecmaVersion": 2018, 20 | "sourceType": "module" 21 | }, 22 | "plugins": [ 23 | "@typescript-eslint" 24 | ], 25 | "rules": { 26 | "indent": ["error", 2, { 27 | "SwitchCase": 1 28 | }], 29 | "linebreak-style": ["error", "unix"], 30 | "no-constant-condition": ["error", { 31 | "checkLoops": false 32 | }], 33 | "quotes": ["error", "single", 34 | { 35 | "allowTemplateLiterals": false, 36 | "avoidEscape": true 37 | } 38 | ], 39 | "semi": ["error", "always"], 40 | "no-console": ["error", { "allow": ["warn", "error", "info", "debug"] }], 41 | "no-trailing-spaces": "error", 42 | "eol-last": "error", 43 | "@typescript-eslint/ban-ts-comment": ["error", 44 | { 45 | "ts-expect-error": "allow-with-description" 46 | } 47 | ], 48 | "@typescript-eslint/ban-tslint-comment": "error", 49 | "@typescript-eslint/consistent-type-assertions": ["error", { 50 | "assertionStyle": "as", 51 | "objectLiteralTypeAssertions": "never" 52 | }], 53 | "@typescript-eslint/member-delimiter-style": "error", 54 | "@typescript-eslint/no-inferrable-types": "off", 55 | "@typescript-eslint/no-explicit-any" : 0, 56 | "@typescript-eslint/no-for-in-array": "error", 57 | "@typescript-eslint/no-invalid-void-type": "error", 58 | "@typescript-eslint/no-namespace": "error", 59 | "@typescript-eslint/no-non-null-asserted-optional-chain": "error", 60 | "@typescript-eslint/no-unused-vars": ["error", { 61 | "ignoreRestSiblings": true, 62 | "args": "none" 63 | }], 64 | "@typescript-eslint/prefer-for-of": ["error"], 65 | "@typescript-eslint/prefer-ts-expect-error": ["error"] 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at me@evertpot.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /.github/workflows/node.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js Test Runner 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | node-test: 14 | name: Node.js tests 15 | 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 10 18 | 19 | strategy: 20 | matrix: 21 | node-version: [18.x, 20.x, 21.x] 22 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 23 | 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Use Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | - run: npm ci 31 | - run: npm run build --if-present 32 | - run: npm test 33 | 34 | lint: 35 | name: Lint 36 | 37 | runs-on: ubuntu-latest 38 | 39 | steps: 40 | - uses: actions/checkout@v3 41 | - name: Use Node.js 42 | uses: actions/setup-node@v3 43 | with: 44 | node-version: 18.x 45 | - run: npm ci 46 | - run: npm run lint 47 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Publish NPM package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | - run: npm ci 19 | - run: npm test 20 | 21 | publish-npm: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | - uses: actions/setup-node@v3 27 | with: 28 | node-version: 18 29 | registry-url: https://registry.npmjs.org/ 30 | - run: npm ci 31 | - run: npm publish 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 34 | 35 | publish-gpr: 36 | needs: build 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v3 40 | - uses: actions/setup-node@v3 41 | with: 42 | node-version: 18 43 | - run: npm ci 44 | - uses: actions/setup-node@v3 45 | with: 46 | node-version: 18 47 | registry-url: 'https://npm.pkg.github.com' 48 | scope: '@curveball' 49 | - run: npm publish 50 | env: 51 | NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # npm 2 | /node_modules 3 | 4 | # typescript output 5 | /dist 6 | 7 | # Directory used for running tests in CommonJS mode 8 | /cjs-test 9 | 10 | # vim 11 | .*.swp 12 | 13 | # nyc 14 | /.nyc_output 15 | 16 | # bun! 17 | /bun.lockb 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2024 Bad Gateway Inc. 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SOURCE_FILES:=$(shell find src/ -type f -name '*.ts') 2 | 3 | .PHONY:all 4 | all: build 5 | 6 | .PHONY:build 7 | build: dist/build 8 | 9 | .PHONY:test 10 | test: 11 | npx nyc mocha 12 | 13 | .PHONY:lint 14 | lint: 15 | npx eslint --quiet 'src/**/*.ts' 'test/**/*.ts' 16 | 17 | .PHONY:lint-fix 18 | lint-fix: fix 19 | 20 | .PHONY:fix 21 | fix: 22 | npx eslint --quiet 'src/**/*.ts' 'test/**/*.ts' --fix 23 | 24 | .PHONY:watch 25 | watch: 26 | npx tsc --watch 27 | 28 | .PHONY:start 29 | start: build 30 | 31 | .PHONY:clean 32 | clean: 33 | rm -rf dist 34 | 35 | dist/build: $(SOURCE_FILES) 36 | npx tsc 37 | touch dist/build 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Curveball 2 | ========= 3 | 4 | Curveball is a framework for building web services in Node.js. It fullfills a 5 | similar role to [Express][1] and it's heavily inspired by [Koa][2]. 6 | 7 | This web framework has the following goals: 8 | 9 | * A minimal foundation. 10 | * Completely written in and for [TypeScript][3]. 11 | * Modern Ecmascript features. 12 | * Async/await-based middleware. 13 | * Support for Node, [Bun](#Bun%20support), AWS Lambda, Azure functions. 14 | * Native support for HTTP/2, including easy access to HTTP/2 Push. 15 | * Native, deeply integrated Websocket. 16 | * Native support for modern HTTP features, such as [`103 Early Hints`][http-103]. 17 | * The ability to easily do internal sub-requests without having to do a real 18 | HTTP request. 19 | 20 | If you used Koa in the past, this is going to look pretty familiar. I'm a big 21 | fan of Koa myself and would recommend it over this project if you don't need 22 | any of the things this project offers. 23 | 24 | Installation 25 | ------------ 26 | 27 | npm install @curveball/core 28 | 29 | 30 | Getting started 31 | --------------- 32 | 33 | Curveball only provides a basic framework. Using it means implementing or 34 | using curveball middleware. For example, if you want a router, use or build 35 | a Router middleware. 36 | 37 | All of the following examples are written in typescript, but it is also 38 | possible to use the framework with plain javascript. 39 | 40 | ```typescript 41 | import { Application, Context } from '@curveball/core'; 42 | 43 | const app = new Application(); 44 | app.use((ctx: Context) => { 45 | 46 | ctx.status = 200; 47 | ctx.response.body = 'Hello world!' 48 | 49 | }); 50 | 51 | app.listen(4000); 52 | ``` 53 | 54 | Middlewares you might want 55 | -------------------------- 56 | 57 | * [Router](https://github.com/curveball/router) 58 | * [Body Parser](https://github.com/curveball/bodyparser) 59 | * [Controller][controller] 60 | * [Access Logs](https://github.com/curveball/accesslog) 61 | * [Sessions](https://github.com/curveball/session) 62 | * [Generating application/problem+json responses](https://github.com/curveball/problem) 63 | * [CORS](https://github.com/curveball/cors) 64 | * [Hypermedia Links](https://github.com/curveball/links) 65 | * [Server-rendered React support](https://github.com/curveball/react) 66 | * [Serving static files](https://github.com/curveball/static) 67 | * [JSON-Schema validation](https://github.com/curveball/validator) 68 | 69 | 70 | Authentication 71 | -------------- 72 | 73 | * [OAuth2](https://github.com/curveball/oauth2) 74 | * [OAuth2 add-on to let regular browsers log in](https://github.com/curveball/browser-to-bearer) 75 | 76 | You might like [a12n-server](https://github.com/curveball/a12n-server), a full 77 | OAuth2 authorization server, written in Curveball and works well with the 78 | OAuth2 middleware. 79 | 80 | 81 | AWS Lambda support / 'Serverless' 82 | --------------------------------- 83 | 84 | * [aws-lambda](https://github.com/curveball/aws-lambda). 85 | * [Azure functions](https://github.com/curveball/azure-function) 86 | 87 | 88 | Bun support 89 | ----------- 90 | 91 | To use Curveball with [Bun](https://bun.sh/), use the kernel package: 92 | 93 | ```typescript 94 | import { Application } from '@curveball/kernel'; 95 | 96 | const app = new Application(); 97 | 98 | // Add all your middlewares here! 99 | app.use( ctx => { 100 | ctx.response.body = {msg: 'hello world!'}; 101 | }); 102 | 103 | export default { 104 | port: 3000, 105 | fetch: app.fetch.bind(app) 106 | }; 107 | ``` 108 | 109 | Some more details can be found in this [article](https://evertpot.com/bun-curveball-framework/). 110 | 111 | Doing internal subrequests 112 | -------------------------- 113 | 114 | Many Node.js HTTP frameworks don't easily allow doing internal sub-requests. 115 | Instead, they recommend doing a real HTTP request. These requests are more 116 | expensive though, as it has to go through the network stack. 117 | 118 | Curveball allows you to do an internal request with 'mock' request and 119 | response objects. 120 | 121 | Suggested use-cases: 122 | 123 | * Running cheaper integration tests. 124 | * Embedding resources in REST apis. 125 | 126 | Example: 127 | 128 | ```typescript 129 | import { Application } from '@curveball/core'; 130 | 131 | const app = new Application(); 132 | const response = await app.subRequest('POST', '/foo/bar', { 'Content-Type': 'text/html' }, '

Hi

'); 133 | ``` 134 | 135 | Only the first 2 arguments are required. It's also possible to pass a Request object instead. 136 | 137 | ```typescript 138 | import { Application, MemoryRequest } from '@curveball/core'; 139 | 140 | const app = new Application(); 141 | const request = new MemoryRequest('POST', '/foo/bar', { 'Content-Type': 'text/html' }, '

Hi

'); 142 | const response = await app.subRequest(request); 143 | ``` 144 | 145 | HTTP/2 push 146 | ----------- 147 | 148 | HTTP/2 push can be used to anticipate GET requests client might want to do 149 | in the near future. 150 | 151 | Example use-cases are: 152 | 153 | * Sending scripts and stylesheets earlier for HTML-based sites. 154 | * REST api's sending resources based on relationships clients might want to 155 | follow. 156 | 157 | ```typescript 158 | import { Application } from '@curveball/core'; 159 | import http2 from 'http2'; 160 | 161 | const app = new Application(); 162 | const server = http2.createSecureServer({ 163 | key: fs.readFileSync('server-key.pem'), 164 | cert: fs.readFileSync('server-cert.pem') 165 | }, app.callback()); 166 | server.listen(4443); 167 | 168 | app.use( ctx => { 169 | 170 | ctx.response.status = 200; 171 | ctx.response.headers.set('Content-Type', 'text/html'); 172 | ctx.response.body = ''; 173 | 174 | await ctx.response.push( pushCtx => { 175 | 176 | pushCtx.path = '/script.js'; 177 | return app.handle(pushCtx); 178 | 179 | }); 180 | 181 | }); 182 | ``` 183 | 184 | HTTP/2 push works by sending HTTP responses to the client, but it also 185 | includes HTTP requests. This is because HTTP clients need to know which 186 | request the response belongs to. 187 | 188 | The `push` function simply takes a middleware, similar to `use` on 189 | Application. The callback will only be triggered if the clients supports 190 | push and wants to receive pushes. 191 | 192 | In the preceding example, we are using `app.handle()` to do a full HTTP 193 | request through all the regular middlewares. 194 | 195 | It's not required to do this. You can also generate responses right in the 196 | callback or call an alternative middleware. 197 | 198 | Lastly, `pushCtx.request.method` will be set to `GET` by default. `GET` is 199 | also the only supported method for pushes. 200 | 201 | 202 | Sending 1xx Informational responses 203 | ----------------------------------- 204 | 205 | Curveball has native support for sending informational responses. Examples are: 206 | 207 | * [`100 Continue`][http-100] to let a client know even before the request 208 | completed that it makes sense to continue, or that it should break off the 209 | request. 210 | * [`102 Processing`][http-102] to periodically indicate that the server is 211 | still working on the response. This might not be very useful anymore. 212 | * [`103 Early Hints`][http-103] a new standard to let a client or proxy know 213 | early in the process that some headers might be coming, allowing clients or 214 | proxies to for example pre-fetch certain resources even before the initial 215 | request completes. 216 | 217 | Here's an example of a middleware using `103 Early Hints`: 218 | 219 | ```typescript 220 | import { Application, Context, Middleware } from '@curveball/core'; 221 | 222 | const app = new Curveball(); 223 | app.use(async (ctx: Context, next: Middleware) => { 224 | 225 | await ctx.response.sendInformational(103, { 226 | 'Link' : [ 227 | ' rel="prefetch" as="style"', 228 | ' rel="prefetch" as="script"', 229 | ] 230 | }); 231 | await next(); 232 | 233 | }); 234 | ``` 235 | 236 | Websocket 237 | --------- 238 | 239 | To get Websocket up and running, just run: 240 | 241 | ```typescript 242 | app.listenWs(port); 243 | ``` 244 | 245 | This will start a websocket server on the specified port. Any incoming 246 | Websocket connections will now *just work*. 247 | 248 | If a Websocket connection was started, the `Context` object will now have a 249 | `webSocket` property. This property is simply an instance of [Websocket][ws] 250 | from the [ws][ws] NPM package. 251 | 252 | Example usage: 253 | 254 | ```typescript 255 | import { UpgradeRequired } from '@curveball/http-errors'; 256 | 257 | app.use( ctx => { 258 | if (!ctx.webSocket) { 259 | throw new UpgradeRequired('This endpoint only supports WebSocket'); 260 | } 261 | 262 | ctx.webSocket.send('Hello'); 263 | ctx.webSocket.on('message', (msg) => { 264 | console.log('Received %s', msg); 265 | }); 266 | 267 | }); 268 | ``` 269 | 270 | If you use typescript, install the `@types/ws` package to get all the correct 271 | typings: 272 | 273 | npm i -D @types/ws 274 | 275 | The [Controller][controller] package also has built-in features to make this 276 | even easier. 277 | 278 | 279 | API 280 | --- 281 | 282 | ### The Application class 283 | 284 | The application is main class for your project. It's mainly responsible for 285 | calling middlewares and hooking into the HTTP server. 286 | 287 | It has the following methods 288 | 289 | * `use(m: Middleware)` - Add a middleware to your application. 290 | * `handle(c: Context)` - Take a Context object, and run all middlewares in 291 | order on it. 292 | * `listen(port: number)` - Run a HTTP server on the specified port. 293 | * `listenWs(port: number)` - Start a websocket server on the specified port. 294 | * `callback()` - The result of this function can be used as a requestListener 295 | for node.js `http`, `https` and `http2` packages. 296 | * `subRequest(method: string, path:string, headers: object, body: any)` - Run 297 | an internal HTTP request and return the result. 298 | * `subRequest(request: Request)` - Run an internal HTTP request and return the 299 | result. 300 | * `origin` - Sets the 'origin' for the application. This is used to determine 301 | absolute URIs. You can set the `origin` directly on the application, but 302 | you can also set a `CURVEBALL_ORIGIN` environment variable. If nothing is 303 | set this value will default to `http://localhost`. 304 | 305 | 306 | ### The Context class 307 | 308 | The Context object has the following properties: 309 | 310 | * `request` - An instance of `Request`. 311 | * `response` - An instance of `Response`. 312 | * `state` - An object you can use to store request-specific state information. 313 | this object can be used to pass information between middlewares. A common 314 | example is that an authentication middlware might set 'currently logged in 315 | user' information here. 316 | * `ip()` - Get the `ip` address of the HTTP client that's trying to connect. 317 | * `path` - The path of the request, for example `/foo.html`. 318 | * `method` - For example, `POST`. 319 | * `query` - An object containing the query parametes. 320 | * `status` - The HTTP status code, for example `200` or `404`. 321 | * `sendInformational(status, headers?)` - Sends a `100 Continue`, 322 | `102 Processing` or `103 Early Hints` - response with optional headers. 323 | * `push(callback: Middleware)` - Do a HTTP/2 push. 324 | * `redirect(status, location)` - Send a redirect status code and set a 325 | `Location` header. 326 | * `absoluteUrl` - The absolute URL for the request. 327 | 328 | 329 | ### The Request interface 330 | 331 | The Request interface represents the HTTP request. It has the following 332 | properties and methods: 333 | 334 | * `headers` - An instance of `Headers`. 335 | * `path` - The path of the request, for example `/foo.html`. 336 | * `method` - For example, `POST`. 337 | * `requestTarget` - The full `requestTarget` from the first line of the HTTP 338 | request. 339 | * `body` - This might represent the body, but is initially just empty. It's 340 | up to middlewares to do something with raw body and parse it. 341 | * `rawBody()` - This function uses the [raw-body][5] function to parse the 342 | body from the request into a string or Buffer. You can only do this once, 343 | so a middleware should use this function to populate `body`. 344 | * `query` - An object containing the query parametes. 345 | * `type` - The `Content-Type` without additional parameters. 346 | * `accepts` - Uses the [accepts][6] package to do content-negotiation. 347 | * `is(contentType)` - Returns true or false if the `Content-Type` of the 348 | request matches the argument. If your `Content-Type` is 349 | `application/hal+json` it will return true for `application/hal+json`, 350 | `hal+json` and `json`. 351 | * `origin` - The 'origin' for the request, for example: 352 | `http://my-api:8008`. 353 | * `absoluteUrl` - The absolute URL for the request. 354 | * `ip()` - Returns the ip address of the client. This may be ipv4 or ipv6. 355 | If `CURVEBALL_TRUSTPROXY` is set in the environment and truthy, this will 356 | use the information from the `X-Forwarded-For` header (if available). 357 | 358 | 359 | 360 | ### The Response interface 361 | 362 | The Response interface represents a HTTP response. It has the following 363 | properties and methods: 364 | 365 | * `headers` - An instance of `Headers`. 366 | * `status` - The HTTP status code, for example `200` or `404`. 367 | * `body` - The response body. Can be a string, a buffer or an Object. If it's 368 | an object, the server will serialize it as JSON. 369 | * `type` - The `Content-Type` without additional parameters. 370 | * `sendInformational(status, headers?)` - Sends a `100 Continue`, 371 | `102 Processing` or `103 Early Hints` - response with optional headers. 372 | * `push(callback: Middleware)` - Do a HTTP/2 push. 373 | * `is(contentType)` - Returns true or false if the `Content-Type` of the 374 | response matches the argument. If your `Content-Type` is 375 | `application/hal+json` it will return true for `application/hal+json`, 376 | `hal+json` and `json`. 377 | * `redirect(status, location)` - Send a redirect status code and set a 378 | `Location` header. 379 | * `origin` - The 'origin' for the request, for example: 380 | `http://my-api:8008`. 381 | 382 | 383 | ### The Headers interface 384 | 385 | The Headers interface represents HTTP headers for both the `Request` and 386 | `Response`. 387 | 388 | It has the following methods: 389 | 390 | * `set(name, value)` - Sets a HTTP header. 391 | * `get(name)` - Returns the value of a HTTP header, or null. 392 | * `has(name)` - Returns true or false if the header exists. 393 | * `delete(name)` - Deletes a HTTP header. 394 | * `append(name, value)` - Adds a HTTP header, but doesn't erase an existing 395 | one with the same name. 396 | * `getAll()` - Returns all HTTP headers as a key-value object. 397 | 398 | Other features 399 | -------------- 400 | 401 | Use the `checkConditional` function to verify the following headers: 402 | 403 | * `If-Match` 404 | * `If-None-Match` 405 | * `If-Modified-Since` 406 | * `If-Unmodified-Since`. 407 | 408 | Signature: 409 | 410 | ```typescript 411 | checkConditionial(req: RequestInterface, lastModified: Date | null, etag: string | null): 200 | 304 : 412; 412 | ``` 413 | 414 | This function returns `200` if the conditional passed. If it didn't, it will 415 | return either `304` or `412`. The former means you'll want to send a 416 | `304 Not Modified` back, the latter `412 Precondition Failed`. 417 | 418 | `200` does not mean you _have_ to return a `200 OK` status, it's just an easy 419 | way to indicate that all all conditions have passed. 420 | 421 | 422 | [1]: https://expressjs.com/ "Express" 423 | [2]: https://koajs.com/ "Koa" 424 | [3]: https://www.typescriptlang.org/ "TypeScript" 425 | [5]: https://www.npmjs.com/package/raw-body 426 | [6]: https://www.npmjs.com/package/accepts 427 | [http-100]: https://tools.ietf.org/html/rfc7231#section-6.2.1 "RFC7231: 100 Continue" 428 | [http-102]: https://tools.ietf.org/html/rfc2518#section-10.1 "RFC2518: 102 Processing" 429 | [http-103]: https://tools.ietf.org/html/rfc8297 "RFC8297: 103 Early Hints" 430 | [ws]: https://github.com/websockets/ws 431 | [controller]: https://github.com/curveball/controller 432 | [bun]: https://github.com/curveball/bun 433 | [lambda]: https://github.com/curveball/bun 434 | 435 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 1.0.2 (2024-11-06) 5 | ------------------ 6 | 7 | * Fixed another bug related to CURVEBALL_TRUSTPROXY 8 | 9 | 10 | 1.0.1 (2024-10-30) 11 | ------------------ 12 | 13 | * Actually check `CURVEBALL_TRUSTPROXY`. 14 | 15 | 16 | 1.0.0 (2024-01-15) 17 | ------------------ 18 | 19 | * Finally! Curveball v1. Only took 6 years. 20 | * CommonJS support has been dropped. The previous version of this library 21 | supported both CommonJS and ESM. The effort of this no longer feels worth it. 22 | ESM is the future, so we're dropping CommonJS. 23 | * Now requires Node 18. 24 | 25 | 26 | 0.21.1 (2023-02-17) 27 | ------------------- 28 | 29 | * Export server version string 30 | 31 | 32 | 0.21.0 (2023-02-14) 33 | ------------------- 34 | 35 | * ESM support. 36 | * Dropped Node 14 support. Use Node 16 or later. 37 | 38 | 39 | 0.20.0 (2022-09-03) 40 | ------------------- 41 | 42 | * Most of the internal plumbing has moved to `@curveball/kernel`. 43 | `@curveball/core` still contains all of the Node-specific code, but this 44 | allows curveball to run on Bun. 45 | 46 | 47 | 0.20.0-alpha.0 (2022-09-01) 48 | --------------------------- 49 | 50 | * Added support for `fetch()`, which lets you make requests in an a Curveball 51 | application using the native `Request` and `Response` objects. 52 | 53 | 54 | 0.19.0 (2022-04-25) 55 | ------------------- 56 | 57 | * Now requires Node 14. 58 | * `Application`, `Context`, `Request` and `Response` now have a `origin` 59 | property. This defaults defaults to `http://localhost`. This can be 60 | overridden by setting `Application.origin`, or setting a `CURVEBALL_ORIGIN` 61 | environment variable. `PUBLIC_URI` also works, but it's mainly a fallback for 62 | earlier examples and recommendations. 63 | * `Request` and `Context` now have a `absoluteUrl` property. This is calculated 64 | based on the request path and the `origin`. 65 | * BC Break: Due to the new `origin` property, all `Request` and `Response` 66 | classes now have an extra constructor argument. This means if you ever 67 | manually constructed any of these, there's a small change you'll need to 68 | make. Typescript should point all these problems! 69 | * If `CURVEBALL_TRUSTPROXY` is set, `request.ip()` will trust proxies by 70 | default, and return the ip of the real client instead of the proxy. 71 | 72 | 73 | 0.18.0 (2022-04-16) 74 | ------------------- 75 | 76 | Identical release as the previous alpha. 77 | 78 | 79 | 80 | 81 | 0.18.0-alpha.0 (2022-04-09) 82 | --------------------------- 83 | 84 | * The `Context` interface has been removed, and the `BaseContext` class is 85 | renamed to `Context`. This is a BC break, but should only be an issue if you 86 | used the `Context` interface directly. `BaseContext` is still exported but 87 | simply aliased to `Context`. This alias will be removed from a future 88 | version. This change should make ite asier to use interface declaration 89 | merging to extend Context. 90 | * The `ws` dependency has been updated to version 8. There are some [breaking 91 | changes][ws8] in this release. The most likely you'll hit is that incoming 92 | messages are now of type `Buffer` instead of `string`. Check out the [ws 93 | changelog][ws8] for more details. 94 | 95 | 96 | 0.17.0 (2022-02-08) 97 | ------------------- 98 | 99 | * `listen()` now starts a WebSocket service on the same port by default. 100 | * `listenWs()` is now deprecated, and will be removed in a future version. 101 | * JSON is now pretty-printed by default. 102 | 103 | Happy birthday Mom! 104 | 105 | 106 | 0.16.4 (2022-01-05) 107 | ------------------- 108 | 109 | * Update all dependencies and ensure compatibility with latest Typescript 110 | changes. 111 | 112 | 113 | 0.16.3 (2021-05-06) 114 | ------------------- 115 | 116 | * Updated lint rules 117 | * Make file update 118 | * Updated dependencies 119 | 120 | 121 | 0.16.2 (2021-02-18) 122 | ------------------- 123 | 124 | * Releasing on Github packages. 125 | 126 | 127 | 0.16.1 (2021-02-01) 128 | ------------------- 129 | 130 | * Request.body is no longer optional, which will help with typing. It can still 131 | be explicitly set to `null`. 132 | 133 | 134 | 0.16.0 (2021-01-30) 135 | ------------------- 136 | 137 | * This release is identical to the last 138 | 139 | 140 | 0.16.0-beta.0 (2021-01-10) 141 | -------------------------- 142 | 143 | * BC Break: `Request.body` is now typed as `unknown` instead of `any`. This 144 | forces users to either validate the body, or cast to `any`. 145 | * It's now possible to write directly to response streams by setting 146 | response.body to a callback. 147 | 148 | 149 | 0.15.0 (2020-12-05) 150 | ------------------- 151 | 152 | * Curveball now required Node 12. 153 | * `esModuleInterop` flag is no longer required to use curveball. 154 | 155 | 156 | 0.14.3 (2020-09-23) 157 | ------------------- 158 | 159 | * #155 - `listen` and `listenWs` now both have a second `host` argument to bind 160 | to a specific interface. (@Nicholaiii) 161 | * #145 - `request.headers` and `response.head` function to get a list of header 162 | values for a given header name. (@Nicholaiii)ers` now have a `getMany()` 163 | 164 | 165 | 0.14.2 (2020-07-14) 166 | ------------------- 167 | 168 | * Republish of 1.14.1, which was missing some changes. 169 | 170 | 171 | 0.14.1 (2020-07-14) 172 | ------------------- 173 | 174 | * types ws package is now non-devDependency 175 | 176 | 177 | 0.14.0 (2020-07-13) 178 | ------------------- 179 | 180 | * Native Websocket support. If enabled, `ctx` will now have a `webSocket` 181 | property. 182 | 183 | 184 | 0.13.0 (2020-06-16) 185 | ------------------- 186 | 187 | * Removed `Request` and `Response` interfaces again. They actually made it more 188 | difficult to extend. 189 | 190 | 191 | 0.12.0 (2020-03-22) 192 | ------------------- 193 | 194 | * Both `Request` and `Response` are now typescript interfaces. This will allow 195 | plugins to extends them via interface declaration merging. 196 | * Everything is now compiled with the typescript 'strict' mode, which caused 197 | some internal refactoring. 198 | 199 | 200 | 0.11.2 (2020-03-09) 201 | ------------------- 202 | 203 | * Added utilities to check `If-Match`, `If-None-Match`, `If-Modified-Since`, 204 | `If-Unmodified-Since`. 205 | * Typescript target is now `es2019` instead of `esnext` to ensure that older 206 | Node.js versions are supported. 207 | * Added a workaround to make sure the package works around a bug in 208 | `@types/node@13`. 209 | 210 | 211 | 0.11.1 (2020-03-03) 212 | ------------------- 213 | 214 | * Set `Content-Type` to `text/plain` for errors that fall without being caught 215 | by an exception handling middleware. 216 | 217 | 218 | 0.11.0 (2020-02-26) 219 | ------------------- 220 | 221 | * `Context` is no longer a class, it's an interface. It's default 222 | implementation is now `BaseContext`. This allows plugins to modify the 223 | interface and add new features. 224 | 225 | 226 | 0.10.0 (2020-01-05) 227 | ------------------- 228 | 229 | * Added a `redirect()` function to `Context` and `Response` objects, making it 230 | easier to set a status-code and location header in one step. 231 | * Support for more `Prefer` parameters: `depth-noroot`, `safe`, `transclude`. 232 | 233 | 234 | 0.9.4 (2019-12-21) 235 | ------------------ 236 | 237 | * Fix a bug in HTTP/2 Push. Resources with query parameters in their path were 238 | not pushed correctly. 239 | 240 | 241 | 0.9.3 (2019-12-19) 242 | ------------------ 243 | 244 | * The `is()` function will now also match wildcards, such as `image/*`. 245 | 246 | 247 | 0.9.2 (2019-11-04) 248 | ------------------ 249 | 250 | * `rawBody()` had an incorrect type. It should _always_ return a `Buffer` if 251 | the first argument is omitted. 252 | 253 | 254 | 0.9.1 (2019-09-19) 255 | ------------------ 256 | 257 | * The server now sets a `application/hal+json` content-type if nothing else was 258 | set. This fixes a regression from 0.9.0. 259 | 260 | 261 | 0.9.0 (2019-09-13) 262 | ------------------ 263 | 264 | * `Request` and `Response` types are now abstract classes instead of 265 | interfaces. This removes a bunch of duplication. 266 | * `Request` objects now have a `prefer()` method for quickly reading out the 267 | values from the RFC7240 `Prefer` header. 268 | 269 | 270 | 0.8.6 (2019-03-30) 271 | ------------------ 272 | 273 | * Correctly set status for HTTP exceptions in sub-requests. 274 | * Fixed a regression from 0.8.3. 275 | 276 | 277 | 0.8.3 (2019-03-29) 278 | ------------------ 279 | 280 | * Correctly set status for HTTP exceptions in sub-requests. 281 | 282 | 283 | 0.8.2 (2019-03-29) 284 | ------------------ 285 | 286 | * Subrequests should behave as regular requests and catch any exceptions. 287 | * Updated all dependecies. 288 | * Stricter typescript rules. 289 | 290 | 291 | 0.8.1 (2018-11-01) 292 | ------------------ 293 | 294 | * Now exporting an `invokeMiddleware` function that can be used to chain and 295 | call mutltiple middlewares. 296 | * Application will now by default throw a `NotFound` exception if nothing 297 | handled a HTTP request. 298 | 299 | 300 | 0.8.0 (2018-10-12) 301 | ------------------ 302 | 303 | * It's now possible to pass objects as Middlewares. If an object has a member 304 | thats the `middlewareCall` symbol, it will call that instead. 305 | * The package now exports a `invokeMiddleware` function, which is a convenience 306 | method to call many middlewares. 307 | * #70: It's possible to set `Response.body` to a `stream.Readable` object. 308 | * #91: Bugfix: The `accept()` function ignored changes made my middlewares to 309 | `Accept` header. 310 | 311 | 312 | 0.7.0 (2018-10-04) 313 | ------------------ 314 | 315 | * The `Context` object now has an `ip` method that can be used to get the ip 316 | address of the client that's connecting. 317 | * The `Request` and `Response` objects now have an `is()` method that can be 318 | used to easily check the `Content-Type` header of the object. For example 319 | `Request.is('json')` will return true for `application/hal+json`. 320 | * The `Headers` object now has a `has()` method. 321 | 322 | 323 | 0.6.0 (2018-09-05) 324 | ------------------ 325 | 326 | * Request and Response object are now generic. `Response` implies the body 327 | property has type `T`. 328 | * `ctx.status` is now writable. 329 | 330 | 331 | 0.5.0 (2018-08-31) 332 | ------------------ 333 | 334 | * #74: Added `method`, `path`, `status`, `accepts`, `push`, 335 | `sendInformational`, and `query` to Context object. These properties and 336 | methods all forward to the request or response object. 337 | * #78: By default the Application will return with a `404` response, unless a 338 | middleware updates the status or a body was set. 339 | * Tests will now error when a node version under 8.11.2 is used. They broke 340 | before as well, but it's more explicit now about why. 341 | 342 | 343 | 0.4.3 (2018-07-09) 344 | ------------------ 345 | 346 | * `Application.buildContextFromHttp` is now public. 347 | 348 | 349 | 0.4.2 (2018-07-04) 350 | ------------------ 351 | 352 | * #71: Fixed error messages when a HTTP/2 client disables or refuses a push 353 | late in the process. 354 | * #72: Refactored node-specific code into its own directory. 355 | 356 | 357 | 0.4.1 (2018-07-01) 358 | ------------------ 359 | 360 | * #57: `Response.type` is now settable. 361 | 362 | 363 | 0.4.0 (2018-07-01) 364 | ------------------ 365 | 366 | * #4: Support for HTTP/2 push via the `Response.push()` method. 367 | * #62: It's now possible to do internal sub-requests without going through the 368 | HTTP stack, with `Application.subRequest()`. 369 | * Added `MemoryRequest` and `MemoryResponse`. 370 | * #56: `Response.body` may now be `null`. 371 | * Renamed package to `@curveball/core`. 372 | 373 | 374 | 0.3.1 (2018-06-29) 375 | ------------------ 376 | 377 | * Added License, Code of Conduct. 378 | * #52: Support for `Buffer` and arbitrary objects in `response.body`. The 379 | latter will automatically get converted to JSON. 380 | 381 | 382 | 0.3.0 (2018-06-26) 383 | ------------------ 384 | 385 | * #5: Support for informational status codes such as `100 Continue` and `103 386 | Early Hints` for both HTTP/1 and HTTP/2. 387 | * #28: HTTP2 support. 388 | * #34: `Application` is now the default export. 389 | * #47: `Application.callback` now returns a callback instead of implementing 390 | it. This makes it a bit easier to deal with `this` scope and is also 391 | consistent with Koa. 392 | * #48: Added a setter for `Response.status()`. 393 | * Now exporting the `Middleware` type. 394 | 395 | 396 | 0.2.0 (2018-06-25) 397 | ------------------ 398 | 399 | * #19: Added `Request.rawBody()` method. 400 | * #33: Added `Request.accept()` method. 401 | * #35: Added `Request.type` and `Response.type`. 402 | * #36: Added `Request.query`. 403 | * #37: `Response.body` now has type `any`. 404 | * #38: Added `Context.state`. 405 | * #39: Added `Application.callback`. 406 | 407 | 408 | 0.1.2 (2018-06-24) 409 | ------------------ 410 | 411 | * Set `script` and `types` correctly in `package.json`. 412 | 413 | 414 | 0.1.1 (2018-06-24) 415 | ------------------ 416 | 417 | * Fixed npm package distribution. Was shipping the wrong files. 418 | 419 | 420 | 0.1.0 (2018-06-24) 421 | ------------------ 422 | 423 | * Created `Request`, `Response`, `Application`, `Context`, `Headers` classes. 424 | * Basic framework works 425 | 426 | 427 | 0.0.1 (2018-06-23) 428 | ------------------ 429 | 430 | * First published on npm.js to claim package name. 431 | 432 | [ws8]: https://github.com/websockets/ws/releases/tag/8.0.0 433 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@curveball/core", 3 | "version": "1.0.2", 4 | "description": "Curveball is a framework writting in Typescript for Node.js", 5 | "type": "module", 6 | "exports": "./dist/index.js", 7 | "homepage": "https://github.com/curveball/core#readme", 8 | "bugs": { 9 | "url": "https://github.com/curveball/core/issues" 10 | }, 11 | "keywords": [ 12 | "http", 13 | "framework", 14 | "nodejs", 15 | "typescript", 16 | "push" 17 | ], 18 | "author": "Evert Pot (https://evertpot.com/)", 19 | "license": "MIT", 20 | "scripts": { 21 | "prepublishOnly": "make build", 22 | "test": "make test", 23 | "lint": "make lint", 24 | "fix": "make fix", 25 | "tsc": "tsc", 26 | "start": "make start" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/curveball/core.git" 31 | }, 32 | "dependencies": { 33 | "@types/ws": "^8.5.3", 34 | "raw-body": "^2.4.1", 35 | "ws": "^8.5.0" 36 | }, 37 | "peerDependencies": { 38 | "@curveball/kernel": "^1" 39 | }, 40 | "devDependencies": { 41 | "@curveball/http-errors": "^0.5.0", 42 | "@curveball/kernel": "^1.0.0", 43 | "@types/chai": "^5.0.1", 44 | "@types/co-body": "^6.1.0", 45 | "@types/mocha": "^10.0.1", 46 | "@types/node": "^18.19.6", 47 | "@types/node-fetch": "^2.5.8", 48 | "@types/sinon": "^17.0.3", 49 | "@typescript-eslint/eslint-plugin": "^6.18.1", 50 | "@typescript-eslint/parser": "^6.18.1", 51 | "chai": "^5.1.2", 52 | "eslint": "^8.6.0", 53 | "mocha": "^10.2.0", 54 | "node-fetch": "^3.3.0", 55 | "nyc": "^15.1.0", 56 | "sinon": "^17.0.1", 57 | "ts-node": "^10.4.0", 58 | "typescript": "^5.3.3" 59 | }, 60 | "engines": { 61 | "node": ">= 16" 62 | }, 63 | "files": [ 64 | "package.json", 65 | "README.md", 66 | "dist", 67 | "LICENSE", 68 | "src" 69 | ], 70 | "mocha": { 71 | "loader": [ 72 | "ts-node/esm" 73 | ], 74 | "recursive": true, 75 | "extension": [ 76 | "ts", 77 | "js", 78 | "tsx" 79 | ], 80 | "exit": true 81 | }, 82 | "nyc": { 83 | "extension": [ 84 | ".ts" 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/application.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'node:http'; 2 | import { WebSocket, WebSocketServer } from 'ws'; 3 | import * as net from 'node:net'; 4 | 5 | import { 6 | HttpCallback, 7 | NodeHttpRequest, 8 | NodeHttpResponse, 9 | nodeHttpServerCallback, 10 | } from './node/http-utils.js'; 11 | import NodeRequest from './node/request.js'; 12 | import NodeResponse from './node/response.js'; 13 | 14 | import { 15 | Application as BaseApplication, 16 | Context, 17 | MemoryResponse, 18 | Middleware, 19 | } from '@curveball/kernel'; 20 | 21 | export default class Application extends BaseApplication { 22 | 23 | middlewares: Middleware[] = []; 24 | 25 | private wss: WebSocketServer | undefined; 26 | 27 | /** 28 | * Starts a HTTP server on the specified port. 29 | */ 30 | listen(port: number, host?: string): http.Server { 31 | const server = http.createServer(this.callback()); 32 | server.on('upgrade', this.upgradeCallback.bind(this)); 33 | 34 | return server.listen(port, host); 35 | } 36 | 37 | /** 38 | * Starts a Websocket-only server on the specified port. 39 | * 40 | * Note that this is now deprecated. The listen() function already starts 41 | * a websocket on the main HTTP port, so this is somewhat redundant. 42 | * 43 | * @deprecated 44 | */ 45 | listenWs(port: number, host?: string): WebSocketServer { 46 | 47 | const wss = new WebSocketServer({ 48 | port, 49 | host 50 | }); 51 | wss.on('connection', async(ws, req) => { 52 | 53 | const request = new NodeRequest(req, this.origin); 54 | const response = new MemoryResponse(this.origin); 55 | const context = new Context(request, response); 56 | 57 | context.webSocket = ws; 58 | 59 | await this.handle(context); 60 | 61 | }); 62 | return wss; 63 | 64 | } 65 | 66 | /** 67 | * Returns a callback that can be used with Node's http.Server, http2.Server, https.Server. 68 | * 69 | * Normally you want to pass this to the constructor of each of these classes. 70 | */ 71 | callback(): HttpCallback { 72 | 73 | return nodeHttpServerCallback(this); 74 | 75 | } 76 | 77 | /** 78 | * This callback can be used to tie to the Node.js Http(s/2) server 'upgrade' event'. 79 | * 80 | * It's used to facilitate incoming Websocket requests 81 | */ 82 | upgradeCallback(request: http.IncomingMessage, socket: net.Socket, head: Buffer) { 83 | if (!this.wss) { 84 | // We don't have an existing Websocket server. Lets make one. 85 | this.wss = new WebSocketServer({ noServer: true }); 86 | this.wss.on('connection', async(ws, req) => { 87 | const request = new NodeRequest(req, this.origin); 88 | const response = new MemoryResponse(this.origin); 89 | const context = new Context(request, response); 90 | 91 | context.webSocket = ws; 92 | await this.handle(context); 93 | }); 94 | } 95 | this.wss.handleUpgrade(request, socket, head, (ws: WebSocket) => { 96 | this.wss!.emit('connection', ws, request); 97 | }); 98 | } 99 | 100 | /** 101 | * Creates a Context object based on a node.js request and response object. 102 | */ 103 | public buildContextFromHttp( 104 | req: NodeHttpRequest, 105 | res: NodeHttpResponse 106 | ): Context { 107 | const context = new Context( 108 | new NodeRequest(req, this.origin), 109 | new NodeResponse(res, this.origin) 110 | ); 111 | 112 | return context; 113 | } 114 | 115 | } 116 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Application from './application.js'; 2 | 3 | export default Application; 4 | export { Application }; 5 | 6 | export { 7 | conditionalCheck, 8 | Headers, 9 | invokeMiddlewares, 10 | middlewareCall, 11 | Middleware, 12 | Request, 13 | Response, 14 | MemoryRequest, 15 | MemoryResponse, 16 | WsContext, 17 | Context, 18 | // For backwards compatibility 19 | Context as BaseContext, 20 | 21 | VERSION, 22 | } from '@curveball/kernel'; 23 | -------------------------------------------------------------------------------- /src/node/http-utils.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'node:http'; 2 | import * as http2 from 'node:http2'; 3 | import { Readable, Writable } from 'node:stream'; 4 | import { NodeRequest as CurveballNodeRequest } from './request.js'; 5 | import { NodeResponse as CurveballNodeResponse } from './response.js'; 6 | import { isHttpError } from '@curveball/http-errors'; 7 | import { 8 | Application, 9 | Body, 10 | Context, 11 | } from '@curveball/kernel'; 12 | 13 | /** 14 | * A node.js Http request 15 | */ 16 | export type NodeHttpRequest = http.IncomingMessage | http2.Http2ServerRequest; 17 | 18 | /** 19 | * A node.js Http response 20 | */ 21 | export type NodeHttpResponse = http.ServerResponse | http2.Http2ServerResponse; 22 | 23 | /** 24 | * A type guard to see if a Response object is a HTTP2 response. 25 | */ 26 | export function isHttp2Response(response: NodeHttpResponse): response is http2.Http2ServerResponse { 27 | 28 | return (response as http2.Http2ServerResponse).stream !== undefined; 29 | 30 | } 31 | 32 | /** 33 | * Returns a callback that can be used with Node's http.Server, http2.Server, https.Server. 34 | * 35 | * Normally you want to pass this to the constructor of each of these classes. 36 | */ 37 | export function nodeHttpServerCallback(app: Application): HttpCallback { 38 | 39 | return async ( 40 | req: NodeHttpRequest, 41 | res: NodeHttpResponse 42 | ): Promise => { 43 | try { 44 | const ctx = createContextFromNode(req, res, app.origin); 45 | await app.handle(ctx); 46 | sendBody(res, ctx.response.body as any); 47 | } catch (err: any) { 48 | // eslint-disable-next-line no-console 49 | console.error(err); 50 | 51 | if (isHttpError(err)) { 52 | res.statusCode = err.httpStatus; 53 | } else { 54 | res.statusCode = 500; 55 | } 56 | res.setHeader('Content-Type', 'text/plain'); 57 | res.end( 58 | 'Uncaught exception. No middleware was defined to handle it. We got the following HTTP status: ' + 59 | res.statusCode 60 | ); 61 | 62 | if (app.listenerCount('error')) { 63 | app.emit('error', err); 64 | } 65 | } 66 | }; 67 | 68 | } 69 | 70 | /** 71 | * Emits a 'body' from a Curveball response to a Node HTTP stream/socket 72 | */ 73 | export function sendBody(res: NodeHttpResponse | http2.Http2Stream, body: Body): void { 74 | 75 | if (body === null) { 76 | res.end(); 77 | return; 78 | } else if (typeof body === 'string') { 79 | res.end(body); 80 | } else if (body instanceof Buffer) { 81 | res.end(body); 82 | } else if (body instanceof Readable) { 83 | body.pipe(res as Writable); 84 | } else if (typeof body === 'object') { 85 | res.end(JSON.stringify(body, null , 2)); 86 | } else if (typeof body === 'function') { 87 | body(res as Writable); 88 | } else { 89 | throw new TypeError('Unsupported type for body: ' + typeof body); 90 | } 91 | 92 | } 93 | 94 | 95 | /** 96 | * This function takes the request and response objects from a Node http, 97 | * https or http2 server and returns a curveball compatible Context. 98 | */ 99 | export function createContextFromNode(req: NodeHttpRequest, res: NodeHttpResponse, origin:string) { 100 | const context = new Context( 101 | new CurveballNodeRequest(req, origin), 102 | new CurveballNodeResponse(res, origin) 103 | ); 104 | return context; 105 | } 106 | 107 | 108 | 109 | /** 110 | * The HttpCallback is the function that is passed as a request listener to 111 | * node.js's HTTP implementations (http, https, http2). 112 | */ 113 | export type HttpCallback = (req: NodeHttpRequest, res: NodeHttpResponse) => void; 114 | -------------------------------------------------------------------------------- /src/node/push.ts: -------------------------------------------------------------------------------- 1 | import * as http2 from 'node:http2'; 2 | import { Context } from '@curveball/kernel'; 3 | import { sendBody } from './http-utils.js'; 4 | 5 | /** 6 | * This is a utility for helping with HTTP/2 Push for node servers. 7 | */ 8 | export default async function push(stream: http2.ServerHttp2Stream, pushCtx: Context) { 9 | 10 | const requestHeaders = { 11 | ':path': pushCtx.request.requestTarget, 12 | ...pushCtx.request.headers.getAll(), 13 | 14 | }; 15 | let pushStream: http2.ServerHttp2Stream; 16 | 17 | try { 18 | pushStream = await getPushStream( 19 | stream, 20 | requestHeaders, 21 | ); 22 | } catch (err: any) { 23 | if (err.code === 'ERR_HTTP2_PUSH_DISABLED') { 24 | // HTTP/2 disabled pusing after all 25 | return; 26 | } 27 | throw err; 28 | } 29 | pushStream.on('error', err => { 30 | 31 | const isRefusedStream = 32 | pushStream.rstCode === http2.constants.NGHTTP2_REFUSED_STREAM; 33 | 34 | if (!isRefusedStream) { 35 | throw err; 36 | } 37 | 38 | }); 39 | pushStream.respond({ 40 | ':status': 200, 41 | ...pushCtx.response.headers.getAll(), 42 | }); 43 | sendBody(pushStream, pushCtx.response.body); 44 | 45 | } 46 | 47 | function getPushStream(stream: http2.ServerHttp2Stream, requestHeaders: http2.OutgoingHttpHeaders): Promise { 48 | 49 | return new Promise((res, rej) => { 50 | 51 | stream.pushStream(requestHeaders, (err, pushStream) => { 52 | 53 | if (err) { 54 | rej(err); 55 | return; 56 | } 57 | res(pushStream); 58 | 59 | }); 60 | 61 | }); 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/node/request.ts: -------------------------------------------------------------------------------- 1 | import rawBody from 'raw-body'; 2 | import { Readable } from 'node:stream'; 3 | 4 | import { 5 | Headers, 6 | Request 7 | } from '@curveball/kernel'; 8 | import { NodeHttpRequest } from './http-utils.js'; 9 | 10 | export class NodeRequest extends Request { 11 | 12 | /** 13 | * Node.js Request object 14 | */ 15 | private inner: NodeHttpRequest; 16 | 17 | constructor(inner: NodeHttpRequest, origin: string) { 18 | 19 | super(inner.method!, inner.url!, origin); 20 | this.inner = inner; 21 | // @ts-expect-error ignoring that headers might be undefined 22 | this.headers = new Headers(this.inner.headers); 23 | 24 | } 25 | 26 | /** 27 | * This function returns the request body. 28 | * 29 | * If encoding is not specified, this function returns a Buffer. If encoding 30 | * is specified, it will return a string. 31 | * This function returns the request body. 32 | * 33 | * If encoding is not specified, this function returns a Buffer. If encoding 34 | * is specified, it will return a string. 35 | * 36 | * You can only call this function once. Most likely you'll want a single 37 | * middleware that calls this function and then sets `body`. 38 | */ 39 | rawBody(encoding?: string, limit?: string): Promise; 40 | rawBody(encoding?: undefined, limit?: string): Promise; 41 | rawBody(encoding?: undefined|string, limit?: string): Promise { 42 | 43 | const options: { 44 | encoding?: string; 45 | limit?: string; 46 | length?: string; 47 | } = {}; 48 | if (limit) { 49 | options.limit = limit; 50 | } 51 | if (encoding) { 52 | options.encoding = encoding; 53 | } 54 | const length = this.headers.get('Content-Length'); 55 | if (length) { 56 | options.length = length; 57 | } 58 | return rawBody(this.inner, options); 59 | 60 | } 61 | 62 | /** 63 | * getStream returns a Node.js readable stream. 64 | * 65 | * A stream can typically only be read once. 66 | */ 67 | getStream(): Readable { 68 | 69 | return this.inner; 70 | 71 | } 72 | 73 | /** 74 | * Returns the IP address of the HTTP client. 75 | * 76 | * If trustProxy is set to true, it means this server is running behind a 77 | * proxy, and the X-Forwarded-For header should be used instead. 78 | * 79 | * If trustProxy is not set, it defaults to false unless the 80 | * CURVEBALL_TRUSTPROXY environment variable is set. Using an environment 81 | * variable is a good idea for this as having a proxy may be environment 82 | * dependent. 83 | */ 84 | ip(trustProxy?: boolean): string { 85 | 86 | if (trustProxy===true || process.env.CURVEBALL_TRUSTPROXY) { 87 | const forwardedForHeader = this.headers.get('X-Forwarded-For'); 88 | if (forwardedForHeader) { 89 | return forwardedForHeader.split(',')[0].trim(); 90 | } 91 | } 92 | 93 | return this.inner.socket.remoteAddress!; 94 | 95 | } 96 | 97 | } 98 | 99 | export default NodeRequest; 100 | -------------------------------------------------------------------------------- /src/node/response-headers.ts: -------------------------------------------------------------------------------- 1 | import { HeadersInterface, HeadersObject } from '@curveball/kernel'; 2 | import { NodeHttpResponse } from './http-utils.js'; 3 | 4 | /** 5 | * This is a wrapper around the Node Response object, and handles creates a 6 | * nicer API around Headers access. 7 | */ 8 | export default class NodeHeaders implements HeadersInterface { 9 | 10 | private inner: NodeHttpResponse; 11 | 12 | constructor(inner: NodeHttpResponse) { 13 | 14 | this.inner = inner; 15 | 16 | } 17 | 18 | /** 19 | * Sets a HTTP header name and value 20 | */ 21 | set(name: string, value: string) { 22 | 23 | this.inner.setHeader(name, value); 24 | 25 | } 26 | 27 | /** 28 | * Gets a HTTP header's value. 29 | * 30 | * This function will return null if the header did not exist. If it did 31 | * exist, it will return a string. 32 | * 33 | * If there were multiple headers with the same value, it will join the 34 | * headers with a comma. 35 | */ 36 | get(name: string): string|null { 37 | 38 | const value = this.inner.getHeader(name); 39 | if (value === undefined || value === null) { 40 | return null; 41 | } else if (typeof(value) === 'string') { 42 | return value; 43 | } else if (Array.isArray(value)) { 44 | return value.join(', '); 45 | } else { 46 | return value.toString(); 47 | } 48 | 49 | } 50 | 51 | /** 52 | * Gets all values of a HTTP header 53 | * 54 | * This function will return an array with 0 or more values of a header. 55 | * 56 | */ 57 | getMany(name: string): string[] { 58 | 59 | const value = this.inner.getHeader(name); 60 | 61 | if (value === undefined || value === null) { 62 | return []; 63 | } else if (Array.isArray(value)) { 64 | return value; 65 | } else { 66 | return [value.toString()]; 67 | } 68 | } 69 | /** 70 | * Returns true or false depending on if a HTTP header exists. 71 | */ 72 | has(name: string): boolean { 73 | 74 | return !!this.inner.getHeader(name); 75 | 76 | } 77 | 78 | /** 79 | * Removes a HTTP header 80 | */ 81 | delete(name: string): void { 82 | 83 | this.inner.removeHeader(name); 84 | 85 | } 86 | 87 | /** 88 | * Returns all HTTP headers. 89 | * 90 | * Headernames are not lowercased. Values may be either strings or arrays of 91 | * strings. 92 | */ 93 | getAll(): HeadersObject { 94 | 95 | // @ts-expect-error typescript doesn't like that the getHeaders function can 96 | // have undefined values, so we're just ignoring that problem. 97 | return this.inner.getHeaders(); 98 | 99 | } 100 | /** 101 | * Appends a new header, without removing an old one with the same name. 102 | */ 103 | append(name: string, value: string | string[] | number): void { 104 | 105 | let oldValue = this.inner.getHeader(name); 106 | if (oldValue === undefined) { 107 | oldValue = []; 108 | } 109 | if (!Array.isArray(oldValue)) { 110 | oldValue = [oldValue.toString()]; 111 | } 112 | this.inner.setHeader(name, oldValue.concat(value as string[]|string)); 113 | 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /src/node/response.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'node:http'; 2 | import { promisify } from 'node:util'; 3 | import { isHttp2Response, NodeHttpResponse } from './http-utils.js'; 4 | import push from './push.js'; 5 | import NodeHeaders from './response-headers.js'; 6 | import { 7 | Context, 8 | Response, 9 | MemoryRequest, 10 | MemoryResponse, 11 | HeadersInterface, 12 | HeadersObject, 13 | invokeMiddlewares, 14 | Middleware, 15 | headerHelpers, 16 | } from '@curveball/kernel'; 17 | 18 | 19 | export class NodeResponse implements Response { 20 | 21 | private inner: NodeHttpResponse; 22 | private bodyValue!: T; 23 | private explicitStatus: boolean; 24 | 25 | constructor(inner: NodeHttpResponse, origin: string) { 26 | 27 | // The default response status is 404. 28 | this.inner = inner; 29 | 30 | // @ts-expect-error Typescript doesn't like null here because it might be 31 | // incompatible with T, but we're ignoring it as it's a good default. 32 | this.body = null; 33 | this.status = 404; 34 | this.explicitStatus = false; 35 | this.origin = origin; 36 | 37 | } 38 | 39 | /** 40 | * List of HTTP Headers 41 | */ 42 | get headers(): NodeHeaders { 43 | 44 | return new NodeHeaders(this.inner); 45 | 46 | } 47 | 48 | /** 49 | * HTTP status code. 50 | */ 51 | get status(): number { 52 | 53 | return this.inner.statusCode; 54 | 55 | } 56 | 57 | /** 58 | * Updates the HTTP status code for this response. 59 | */ 60 | set status(value: number) { 61 | 62 | this.explicitStatus = true; 63 | this.inner.statusCode = value; 64 | 65 | } 66 | 67 | /** 68 | * Updates the response body. 69 | */ 70 | set body(value: T) { 71 | 72 | if (!this.explicitStatus) { 73 | // If no status was set earlier, we set it to 200. 74 | this.inner.statusCode = 200; 75 | } 76 | this.bodyValue = value; 77 | } 78 | 79 | /** 80 | * Returns the response body. 81 | */ 82 | get body(): T { 83 | 84 | return this.bodyValue; 85 | 86 | } 87 | 88 | /** 89 | * Sends an informational response before the real response. 90 | * 91 | * This can be used to for example send a `100 Continue` or `103 Early Hints` 92 | * response. 93 | */ 94 | async sendInformational(status: number, headers?: HeadersInterface | HeadersObject): Promise { 95 | 96 | let outHeaders: HeadersObject = {}; 97 | 98 | if (typeof headers !== 'undefined') { 99 | if (headers.getAll !== undefined) { 100 | outHeaders = (headers as HeadersInterface).getAll(); 101 | } else { 102 | outHeaders = headers as HeadersObject; 103 | } 104 | } 105 | 106 | /** 107 | * It's a HTTP2 connection. 108 | */ 109 | if (isHttp2Response(this.inner)) { 110 | this.inner.stream.additionalHeaders({ 111 | ':status': status, 112 | ...outHeaders 113 | }); 114 | 115 | } else { 116 | 117 | const rawHeaders: string[] = []; 118 | for (const headerName of Object.keys(outHeaders)) { 119 | const headerValue = outHeaders[headerName]; 120 | if (Array.isArray(headerValue)) { 121 | for (const headerVal of headerValue) { 122 | rawHeaders.push(`${headerName}: ${headerVal}\r\n`); 123 | } 124 | } else { 125 | rawHeaders.push(`${headerName}: ${headerValue}\r\n`); 126 | } 127 | } 128 | 129 | // @ts-expect-error let's ignore this 130 | const writeRaw = promisify(this.inner._writeRaw.bind(this.inner)); 131 | const message = `HTTP/1.1 ${status} ${http.STATUS_CODES[status]}\r\n${rawHeaders.join('')}\r\n`; 132 | await writeRaw(message, 'ascii'); 133 | 134 | } 135 | 136 | } 137 | 138 | /** 139 | * Sends a HTTP/2 push. 140 | * 141 | * The passed middleware will be called with a new Context object specific 142 | * for pushes. 143 | */ 144 | async push(callback: Middleware): Promise { 145 | 146 | if (!isHttp2Response(this.inner)) { 147 | // Not HTTP2 148 | return; 149 | } 150 | 151 | const stream = this.inner.stream; 152 | if (!stream.pushAllowed) { 153 | // Client doesn't want pushes 154 | return; 155 | } 156 | 157 | const pushCtx = new Context( 158 | new MemoryRequest('GET', '|||DELIBERATELY_INVALID|||', this.origin), 159 | new MemoryResponse(this.origin) 160 | ); 161 | 162 | await invokeMiddlewares(pushCtx, [callback]); 163 | if (pushCtx.request.requestTarget === '|||DELIBERATELY_INVALID|||') { 164 | throw new Error('The "path" must be set in the push context\'s request'); 165 | } 166 | 167 | return push(stream, pushCtx); 168 | 169 | } 170 | 171 | /** 172 | * Returns the value of the Content-Type header, with any additional 173 | * parameters such as charset= removed. 174 | * 175 | * If there was no Content-Type header, an empty string will be returned. 176 | */ 177 | get type(): string { 178 | 179 | const type = this.headers.get('content-type'); 180 | if (!type) { return ''; } 181 | return type.split(';')[0]; 182 | 183 | } 184 | 185 | /** 186 | * Shortcut for setting the Content-Type header. 187 | */ 188 | set type(value: string) { 189 | 190 | this.headers.set('content-type', value); 191 | 192 | } 193 | 194 | /** 195 | * This method will return true or false if a Request or Response has a 196 | * Content-Type header that matches the argument. 197 | * 198 | * For example, if the Content-Type header has the value: application/hal+json, 199 | * then the arguments will all return true: 200 | * 201 | * * application/hal+json 202 | * * application/json 203 | * * hal+json 204 | * * json 205 | * * application/* 206 | */ 207 | is(type: string): boolean { 208 | 209 | return headerHelpers.is(this, type); 210 | 211 | } 212 | 213 | redirect(address: string): void; 214 | redirect(status: number, address: string): void; 215 | /** 216 | * redirect redirects the response with an optionally provided HTTP status 217 | * code in the first position to the location provided in address. If no status 218 | * is provided, 303 See Other is used. 219 | * 220 | * @param {(string|number)} addrOrStatus if passed a string, the string will 221 | * be used to set the Location header of the response object and the default status 222 | * of 303 See Other will be used. If a number, an addressed must be passed in the second 223 | * argument. 224 | * @param {string} address If addrOrStatus is passed a status code, this value is 225 | * set as the value of the response's Location header. 226 | */ 227 | redirect(addrOrStatus: string|number, address = ''): void { 228 | let status: number = 303; 229 | let addr: string; 230 | if (typeof(addrOrStatus) === 'number') { 231 | status = addrOrStatus; 232 | addr = address; 233 | } else { 234 | addr = addrOrStatus; 235 | } 236 | 237 | this.status = status; 238 | this.headers.set('Location', addr); 239 | } 240 | 241 | /** 242 | * Public base URL 243 | * 244 | * This will be used to determine the absoluteUrl 245 | */ 246 | readonly origin: string; 247 | 248 | } 249 | 250 | export default NodeResponse; 251 | -------------------------------------------------------------------------------- /test/application.ts: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'node:assert'; 2 | import { Application, middlewareCall, MemoryRequest, Context } from '../src/index.js'; 3 | import { Readable, Writable } from 'node:stream'; 4 | import { expect } from 'chai'; 5 | 6 | let lastPort = 5555; 7 | const getPort = () => { 8 | return lastPort++; 9 | }; 10 | 11 | describe('Application', () => { 12 | it('should instantiate', () => { 13 | const application = new Application(); 14 | assert.ok(application instanceof Application); 15 | }); 16 | 17 | it('should respond to HTTP requests', async () => { 18 | const application = new Application(); 19 | application.use(ctx => { 20 | ctx.response.body = 'string'; 21 | }); 22 | 23 | const port = getPort(); 24 | const server = application.listen(port); 25 | 26 | const response = await fetch('http://localhost:' + port); 27 | const body = await response.text(); 28 | 29 | assert.equal(body, 'string'); 30 | assert.match( 31 | response.headers.get('server')!, 32 | /Curveball\// 33 | ); 34 | assert.equal(response.status, 200); 35 | 36 | server.close(); 37 | }); 38 | 39 | it('should work with Buffer responses', async () => { 40 | const application = new Application(); 41 | application.use(ctx => { 42 | ctx.response.body = Buffer.from('buffer'); 43 | }); 44 | 45 | const port = getPort(); 46 | const server = application.listen(port); 47 | 48 | 49 | const response = await fetch('http://localhost:'+port); 50 | const body = await response.text(); 51 | 52 | assert.equal(body, 'buffer'); 53 | assert.match( 54 | response.headers.get('server')!, 55 | /Curveball\// 56 | ); 57 | assert.equal(response.status, 200); 58 | 59 | server.close(); 60 | }); 61 | 62 | it('should work with Readable stream responses', async () => { 63 | const application = new Application(); 64 | application.use(ctx => { 65 | ctx.response.body = Readable.from(Buffer.from('stream')); 66 | }); 67 | 68 | const port = getPort(); 69 | const server = application.listen(port); 70 | 71 | const response = await fetch('http://localhost:'+port); 72 | const body = await response.text(); 73 | 74 | assert.equal(body, 'stream'); 75 | assert.match( 76 | response.headers.get('server')!, 77 | /Curveball\// 78 | ); 79 | assert.equal(response.status, 200); 80 | server.close(); 81 | }); 82 | 83 | it('should work with a callback resonse body', async () => { 84 | const application = new Application(); 85 | application.use(ctx => { 86 | ctx.response.body = (stream: Writable) => { 87 | stream.write('callback'); 88 | stream.end(); 89 | }; 90 | }); 91 | const port = getPort(); 92 | const server = application.listen(port); 93 | 94 | const response = await fetch('http://localhost:'+port); 95 | const body = await response.text(); 96 | 97 | assert.equal(body, 'callback'); 98 | assert.match( 99 | response.headers.get('server')!, 100 | /Curveball\// 101 | ); 102 | assert.equal(response.status, 200); 103 | 104 | server.close(); 105 | }); 106 | 107 | it('should automatically JSON-encode objects', async () => { 108 | const application = new Application(); 109 | application.use(ctx => { 110 | ctx.response.body = { foo: 'bar' }; 111 | }); 112 | const port = getPort(); 113 | const server = application.listen(port); 114 | 115 | const response = await fetch('http://localhost:'+port); 116 | const body = await response.text(); 117 | 118 | assert.equal(body, '{\n "foo": "bar"\n}'); 119 | assert.match( 120 | response.headers.get('server')!, 121 | /Curveball\// 122 | ); 123 | assert.equal(response.status, 200); 124 | 125 | server.close(); 126 | }); 127 | 128 | it('should handle "null" bodies', async () => { 129 | const application = new Application(); 130 | application.use(ctx => { 131 | ctx.response.body = null; 132 | }); 133 | const port = getPort(); 134 | const server = application.listen(port); 135 | 136 | const response = await fetch('http://localhost:'+port); 137 | const body = await response.text(); 138 | 139 | assert.equal(body, ''); 140 | assert.match( 141 | response.headers.get('server')!, 142 | /Curveball\// 143 | ); 144 | assert.equal(response.status, 200); 145 | server.close(); 146 | }); 147 | 148 | it('should throw an exception for unsupported bodies', async () => { 149 | const application = new Application(); 150 | application.use(ctx => { 151 | ctx.response.body = 5; 152 | }); 153 | const port = getPort(); 154 | const server = application.listen(port); 155 | 156 | const response = await fetch('http://localhost:'+port); 157 | const body = await response.text(); 158 | 159 | assert.ok(body.includes(': 500')); 160 | assert.match( 161 | response.headers.get('server')!, 162 | /Curveball\// 163 | ); 164 | assert.equal(response.status, 500); 165 | 166 | server.close(); 167 | }); 168 | 169 | it('should work with multiple calls to middlewares', async () => { 170 | const application = new Application(); 171 | application.use(async (ctx, next) => { 172 | ctx.response.body = 'hi'; 173 | await next(); 174 | }); 175 | application.use(ctx => { 176 | ctx.response.headers.set('X-Foo', 'bar'); 177 | }); 178 | const port = getPort(); 179 | const server = application.listen(port); 180 | 181 | const response = await fetch('http://localhost:'+port); 182 | const body = await response.text(); 183 | 184 | assert.equal(body, 'hi'); 185 | assert.equal(response.headers.get('X-Foo'), 'bar'); 186 | assert.equal(response.status, 200); 187 | 188 | server.close(); 189 | }); 190 | it('should work with multiple middlewares as arguments', async () => { 191 | const application = new Application(); 192 | application.use(async (ctx, next) => { 193 | ctx.response.body = 'hi'; 194 | await next(); 195 | }), 196 | application.use((ctx, next) => { 197 | ctx.response.headers.set('X-Foo', 'bar'); 198 | }); 199 | const port = getPort(); 200 | const server = application.listen(port); 201 | 202 | const response = await fetch('http://localhost:'+port); 203 | const body = await response.text(); 204 | 205 | assert.equal(body, 'hi'); 206 | assert.equal(response.headers.get('X-Foo'), 'bar'); 207 | assert.equal(response.status, 200); 208 | 209 | server.close(); 210 | }); 211 | 212 | it('should work with object-middlewares', async () => { 213 | const application = new Application(); 214 | 215 | const myMw = { 216 | // eslint-disable-next-line @typescript-eslint/ban-types 217 | [middlewareCall]: async (ctx: Context, next: Function) => { 218 | ctx.response.body = 'hi'; 219 | } 220 | }; 221 | 222 | application.use(myMw); 223 | 224 | const port = getPort(); 225 | const server = application.listen(port); 226 | 227 | const response = await fetch('http://localhost:'+port); 228 | const body = await response.text(); 229 | 230 | assert.equal(body, 'hi'); 231 | assert.equal(response.status, 200); 232 | 233 | server.close(); 234 | }); 235 | 236 | it('should not call sequential middlewares if next is not called', async () => { 237 | const application = new Application(); 238 | application.use(ctx => { 239 | ctx.response.body = 'hi'; 240 | }); 241 | application.use(ctx => { 242 | ctx.response.headers.set('X-Foo', 'bar'); 243 | }); 244 | const port = getPort(); 245 | const server = application.listen(port); 246 | 247 | const response = await fetch('http://localhost:'+port); 248 | const body = await response.text(); 249 | 250 | assert.equal(body, 'hi'); 251 | assert.equal(response.headers.get('X-Foo'), null); 252 | assert.equal(response.status, 200); 253 | 254 | server.close(); 255 | }); 256 | 257 | describe('When an uncaught exception happens', () => { 258 | it('should trigger an "error" event', async () => { 259 | const application = new Application(); 260 | application.use((ctx, next) => { 261 | throw new Error('hi'); 262 | }); 263 | let error: any; 264 | application.on('error', err => { 265 | error = err; 266 | }); 267 | const port = getPort(); 268 | const server = application.listen(port); 269 | 270 | await fetch('http://localhost:'+port); 271 | 272 | assert.ok(error instanceof Error); 273 | assert.equal(error!.message, 'hi'); 274 | 275 | server.close(); 276 | }); 277 | 278 | it('should return an error message in the response body.', async () => { 279 | const application = new Application(); 280 | application.use((ctx, next) => { 281 | throw new Error('hi'); 282 | }); 283 | const port = getPort(); 284 | const server = application.listen(port); 285 | 286 | const response = await fetch('http://localhost:'+port); 287 | const body = await response.text(); 288 | 289 | assert.ok(body.includes(': 500')); 290 | 291 | server.close(); 292 | }); 293 | }); 294 | 295 | describe('When no middlewares are defined', () => { 296 | it('should do nothing', async () => { 297 | const application = new Application(); 298 | const port = getPort(); 299 | const server = application.listen(port); 300 | 301 | await fetch('http://localhost:'+port); 302 | 303 | server.close(); 304 | }); 305 | }); 306 | 307 | describe('Subrequests', () => { 308 | it('should work with a Request object', async () => { 309 | let innerRequest; 310 | 311 | const application = new Application(); 312 | application.use(ctx => { 313 | innerRequest = ctx.request; 314 | ctx.response.status = 201; 315 | ctx.response.headers.set('X-Foo', 'bar'); 316 | ctx.response.body = 'hello world'; 317 | }); 318 | 319 | const request = new MemoryRequest( 320 | 'POST', 321 | '/', 322 | application.origin, 323 | { foo: 'bar' }, 324 | 'request-body' 325 | ); 326 | const response = await application.subRequest(request); 327 | 328 | expect(response.status).to.equal(201); 329 | expect(response.headers.get('X-Foo')).to.equal('bar'); 330 | expect(response.body).to.equal('hello world'); 331 | expect(innerRequest).to.equal(request); 332 | }); 333 | 334 | it('should work without a Request object', async () => { 335 | const application = new Application(); 336 | application.use(ctx => { 337 | ctx.response.status = 201; 338 | ctx.response.headers.set('X-Foo', 'bar'); 339 | ctx.response.body = 'hello world'; 340 | }); 341 | 342 | const response = await application.subRequest( 343 | 'POST', 344 | '/', 345 | { foo: 'bar' }, 346 | 'request-body' 347 | ); 348 | 349 | expect(response.status).to.equal(201); 350 | expect(response.headers.get('X-Foo')).to.equal('bar'); 351 | expect(response.body).to.equal('hello world'); 352 | }); 353 | }); 354 | 355 | describe('When middlewares did not set an explicit status', () => { 356 | it('should return 200 when a body was set', async () => { 357 | const app = new Application(); 358 | app.use(ctx => { 359 | ctx.response.body = 'hi'; 360 | }); 361 | const port = getPort(); 362 | const server = app.listen(port); 363 | 364 | const response = await fetch('http://localhost:'+port); 365 | expect(response.status).to.equal(200); 366 | 367 | server.close(); 368 | }); 369 | it('should return 404 when no body was set', async () => { 370 | const app = new Application(); 371 | const port = getPort(); 372 | const server = app.listen(port); 373 | 374 | const response = await fetch('http://localhost:'+port); 375 | expect(response.status).to.equal(404); 376 | 377 | server.close(); 378 | }); 379 | }); 380 | 381 | describe('Origin', async() => { 382 | 383 | it('should default to http://localhost', () => { 384 | 385 | const app = new Application(); 386 | expect(app.origin).to.equal('http://localhost'); 387 | 388 | }); 389 | 390 | it('should use the PORT variable too if set', () => { 391 | 392 | process.env.PORT = '81'; 393 | const app = new Application(); 394 | expect(app.origin).to.equal('http://localhost:81'); 395 | delete process.env.PORT; 396 | 397 | }); 398 | 399 | it('should use CURVEBALL_ORIGIN if set', () => { 400 | 401 | process.env.CURVEBALL_ORIGIN = 'https://curveballjs.org'; 402 | const app = new Application(); 403 | expect(app.origin).to.equal('https://curveballjs.org'); 404 | delete process.env.CURVEBALL_ORIGIN; 405 | 406 | }); 407 | 408 | it('should use PUBLIC_URI if set', () => { 409 | 410 | process.env.PUBLIC_URI = 'https://curveballjs.org'; 411 | const app = new Application(); 412 | expect(app.origin).to.equal('https://curveballjs.org'); 413 | delete process.env.PUBLIC_URI; 414 | 415 | }); 416 | 417 | it('should ignore PUBLIC_URI if origin was manually set', () => { 418 | 419 | process.env.PUBLIC_URI = 'https://curveballjs.org'; 420 | const app = new Application(); 421 | app.origin = 'http://foo-bar.com'; 422 | expect(app.origin).to.equal('http://foo-bar.com'); 423 | delete process.env.PUBLIC_URI; 424 | 425 | }); 426 | 427 | }); 428 | 429 | }); 430 | -------------------------------------------------------------------------------- /test/conditional.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { conditionalCheck, MemoryRequest } from '../src/index.js'; 3 | 4 | describe('conditionals', () => { 5 | 6 | describe('If-Match on a resource that has ETag "a"', () => { 7 | 8 | const tests: any = [ 9 | [200, 'GET', '"a"'], 10 | [200, 'PUT', '"a"'], 11 | [412, 'GET', '"b"'], 12 | [412, 'PUT', '"b"'], 13 | [200, 'GET', '*'], 14 | [200, 'GET', '"b", "a"'], 15 | [412, 'GET', '"b", "c"'], 16 | ]; 17 | 18 | for (const [status, method, header] of tests) { 19 | it(`should return ${status} when doing ${method} with If-Match: ${header}`, () => { 20 | 21 | const request = new MemoryRequest(method, '/foo', 'http://localhost', { 'If-Match': header }); 22 | expect(conditionalCheck(request, null, '"a"')).to.eql(status); 23 | 24 | }); 25 | } 26 | 27 | }); 28 | 29 | describe('If-Match on a resource that has no ETag', () => { 30 | 31 | const tests: any = [ 32 | [412, 'GET', '"a"'], 33 | [412, 'PUT', '"a"'], 34 | [412, 'GET', '*'], 35 | [412, 'GET', '"b", "c"'], 36 | ]; 37 | 38 | for (const [status, method, header] of tests) { 39 | it(`should return ${status} when doing ${method} with If-Match: ${header}`, () => { 40 | 41 | const request = new MemoryRequest(method, '/foo', 'http://localhost', { 'If-Match': header }); 42 | expect(conditionalCheck(request, null, null)).to.eql(status); 43 | 44 | }); 45 | } 46 | 47 | }); 48 | describe('If-None-Match on a resource that has ETag "a"', () => { 49 | 50 | const tests: any = [ 51 | [304, 'GET', '"a"'], 52 | [412, 'PUT', '"a"'], 53 | [200, 'GET', '"b"'], 54 | [200, 'PUT', '"b"'], 55 | [304, 'GET', '*'], 56 | [412, 'PUT', '*'], 57 | [304, 'GET', '"b", "a"'], 58 | [200, 'GET', '"b", "c"'], 59 | [412, 'PUT', '"b", "a"'], 60 | [200, 'PUT', '"b", "c"'], 61 | ]; 62 | 63 | for (const [status, method, header] of tests) { 64 | it(`should return ${status} when doing ${method} with If-None-Match: ${header}`, () => { 65 | 66 | const request = new MemoryRequest(method, '/foo', 'http://localhost', { 'If-None-Match': header }); 67 | expect(conditionalCheck(request, null, '"a"')).to.eql(status); 68 | 69 | }); 70 | } 71 | 72 | }); 73 | 74 | describe('If-None-Match on a resource that has no ETag', () => { 75 | 76 | const tests: any = [ 77 | [200, 'GET', '"a"'], 78 | [200, 'PUT', '"a"'], 79 | [200, 'PUT', '*'], 80 | [200, 'GET', '"b", "c"'], 81 | ]; 82 | 83 | for (const [status, method, header] of tests) { 84 | it(`should return ${status} when doing ${method} with If-None-Match: ${header}`, () => { 85 | 86 | const request = new MemoryRequest(method, '/foo', 'http://localhost', { 'If-None-Match': header }); 87 | expect(conditionalCheck(request, null, null)).to.eql(status); 88 | 89 | }); 90 | } 91 | 92 | }); 93 | 94 | describe('If-Modified-Since on a resource that changed Mar 6th, 2020', () => { 95 | 96 | const tests: any = [ 97 | [200, 'GET', 'Thu, 5 Mar 2020 00:00:00 GMT'], 98 | [200, 'PUT', 'Thu, 5 Mar 2020 00:00:00 GMT'], 99 | [304, 'GET', 'Sat, 7 Mar 2020 00:00:00 GMT'], 100 | [200, 'PUT', 'Sat, 7 Mar 2020 00:00:00 GMT'], 101 | ]; 102 | 103 | for (const [status, method, headerDate] of tests) { 104 | it(`should return ${status} when doing ${method} with If-Modified-Since: ${headerDate}`, () => { 105 | 106 | const request = new MemoryRequest(method, '/foo', 'http://localhost', { 'If-Modified-Since': headerDate }); 107 | expect(conditionalCheck(request, new Date('2020-03-06 00:00:00'), null)).to.eql(status); 108 | 109 | }); 110 | } 111 | 112 | }); 113 | 114 | describe('If-Modified-Since on a resource with no modification date', () => { 115 | 116 | it('should return 200', () => { 117 | 118 | const request = new MemoryRequest('GET', '/foo', 'http://localhost', { 'If-Modified-Since': 'Thu, 7 Mar 2019 14:49:00 GMT' }); 119 | expect(conditionalCheck(request, null, null)).to.eql(200); 120 | 121 | }); 122 | 123 | }); 124 | 125 | describe('If-Unmodified-Since on a resource that changed Mar 6th, 2020', () => { 126 | 127 | const tests: any = [ 128 | [412, 'GET', 'Thu, 5 Mar 2020 14:49:00 GMT'], 129 | [412, 'PUT', 'Thu, 5 Mar 2020 14:49:00 GMT'], 130 | [200, 'GET', 'Sat, 7 Mar 2020 14:49:00 GMT'], 131 | [200, 'PUT', 'Sat, 7 Mar 2020 14:49:00 GMT'], 132 | ]; 133 | 134 | for (const [status, method, headerDate] of tests) { 135 | it(`should return ${status} when doing ${method} with If-Unmodified-Since: ${headerDate}`, () => { 136 | 137 | const request = new MemoryRequest(method, '/foo', 'http://localhost', { 'If-Unmodified-Since': headerDate }); 138 | expect(conditionalCheck(request, new Date('2020-03-06 00:00:00'), null)).to.eql(status); 139 | 140 | }); 141 | } 142 | 143 | }); 144 | 145 | describe('If-Unmodified-Since on a resource with no modification date', () => { 146 | 147 | it('should return 412', () => { 148 | 149 | const request = new MemoryRequest('GET', '/foo', 'http://localhost', { 'If-Unmodified-Since': 'Thu, 7 Mar 2019 14:49:00 GMT' }); 150 | expect(conditionalCheck(request, null, null)).to.eql(412); 151 | 152 | }); 153 | 154 | }); 155 | 156 | }); 157 | -------------------------------------------------------------------------------- /test/context.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { MemoryRequest, MemoryResponse, Context } from '../src/index.js'; 3 | 4 | describe('Context', () => { 5 | 6 | it('should instantiate correctly', () => { 7 | 8 | const request = new MemoryRequest('GET', '/', 'http://localhost'); 9 | const response = new MemoryResponse('http://localhost'); 10 | 11 | const context = new Context( 12 | request, 13 | response 14 | ); 15 | 16 | expect(context.request).to.equal(request); 17 | expect(context.response).to.equal(response); 18 | 19 | }); 20 | 21 | it('should forward the "method" property to the request', () => { 22 | 23 | const request = new MemoryRequest('GET', '/', 'http://localhost'); 24 | const response = new MemoryResponse('http://localhost'); 25 | 26 | const context = new Context( 27 | request, 28 | response 29 | ); 30 | 31 | expect(context.method).to.equal('GET'); 32 | 33 | }); 34 | 35 | it('should forward the "path" property to the request', () => { 36 | 37 | const request = new MemoryRequest('GET', '/foo', 'http://localhost'); 38 | const response = new MemoryResponse('http://localhost'); 39 | 40 | const context = new Context( 41 | request, 42 | response 43 | ); 44 | 45 | expect(context.path).to.equal('/foo'); 46 | 47 | }); 48 | it('should forward the "query" property to the request', () => { 49 | 50 | const request = new MemoryRequest('GET', '/foo?a=b', 'http://localhost'); 51 | const response = new MemoryResponse('http://localhost'); 52 | 53 | const context = new Context( 54 | request, 55 | response 56 | ); 57 | 58 | expect(context.query).to.eql({a: 'b'}); 59 | 60 | }); 61 | 62 | it('should forward the "accepts" method to the request', () => { 63 | 64 | const request = new MemoryRequest('GET', '/foo', 'http://localhost', {Accept: 'text/html'}); 65 | const response = new MemoryResponse('http://localhost'); 66 | 67 | const context = new Context( 68 | request, 69 | response 70 | ); 71 | 72 | expect(context.accepts('text/html')).to.equal('text/html'); 73 | 74 | }); 75 | 76 | it('should forward the "status" property to the response', () => { 77 | 78 | const request = new MemoryRequest('GET', '/foo', 'http://localhost'); 79 | const response = new MemoryResponse('http://localhost'); 80 | response.status = 414; 81 | 82 | const context = new Context( 83 | request, 84 | response 85 | ); 86 | 87 | expect(context.status).to.equal(414); 88 | 89 | context.status = 303; 90 | 91 | expect(response.status).to.equal(303); 92 | 93 | }); 94 | 95 | it('should forward the "push" method to the response', () => { 96 | 97 | let called = false; 98 | const request = new MemoryRequest('GET', '/foo', 'http://localhost'); 99 | const response = new MemoryResponse('http://localhost'); 100 | response.push = () => { 101 | 102 | called = true; 103 | return Promise.resolve(undefined); 104 | 105 | }; 106 | 107 | const context = new Context( 108 | request, 109 | response 110 | ); 111 | 112 | context.push(() => { 113 | /* Intentionally Empty */ 114 | }); 115 | 116 | expect(called).to.equal(true); 117 | 118 | }); 119 | 120 | 121 | it('should forward the "sendInformational" method to the response', () => { 122 | 123 | let called = false; 124 | const request = new MemoryRequest('GET', '/foo', 'http:/localhost'); 125 | const response = new MemoryResponse('http://localhost'); 126 | response.sendInformational = () => { 127 | 128 | called = true; 129 | return Promise.resolve(); 130 | 131 | }; 132 | 133 | const context = new Context( 134 | request, 135 | response 136 | ); 137 | 138 | context.sendInformational(103); 139 | 140 | expect(called).to.equal(true); 141 | 142 | }); 143 | 144 | describe('ip()', () => { 145 | 146 | it('should return null if the underlying request isn\'t socket-based', () => { 147 | 148 | const request = new MemoryRequest('GET', '/foo', 'http://localhost'); 149 | const response = new MemoryResponse('http://localhost'); 150 | 151 | const context = new Context( 152 | request, 153 | response 154 | ); 155 | expect(context.ip()).to.equal(null); 156 | 157 | }); 158 | it('should call the ip() method on the request if it\'s socket-based', () => { 159 | 160 | const request = new MemoryRequest('GET', '/foo', 'http://localhost'); 161 | (request as any).ip = () => '127.0.0.1'; 162 | const response = new MemoryResponse('http://localhost'); 163 | 164 | const context = new Context( 165 | request, 166 | response 167 | ); 168 | expect(context.ip()).to.equal('127.0.0.1'); 169 | 170 | }); 171 | 172 | }); 173 | 174 | describe('redirect', () => { 175 | it('should set the location header to /home with default status code 303', async () => { 176 | const originalTarget = '/foo'; 177 | const newTarget = '/bar'; 178 | const defaultStatus = 303; 179 | 180 | const request = new MemoryRequest('GET', originalTarget, 'http://localhost'); 181 | const response = new MemoryResponse('http://localhost'); 182 | 183 | const context = new Context( 184 | request, 185 | response 186 | ); 187 | 188 | context.redirect(newTarget); 189 | 190 | expect(context.response.headers.get('Location')).equals(newTarget); 191 | expect(context.status).equals(defaultStatus); 192 | }); 193 | 194 | it('should redirect to /home with provided status code 301', async () => { 195 | const originalTarget = '/foo'; 196 | const originalStatus = 303; 197 | const newTarget = '/bar'; 198 | const newStatus = 301; 199 | 200 | const request = new MemoryRequest('GET', originalTarget, 'http://localhost'); 201 | const response = new MemoryResponse('http://localhost'); 202 | 203 | const context = new Context( 204 | request, 205 | response 206 | ); 207 | 208 | context.redirect(newStatus, newTarget); 209 | 210 | expect(context.status).equals(newStatus); 211 | expect(context.status).not.equals(originalStatus); 212 | expect(context.response.headers.get('Location')).not.equals(originalTarget); 213 | expect(context.response.headers.get('Location')).equals(newTarget); 214 | }); 215 | }); 216 | 217 | }); 218 | -------------------------------------------------------------------------------- /test/headers-interface-tests.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { HeadersInterface } from '@curveball/kernel'; 3 | 4 | /** 5 | * This function builds new tests based on an implementation of 6 | * HeaderInterface. 7 | * 8 | * It ensures that the interface was correctly implemented. 9 | */ 10 | export default function headersTest(headers: HeadersInterface) { 11 | 12 | describe('Interface tests', () => { 13 | 14 | describe('get', () => { 15 | 16 | it('should return null when a header was not set', () => { 17 | 18 | expect(headers.get('foo')).to.equal(null); 19 | 20 | }); 21 | 22 | it('should return a string for string headers', () => { 23 | 24 | headers.set('Content-Type', 'text/html'); 25 | expect(headers.get('Content-Type')).to.equal('text/html'); 26 | 27 | }); 28 | 29 | it('should also return values when asking for a header with a different case', () => { 30 | 31 | expect(headers.get('cONTENT-tYPE')).to.equal('text/html'); 32 | 33 | }); 34 | 35 | it('should return a string for number headers', () => { 36 | 37 | headers.set('Content-Length', 5); 38 | expect(headers.get('Content-Length')).to.equal('5'); 39 | 40 | }); 41 | 42 | it('should concatenate multiple headers with the same name', () => { 43 | 44 | headers.set('Accept', ['text/html', 'text/plain']), 45 | expect(headers.get('Accept')).to.equal('text/html, text/plain'); 46 | 47 | }); 48 | 49 | }); 50 | 51 | describe('has', () => { 52 | 53 | it('should return false when a header was not set', () => { 54 | 55 | expect(headers.has('foo')).to.equal(false); 56 | 57 | }); 58 | 59 | it('should return true if a header was set', () => { 60 | 61 | headers.set('Content-Type', 'text/html'); 62 | expect(headers.has('Content-Type')).to.equal(true); 63 | 64 | }); 65 | 66 | it('should also return true when asking for a header with a different case', () => { 67 | 68 | expect(headers.has('cONTENT-tYPE')).to.equal(true); 69 | 70 | }); 71 | 72 | it('should work with multiple headers with the same name', () => { 73 | 74 | headers.set('Accept', ['text/html', 'text/plain']), 75 | expect(headers.has('Accept')).to.equal(true); 76 | 77 | }); 78 | 79 | }); 80 | describe('delete', () => { 81 | 82 | it('should delete headers', () => { 83 | 84 | headers.set('X-Foo', 'bar'); 85 | headers.delete('X-Foo'); 86 | 87 | expect(headers.get('X-Foo')).to.equal(null); 88 | 89 | }); 90 | 91 | it('should delete headers if casing is different', () => { 92 | 93 | headers.set('X-Foo', 'bar'); 94 | headers.delete('x-foo'); 95 | 96 | expect(headers.get('X-Foo')).to.equal(null); 97 | 98 | }); 99 | 100 | it('shouldn\'t error when an unknown header is deleted.', () => { 101 | 102 | headers.delete('x-foo2'); 103 | expect(headers.get('X-Foo2')).to.equal(null); 104 | 105 | }); 106 | 107 | }); 108 | 109 | describe('getAll', () => { 110 | 111 | it('should return the entire set of headers', () => { 112 | 113 | const expected = { 114 | 'accept': ['text/html', 'text/plain'], 115 | 'content-length': 5, 116 | 'content-type' : 'text/html', 117 | }; 118 | 119 | expect(headers.getAll()).to.deep.equal(expected); 120 | 121 | }); 122 | 123 | }); 124 | 125 | describe('append', () => { 126 | 127 | it('should allow creating an initial header', () => { 128 | 129 | headers.append('X-Append', 'a'); 130 | expect(headers.get('X-Append')).to.equal('a'); 131 | 132 | }); 133 | 134 | it('should allow adding another header with the same value', () => { 135 | 136 | headers.append('X-Append', 'b'); 137 | expect(headers.get('X-Append')).to.equal('a, b'); 138 | 139 | }); 140 | 141 | it('should allow adding several headers in one go', () => { 142 | 143 | headers.append('X-Append', ['c', 'd']); 144 | expect(headers.get('X-Append')).to.equal('a, b, c, d'); 145 | 146 | }); 147 | 148 | it('should allow using append on a headers thats singular', () => { 149 | // This is a weird test because it creates a broken value, but it 150 | // tests a specific branch. Maybe I can come up with a better test 151 | // for this later. 152 | headers.append('Content-Length', 6); 153 | expect(headers.get('Content-Length')).to.equal('5, 6'); 154 | 155 | }); 156 | 157 | }); 158 | 159 | }); 160 | 161 | } 162 | -------------------------------------------------------------------------------- /test/headers.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Headers } from '../src/index.js'; 3 | import headersInterfaceTests from './headers-interface-tests.js'; 4 | 5 | describe('Headers class', () => { 6 | 7 | describe('When instantiating with an object', () => { 8 | 9 | it('should make its values available', () => { 10 | 11 | const headers = new Headers({ 12 | 'Content-Type': 'text/html', 13 | }); 14 | 15 | expect(headers.get('content-type')).to.equal('text/html'); 16 | 17 | }); 18 | 19 | }); 20 | 21 | describe('get', () => { 22 | 23 | it('should return null when a header was not set', () => { 24 | 25 | const headers = new Headers({ 26 | 'Content-Type': 'text/html', 27 | }); 28 | expect(headers.get('foo')).to.equal(null); 29 | 30 | }); 31 | 32 | it('should return a string for string headers', () => { 33 | 34 | const headers = new Headers({ 35 | 'Content-Type': 'text/html', 36 | }); 37 | expect(headers.get('Content-Type')).to.equal('text/html'); 38 | 39 | }); 40 | 41 | it('should return a string for number headers', () => { 42 | 43 | const headers = new Headers({ 44 | 'Content-Length': 5, 45 | }); 46 | expect(headers.get('Content-Length')).to.equal('5'); 47 | 48 | }); 49 | 50 | it('should concatenate multiple headers with the same name', () => { 51 | 52 | const headers = new Headers({ 53 | Accept: ['text/html', 'text/plain'], 54 | }); 55 | expect(headers.get('Accept')).to.equal('text/html, text/plain'); 56 | 57 | }); 58 | 59 | }); 60 | 61 | describe('getMany', () => { 62 | 63 | it('should return an empty array when a header was not set', () => { 64 | 65 | const headers = new Headers({ 66 | 'Content-Type': 'text/html', 67 | }); 68 | expect(headers.getMany('foo')).to.eql([]); 69 | 70 | }); 71 | 72 | it('should return an array with a string for string headers', () => { 73 | 74 | const headers = new Headers({ 75 | 'Content-Type': 'text/html', 76 | }); 77 | expect(headers.getMany('Content-Type')).to.eql(['text/html']); 78 | 79 | }); 80 | 81 | it('should return an array with a string for number headers', () => { 82 | 83 | const headers = new Headers({ 84 | 'Content-Length': 5, 85 | }); 86 | expect(headers.getMany('Content-Length')).to.eql(['5']); 87 | 88 | }); 89 | 90 | it('should return an array with multiple headers of the same name', () => { 91 | 92 | const headers = new Headers({ 93 | Accept: ['text/html', 'text/plain'], 94 | }); 95 | expect(headers.getMany('Accept')).to.eql(['text/html', 'text/plain']); 96 | 97 | }); 98 | 99 | }); 100 | 101 | headersInterfaceTests(new Headers()); 102 | 103 | }); 104 | -------------------------------------------------------------------------------- /test/memory-request.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { MemoryRequest, Headers } from '../src/index.js'; 3 | 4 | function getReq() { 5 | 6 | return new MemoryRequest( 7 | 'POST', 8 | '/foo?a=1&b=2', 9 | 'http://localhost', 10 | { 11 | 'X-FOO': 'BAR', 12 | 'Accept': 'text/html', 13 | 'Content-Type': 'text/html', 14 | }, 15 | 'hello world', 16 | ); 17 | 18 | } 19 | 20 | describe('MemoryRequest', () => { 21 | 22 | describe('constructing', async () => { 23 | 24 | it('should get its method set correctly', () => { 25 | 26 | expect(getReq().method).to.equal('POST'); 27 | 28 | }); 29 | 30 | it('should have its path set correctly', () => { 31 | 32 | expect(getReq().path).to.equal('/foo'); 33 | 34 | }); 35 | 36 | it('should have its headers set correctly', () => { 37 | 38 | expect(getReq().headers.get('x-foo')).to.eql('BAR'); 39 | 40 | }); 41 | 42 | it('should have its body set correctly', async () => { 43 | 44 | expect(await getReq().rawBody('utf-8')).to.equal('hello world'); 45 | 46 | }); 47 | 48 | it('should work with HeadersInterface', async () => { 49 | 50 | const headers = new Headers(); 51 | const request = new MemoryRequest('GET', '/', 'http://localhost', headers); 52 | 53 | expect(request.headers).to.equal(headers); 54 | 55 | }); 56 | 57 | }); 58 | 59 | describe('accepts function', () => { 60 | 61 | it('should work', async () => { 62 | 63 | const req = await getReq(); 64 | const result = req.accepts('application/json', 'text/html'); 65 | expect(result).to.equal('text/html'); 66 | 67 | }); 68 | 69 | it('should return false if there was no acceptable match.', async () => { 70 | 71 | const req = await getReq(); 72 | const result = req.accepts('application/json'); 73 | expect(result).to.equal(null); 74 | 75 | }); 76 | 77 | it('should return the first accepts header if no Accept header was provided.', async () => { 78 | 79 | const req = await getReq(); 80 | req.headers.delete('accept'); 81 | const result = req.accepts('application/json'); 82 | expect(result).to.equal('application/json'); 83 | 84 | }); 85 | 86 | }); 87 | 88 | 89 | it('should have a "query" property containing query parameters', async () => { 90 | 91 | const req = await getReq(); 92 | expect(req.query).to.eql({ 93 | a: '1', 94 | b: '2' 95 | }); 96 | 97 | }); 98 | 99 | it('should have a "type" property containing "text/html"', async () => { 100 | 101 | const req = await getReq(); 102 | expect(req.type).to.equal('text/html'); 103 | 104 | }); 105 | 106 | it('should have a working "is()" function"', () => { 107 | 108 | const res = getReq(); 109 | expect(res.is('html')).to.equal(true); 110 | 111 | }); 112 | 113 | it('should have a "type" property containing an empty string if no Content-Type was set.', async () => { 114 | 115 | const req = await getReq(); 116 | req.headers.delete('Content-Type'); 117 | expect(req.type).to.equal(''); 118 | 119 | }); 120 | 121 | describe('rawBody', async () => { 122 | 123 | it('should return a string when passing encoding as utf-8', async () => { 124 | 125 | const req = new MemoryRequest( 126 | 'POST', 127 | '/', 128 | 'http://localhost', 129 | {}, 130 | 'hello' 131 | ); 132 | const body = await req.rawBody('utf-8'); 133 | 134 | expect(body).to.equal('hello'); 135 | 136 | }); 137 | 138 | it('should return a buffer when not passing an encoding parameter', async () => { 139 | 140 | const req = new MemoryRequest( 141 | 'POST', 142 | '/', 143 | 'http://localhost', 144 | {}, 145 | 'hello' 146 | ); 147 | const body = await req.rawBody(); 148 | 149 | expect(body).to.deep.equal(Buffer.from('hello')); 150 | 151 | }); 152 | 153 | it('should return an empty buffer for empty requests', async () => { 154 | 155 | const req = new MemoryRequest( 156 | 'POST', 157 | '/', 158 | 'http://localhost', 159 | {} 160 | ); 161 | 162 | const body = await req.rawBody(); 163 | expect(body).to.deep.equal(Buffer.from('')); 164 | 165 | }); 166 | 167 | it('should work with arbitrary body objects', async () => { 168 | 169 | const req = new MemoryRequest( 170 | 'POST', 171 | '/', 172 | 'http://localhost', 173 | {}, 174 | { foo: 'bar' }, 175 | ); 176 | 177 | const body = await req.rawBody('utf-8'); 178 | expect(body).to.deep.equal('{\n "foo": "bar"\n}'); 179 | 180 | }); 181 | 182 | it('should work with buffers', async () => { 183 | 184 | const req = new MemoryRequest( 185 | 'POST', 186 | '/', 187 | 'http://localhost', 188 | {}, 189 | Buffer.from('hello') 190 | ); 191 | 192 | const body = await req.rawBody('utf-8'); 193 | expect(body).to.deep.equal('hello'); 194 | 195 | }); 196 | 197 | it('should pass through buffers', async () => { 198 | 199 | const buffer = Buffer.from('hello'); 200 | const req = new MemoryRequest( 201 | 'POST', 202 | '/', 203 | 'http://localhost', 204 | {}, 205 | buffer 206 | ); 207 | 208 | const body = await req.rawBody(); 209 | expect(body).to.equal(buffer); 210 | 211 | }); 212 | 213 | }); 214 | 215 | describe('getStream', () => { 216 | 217 | it('should work!', (done) => { 218 | 219 | const req = new MemoryRequest( 220 | 'POST', 221 | '/', 222 | 'http://localhost', 223 | {}, 224 | 'hello' 225 | ); 226 | const stream = req.getStream(); 227 | 228 | let body = ''; 229 | stream.on('data', (chunk) => { 230 | body += chunk; 231 | }); 232 | stream.on('end', () => { 233 | 234 | expect(body).to.equal('hello'); 235 | done(); 236 | 237 | }); 238 | 239 | }); 240 | 241 | }); 242 | 243 | }); 244 | -------------------------------------------------------------------------------- /test/memory-response.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { MemoryResponse } from '../src/index.js'; 3 | 4 | function getRes() { 5 | 6 | const response = new MemoryResponse('http://localhost'); 7 | response.headers.set('Content-Type', 'text/html; charset=utf-8'); 8 | response.status = 200; 9 | 10 | return response; 11 | 12 | } 13 | 14 | describe('MemoryResponse', () => { 15 | 16 | describe('initialization', () => { 17 | 18 | it('should have headers set correctly', () => { 19 | 20 | const res = getRes(); 21 | expect(res.headers.get('content-type')).to.eql('text/html; charset=utf-8'); 22 | 23 | }); 24 | 25 | it('should have status set correctly', () => { 26 | 27 | const res = getRes(); 28 | expect(res.status).to.equal(200); 29 | 30 | }); 31 | 32 | it('should have a "type" property containing "text/html"', () => { 33 | 34 | const res = getRes(); 35 | expect(res.type).to.equal('text/html'); 36 | 37 | }); 38 | 39 | it('should have a working "is()" function"', () => { 40 | 41 | const res = getRes(); 42 | expect(res.is('html')).to.equal(true); 43 | 44 | }); 45 | 46 | it('should have a "type" property containing an empty string if no Content-Type was set.', () => { 47 | 48 | const res = getRes(); 49 | res.headers.delete('Content-Type'); 50 | expect(res.type).to.equal(''); 51 | 52 | }); 53 | 54 | 55 | }); 56 | 57 | it('should update the Content-Type header when "type" is set', async () => { 58 | 59 | const req = await getRes(); 60 | req.type = 'text/plain'; 61 | expect(req.headers.get('Content-Type')).to.equal('text/plain'); 62 | 63 | }); 64 | 65 | 66 | describe('changing the status code', () => { 67 | 68 | it('should not fail', () => { 69 | 70 | const res = getRes(); 71 | res.status = 404; 72 | 73 | expect(res.status).to.equal(404); 74 | 75 | }); 76 | 77 | }); 78 | 79 | describe('sendInformational', () => { 80 | 81 | it('should be callable but do nothing', async () => { 82 | 83 | const res = getRes(); 84 | expect(await res.sendInformational(102)).to.equal(undefined); 85 | 86 | }); 87 | 88 | }); 89 | describe('push', () => { 90 | 91 | it('should be callable but do nothing', async () => { 92 | 93 | const res = getRes(); 94 | let notCalled = true; 95 | 96 | res.push( ctx => { 97 | notCalled = false; 98 | }); 99 | expect(notCalled).is.true; 100 | 101 | }); 102 | 103 | }); 104 | 105 | describe('redirect', () => { 106 | it('should set the location header to /home with default status code 303', async () => { 107 | const res = getRes(); 108 | const defaultStatus = 303; 109 | const originalTarget = res.headers.get('Referer'); 110 | const newTarget = '/bar'; 111 | 112 | res.redirect(newTarget); 113 | 114 | expect(res.headers.get('Location')).equals(newTarget); 115 | expect(res.headers.get('Location')).not.equals(originalTarget); 116 | expect(res.status).equals(defaultStatus); 117 | }); 118 | 119 | it('should redirect to /home with provided status code 301', async () => { 120 | const res = getRes(); 121 | const defaultStatus = 303; 122 | const newStatus = 301; 123 | 124 | const newTarget = '/bar'; 125 | const originalTarget = res.headers.get('Referer'); 126 | 127 | res.redirect(newStatus, newTarget); 128 | 129 | expect(res.status).equals(newStatus); 130 | expect(res.status).not.equals(defaultStatus); 131 | 132 | expect(res.headers.get('Location')).equals(newTarget); 133 | expect(res.headers.get('Location')).not.equals(originalTarget); 134 | }); 135 | }); 136 | 137 | }); 138 | -------------------------------------------------------------------------------- /test/node/push.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | 3 | import { expect } from 'chai'; 4 | import { EventEmitter } from 'node:events'; 5 | import * as http2 from 'node:http2'; 6 | import { Application, Context, MemoryRequest, MemoryResponse } from '../../src/index.js'; 7 | import push from '../../src/node/push.js'; 8 | import NodeResponse from '../../src/node/response.js'; 9 | 10 | describe('NodeResponse http/2 push', () => { 11 | 12 | const matches = process.version.match(/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/); 13 | if (matches) { 14 | const major = parseInt(matches[1]); 15 | const minor = parseInt(matches[2]); 16 | if (major < 9 || (major === 9 && minor < 4)) { 17 | // The reason we requrie 8.11.2 is because in this version 18 | // http2.HttpSession.close() was added. 19 | throw new Error('This package requires Node version 8.11.2'); 20 | } 21 | } 22 | 23 | it('should work', async () => { 24 | 25 | const app = new Application(); 26 | const server = http2.createServer(app.callback()); 27 | server.listen(32653); 28 | 29 | app.use( ctx => { 30 | 31 | switch (ctx.request.path) { 32 | case '/foo' : 33 | ctx.response.body = 'Hello world A'; 34 | ctx.response.push( pushCtx => { 35 | pushCtx.request.path = '/bar'; 36 | return app.handle(pushCtx); 37 | }); 38 | break; 39 | case '/bar' : 40 | ctx.response.body = 'Hello world B'; 41 | break; 42 | } 43 | 44 | }); 45 | 46 | const client = http2.connect('http://localhost:32653', { 47 | settings: { 48 | enablePush: true, 49 | } 50 | }); 51 | 52 | const req = client.request({':path': '/foo'}); 53 | 54 | let data = ''; 55 | let pushedData = ''; 56 | let pushRequestHeaders; 57 | let pushResponseHeaders; 58 | let responseHeaders; 59 | 60 | await new Promise((res, rej) => { 61 | 62 | 63 | client.on('stream', (pushedStream, requestHeaders) => { 64 | 65 | pushRequestHeaders = requestHeaders; 66 | 67 | pushedStream.setEncoding('utf-8'); 68 | pushedStream.on('push', (responseHeaders) => { 69 | 70 | pushResponseHeaders = responseHeaders; 71 | 72 | }); 73 | pushedStream.on('data', (chunk) => { 74 | 75 | pushedData += chunk; 76 | 77 | }); 78 | 79 | }); 80 | req.setEncoding('utf-8'); 81 | req.on('response', (headers, flags) => { 82 | responseHeaders = headers; 83 | }); 84 | req.on('data', (chunk) => { 85 | data += chunk; 86 | }); 87 | req.on('end', () => { 88 | client.close(); 89 | res([data, pushedData]); 90 | }); 91 | 92 | }); 93 | 94 | server.close(); 95 | client.close(); 96 | 97 | expect(data).to.equal('Hello world A'); 98 | expect(pushedData).to.equal('Hello world B'); 99 | expect(pushRequestHeaders?.[':authority']).to.equal('localhost:32653'); 100 | expect(pushRequestHeaders?.[':method']).to.equal('GET'); 101 | expect(pushRequestHeaders?.[':path']).to.equal('/bar'); 102 | expect(pushRequestHeaders?.[':scheme']).to.equal('http'); 103 | expect((pushResponseHeaders as any)[':status']).to.eql(200); 104 | expect((responseHeaders as any)[':status']).to.eql(200); 105 | 106 | }); 107 | it('should still work when the pushed resource uses query parameters', async () => { 108 | 109 | const app = new Application(); 110 | const server = http2.createServer(app.callback()); 111 | server.listen(32653); 112 | 113 | app.use( ctx => { 114 | 115 | switch (ctx.request.path) { 116 | case '/foo' : 117 | ctx.response.body = 'Hello world A'; 118 | ctx.response.push( pushCtx => { 119 | pushCtx.request.path = '/bar?sup'; 120 | return app.handle(pushCtx); 121 | }); 122 | break; 123 | case '/bar' : 124 | ctx.response.body = 'Hello world B'; 125 | break; 126 | } 127 | 128 | }); 129 | 130 | const client = http2.connect('http://localhost:32653', { 131 | settings: { 132 | enablePush: true, 133 | } 134 | }); 135 | 136 | const req = client.request({':path': '/foo'}); 137 | 138 | let data = ''; 139 | let pushedData = ''; 140 | let pushRequestHeaders; 141 | let pushResponseHeaders; 142 | let responseHeaders; 143 | 144 | await new Promise((res, rej) => { 145 | 146 | 147 | client.on('stream', (pushedStream, requestHeaders) => { 148 | 149 | pushRequestHeaders = requestHeaders; 150 | 151 | pushedStream.setEncoding('utf-8'); 152 | pushedStream.on('push', (responseHeaders) => { 153 | 154 | pushResponseHeaders = responseHeaders; 155 | 156 | }); 157 | pushedStream.on('data', (chunk) => { 158 | 159 | pushedData += chunk; 160 | 161 | }); 162 | 163 | }); 164 | req.setEncoding('utf-8'); 165 | req.on('response', (headers, flags) => { 166 | responseHeaders = headers; 167 | }); 168 | req.on('data', (chunk) => { 169 | data += chunk; 170 | }); 171 | req.on('end', () => { 172 | client.close(); 173 | res([data, pushedData]); 174 | }); 175 | 176 | }); 177 | 178 | server.close(); 179 | client.close(); 180 | 181 | expect(data).to.equal('Hello world A'); 182 | expect(pushedData).to.equal('Hello world B'); 183 | expect(pushRequestHeaders?.[':authority']).to.equal('localhost:32653'); 184 | expect(pushRequestHeaders?.[':method']).to.equal('GET'); 185 | expect(pushRequestHeaders?.[':path']).to.equal('/bar?sup'); 186 | expect(pushRequestHeaders?.[':scheme']).to.equal('http'); 187 | expect((pushResponseHeaders as any)[':status']).to.eql(200); 188 | expect((responseHeaders as any)[':status']).to.eql(200); 189 | 190 | }); 191 | 192 | it('should do nothing when a HTTP/1 server is used', async () => { 193 | 194 | const app = new Application(); 195 | const server = app.listen(32653); 196 | 197 | let notCalled = true; 198 | app.use( ctx => { 199 | 200 | switch (ctx.request.path) { 201 | case '/foo' : 202 | ctx.response.body = 'Hello world A'; 203 | ctx.response.push( pushCtx => { 204 | notCalled = false; 205 | }); 206 | break; 207 | } 208 | 209 | }); 210 | 211 | const response = await fetch('http://localhost:32653/foo'); 212 | const body = await response.text(); 213 | server.close(); 214 | expect(body).to.equal('Hello world A'); 215 | expect(notCalled).to.be.true; 216 | 217 | }); 218 | 219 | it('should do nothing if client doesn\'t want pushes', async () => { 220 | 221 | const app = new Application(); 222 | const server = http2.createServer(app.callback()); 223 | server.listen(32653); 224 | let notCalled = true; 225 | let notCalled2 = true; 226 | 227 | app.use( ctx => { 228 | 229 | switch (ctx.request.path) { 230 | case '/foo' : 231 | ctx.response.body = 'Hello world A'; 232 | ctx.response.push( pushCtx => { 233 | notCalled = false; 234 | }); 235 | break; 236 | case '/bar' : 237 | ctx.response.body = 'Hello world B'; 238 | break; 239 | } 240 | 241 | }); 242 | 243 | const client = http2.connect('http://localhost:32653', { 244 | settings: { 245 | enablePush: false, 246 | } 247 | }); 248 | 249 | const req = client.request({':path': '/foo'}); 250 | 251 | let data = ''; 252 | let responseHeaders; 253 | 254 | await new Promise((res, rej) => { 255 | 256 | 257 | client.on('stream', (pushedStream, requestHeaders) => { 258 | 259 | notCalled2 = false; 260 | 261 | }); 262 | req.setEncoding('utf-8'); 263 | req.on('response', (headers, flags) => { 264 | responseHeaders = headers; 265 | }); 266 | req.on('data', (chunk) => { 267 | data += chunk; 268 | }); 269 | req.on('end', () => { 270 | client.close(); 271 | res(); 272 | }); 273 | 274 | }); 275 | 276 | server.close(); 277 | client.close(); 278 | 279 | expect(data).to.equal('Hello world A'); 280 | expect((responseHeaders as any)[':status']).to.eql(200); 281 | expect(notCalled).to.eql(true); 282 | expect(notCalled2).to.eql(true); 283 | 284 | }); 285 | it('should throw an error when no path was set', async () => { 286 | 287 | const response = new NodeResponse( 288 | { 289 | stream: { 290 | pushAllowed: true 291 | } 292 | } as any, 293 | 'http://localhost', 294 | ); 295 | 296 | let err; 297 | try { 298 | 299 | await response.push( pushCtx => { 300 | // eslint-disable-next-line @typescript-eslint/no-empty-function 301 | }); 302 | 303 | } catch (e: any) { 304 | console.error(e); 305 | err = e; 306 | } 307 | 308 | expect(err).to.be.an.instanceof(Error); 309 | expect(err.message).to.equal('The "path" must be set in the push context\'s request'); 310 | 311 | }); 312 | 313 | it('should handle stream errors', async () => { 314 | 315 | const response = new NodeResponse( 316 | { 317 | stream: { 318 | pushStream(headers: any, callback: any) { 319 | callback(new Error('hi')); 320 | }, 321 | pushAllowed: true 322 | }, 323 | } as any, 324 | 'http://localhost' 325 | ); 326 | 327 | let err; 328 | try { 329 | 330 | await response.push( (pushCtx, next) => { 331 | // next does nothing, but it's part of the callback 332 | // signature so it can be compatible with middlewares. 333 | // we're calling next, because we want to trick nyc to 334 | // give us 100% code coverage. 335 | next(); 336 | pushCtx.request.path = '/foo'; 337 | }); 338 | 339 | } catch (e: any) { 340 | console.error(e); 341 | err = e; 342 | } 343 | 344 | expect(err).to.be.an.instanceof(Error); 345 | expect((err).message).to.equal('hi'); 346 | 347 | }); 348 | }); 349 | 350 | 351 | describe('push() function', () => { 352 | 353 | describe('late push disabled', () => { 354 | 355 | it('should not error', async () => { 356 | 357 | const stream = { 358 | pushStream: () => { 359 | const error = new Error('HTTP/2 client has disabled push'); 360 | (error as any).code = 'ERR_HTTP2_PUSH_DISABLED'; 361 | throw error; 362 | } 363 | }; 364 | 365 | await push( 366 | stream as any, 367 | new Context( 368 | new MemoryRequest('GET', '/push-resource', 'http://localhost'), 369 | new MemoryResponse('http://localhost') 370 | ) 371 | ); 372 | 373 | }); 374 | 375 | }); 376 | 377 | describe('Client refusing stream', () => { 378 | 379 | it('should not error', async () => { 380 | 381 | class FakeStream extends EventEmitter { 382 | 383 | rstCode?: number; 384 | respond() { 385 | 386 | const err = new Error('Refused'); 387 | this.rstCode = http2.constants.NGHTTP2_REFUSED_STREAM; 388 | this.emit('error', err); 389 | 390 | } 391 | 392 | // eslint-disable-next-line @typescript-eslint/no-empty-function 393 | end() { 394 | 395 | } 396 | 397 | } 398 | 399 | const stream = { 400 | pushStream: (headers: any, callback: any) => { 401 | callback(null, new FakeStream()); 402 | } 403 | }; 404 | 405 | await push( 406 | stream as any, 407 | new Context( 408 | new MemoryRequest('GET', '/push-resource', 'http://localhost'), 409 | new MemoryResponse('http://localhost') 410 | ) 411 | ); 412 | 413 | }); 414 | 415 | }); 416 | 417 | 418 | describe('Other errors', () => { 419 | 420 | it('should bubble', async () => { 421 | 422 | class FakeStream extends EventEmitter { 423 | 424 | respond() { 425 | 426 | const err = new Error('Other error'); 427 | this.emit('error', err); 428 | 429 | } 430 | 431 | } 432 | 433 | const stream = { 434 | pushStream: (headers: any, callback: any) => { 435 | callback(null, new FakeStream()); 436 | } 437 | }; 438 | 439 | let caught = false; 440 | 441 | try { 442 | await push( 443 | stream as any, 444 | new Context( 445 | new MemoryRequest('GET', '/push-resource', 'http://localhost'), 446 | new MemoryResponse('http://localhost') 447 | ) 448 | ); 449 | } catch (e: any) { 450 | caught = true; 451 | } 452 | 453 | expect(caught).to.equal(true); 454 | 455 | }); 456 | 457 | }); 458 | 459 | }); 460 | -------------------------------------------------------------------------------- /test/node/request.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Application, Request } from '../../src/index.js'; 3 | 4 | let lastPort = 6500; 5 | function getPort() { 6 | return lastPort++; 7 | } 8 | 9 | async function getReq() { 10 | 11 | let request: Request; 12 | const app = new Application(); 13 | const port = getPort(); 14 | const server = app.listen(port); 15 | 16 | app.use(async ctx => { 17 | request = ctx.request; 18 | ctx.response.body = 'response body'; 19 | }); 20 | 21 | await fetch('http://localhost:'+port+'/foo/bar?a=1&b=2', { 22 | method: 'POST', 23 | headers: { 24 | 'accept': 'text/html', 25 | 'content-type': 'text/html; charset=utf-8', 26 | }, 27 | body: 'hello', 28 | }); 29 | 30 | server.close(); 31 | 32 | // @ts-expect-error let's ignore this 33 | return request; 34 | 35 | } 36 | 37 | describe('NodeRequest', () => { 38 | 39 | it('should have headers set correctly', async () => { 40 | 41 | const req = await getReq(); 42 | expect(req.headers.get('content-type')).to.eql('text/html; charset=utf-8'); 43 | 44 | }); 45 | 46 | it('should have path set correctly', async () => { 47 | 48 | const req = await getReq(); 49 | expect(req.path).to.eql('/foo/bar'); 50 | 51 | }); 52 | 53 | it('should have a "method"', async () => { 54 | 55 | const req = await getReq(); 56 | expect(req.method).to.eql('POST'); 57 | 58 | }); 59 | 60 | it('should have a "query" property containing query parameters', async () => { 61 | 62 | const req = await getReq(); 63 | expect(req.query).to.eql({ 64 | a: '1', 65 | b: '2' 66 | }); 67 | 68 | }); 69 | 70 | it('should have a "type" property containing "text/html"', async () => { 71 | 72 | const req = await getReq(); 73 | expect(req.type).to.equal('text/html'); 74 | 75 | }); 76 | 77 | it('should have a "type" property containing an empty string if no Content-Type was set.', async () => { 78 | 79 | const req = await getReq(); 80 | req.headers.delete('Content-Type'); 81 | expect(req.type).to.equal(''); 82 | 83 | }); 84 | 85 | describe('rawBody', async () => { 86 | 87 | it('should return a string when passing encoding as utf-8', async () => { 88 | 89 | let body; 90 | const app = new Application(); 91 | const port = getPort(); 92 | const server = app.listen(port); 93 | 94 | app.use(async ctx => { 95 | body = await ctx.request.rawBody('utf-8'); 96 | ctx.response.body = 'response body'; 97 | }); 98 | 99 | await fetch('http://localhost:'+port+'/foo/bar?a=1&b=2', { 100 | method: 'POST', 101 | headers: { 102 | 'accept': 'text/html', 103 | 'content-type': 'text/html; charset=utf-8', 104 | 105 | }, 106 | body: 'hello', 107 | }); 108 | 109 | server.close(); 110 | expect(body).to.equal('hello'); 111 | 112 | }); 113 | 114 | it('should return a buffer when not passing an encoding parameter', async () => { 115 | 116 | let body; 117 | const app = new Application(); 118 | const port = getPort(); 119 | const server = app.listen(port); 120 | 121 | app.use(async ctx => { 122 | body = await ctx.request.rawBody(); 123 | ctx.response.body = 'response body'; 124 | }); 125 | 126 | await fetch('http://localhost:'+port+'/foo/bar?a=1&b=2', { 127 | method: 'POST', 128 | headers: { 129 | 'accept': 'text/html', 130 | 'content-type': 'text/html; charset=utf-8', 131 | 132 | }, 133 | body: 'hello', 134 | }); 135 | 136 | server.close(); 137 | expect(body).to.deep.equal(Buffer.from('hello')); 138 | 139 | }); 140 | 141 | it('should return an empty buffer for empty requests', async () => { 142 | 143 | let body; 144 | const app = new Application(); 145 | const port = getPort(); 146 | const server = app.listen(port); 147 | 148 | app.use(async ctx => { 149 | body = await ctx.request.rawBody(); 150 | ctx.response.body = 'response body'; 151 | }); 152 | 153 | await fetch('http://localhost:'+port+'/foo/bar?a=1&b=2', { 154 | method: 'GET', 155 | headers: { 156 | 'accept': 'text/html', 157 | 'content-type': 'text/html; charset=utf-8', 158 | 159 | } 160 | }); 161 | 162 | server.close(); 163 | expect(body).to.deep.equal(Buffer.from('')); 164 | 165 | }); 166 | 167 | it('should throw an error when the request body exceeds the limit', async () => { 168 | 169 | const app = new Application(); 170 | const port = getPort(); 171 | const server = app.listen(port); 172 | let error; 173 | 174 | app.use(async ctx => { 175 | try { 176 | await ctx.request.rawBody('utf-8', '3'); 177 | } catch (err) { 178 | error = err; 179 | } 180 | ctx.response.body = 'response body'; 181 | }); 182 | 183 | await fetch('http://localhost:'+port+'/foo/bar?a=1&b=2', { 184 | method: 'POST', 185 | headers: { 186 | 'accept': 'text/html', 187 | 'content-type': 'text/html; charset=utf-8', 188 | 189 | }, 190 | body: 'hello', 191 | }); 192 | 193 | server.close(); 194 | // @ts-expect-error let's ignore this 195 | expect(error.message).to.equal('request entity too large'); 196 | 197 | }); 198 | 199 | }); 200 | 201 | describe('ip()', () => { 202 | 203 | it('should return the ip address of the client that\'s connecting', async () => { 204 | 205 | const app = new Application(); 206 | const port = getPort(); 207 | const server = app.listen(port); 208 | let ip; 209 | 210 | app.use(async ctx => { 211 | ip = ctx.ip(); 212 | }); 213 | 214 | await fetch('http://localhost:'+port+'/foo/bar?a=1&b=2', { 215 | method: 'POST', 216 | headers: { 217 | 'accept': 'text/html', 218 | 'content-type': 'text/html; charset=utf-8', 219 | 220 | }, 221 | body: 'hello', 222 | }); 223 | 224 | server.close(); 225 | expect(ip).to.be.oneOf([ 226 | '::ffff:127.0.0.1', 227 | '::1', 228 | ]); 229 | 230 | }); 231 | 232 | it('should use X-Forwarded-For if trustProxy was true', async () => { 233 | 234 | const app = new Application(); 235 | const port = getPort(); 236 | const server = app.listen(port); 237 | let ip; 238 | 239 | app.use(async ctx => { 240 | ip = ctx.ip(true); 241 | }); 242 | 243 | await fetch('http://localhost:'+port+'/foo/bar?a=1&b=2', { 244 | method: 'POST', 245 | headers: { 246 | 'accept': 'text/html', 247 | 'content-type': 'text/html; charset=utf-8', 248 | 'x-forwarded-for': '127.0.0.2', 249 | 250 | }, 251 | body: 'hello', 252 | }); 253 | 254 | server.close(); 255 | expect(ip).to.eql('127.0.0.2'); 256 | 257 | }); 258 | it('should not use X-Forwarded-For if trustProxy was false', async () => { 259 | 260 | const app = new Application(); 261 | const port = getPort(); 262 | const server = app.listen(port); 263 | let ip; 264 | 265 | app.use(async ctx => { 266 | ip = ctx.ip(false); 267 | }); 268 | 269 | await fetch('http://localhost:'+port+'/foo/bar?a=1&b=2', { 270 | method: 'POST', 271 | headers: { 272 | 'accept': 'text/html', 273 | 'content-type': 'text/html; charset=utf-8', 274 | 'x-forwarded-for': '127.0.0.2', 275 | 276 | }, 277 | body: 'hello', 278 | }); 279 | 280 | server.close(); 281 | expect(ip).to.be.oneOf([ 282 | '::ffff:127.0.0.1', 283 | '::1', 284 | ]); 285 | 286 | }); 287 | it('should use X-Forwarded-For if CURVEBALL_TRUSTPROXY environment variable was set', async () => { 288 | 289 | const app = new Application(); 290 | const port = getPort(); 291 | const server = app.listen(port); 292 | let ip; 293 | 294 | process.env.CURVEBALL_TRUSTPROXY = '1'; 295 | app.use(async ctx => { 296 | ip = ctx.ip(); 297 | }); 298 | 299 | await fetch('http://localhost:'+port+'/foo/bar?a=1&b=2', { 300 | method: 'POST', 301 | headers: { 302 | 'accept': 'text/html', 303 | 'content-type': 'text/html; charset=utf-8', 304 | 'x-forwarded-for': '127.0.0.2', 305 | 306 | }, 307 | body: 'hello', 308 | }); 309 | 310 | delete process.env.CURVEBALL_TRUSTPROXY; 311 | server.close(); 312 | expect(ip).to.eql('127.0.0.2'); 313 | 314 | }); 315 | 316 | 317 | it('should use the clients ip if trustProxy was true but there was no XFF header', async () => { 318 | 319 | const app = new Application(); 320 | const port = getPort(); 321 | const server = app.listen(port); 322 | let ip; 323 | 324 | app.use(async ctx => { 325 | ip = ctx.ip(true); 326 | }); 327 | 328 | await fetch('http://localhost:'+port+'/foo/bar?a=1&b=2', { 329 | method: 'POST', 330 | headers: { 331 | 'accept': 'text/html', 332 | 'content-type': 'text/html; charset=utf-8', 333 | }, 334 | body: 'hello', 335 | }); 336 | 337 | server.close(); 338 | 339 | 340 | expect(ip).to.be.oneOf([ 341 | '::ffff:127.0.0.1', 342 | '::1', 343 | ]); 344 | 345 | }); 346 | }); 347 | describe('getStream', async () => { 348 | 349 | it('should work!', async () => { 350 | 351 | let outerBody; 352 | const app = new Application(); 353 | const port = getPort(); 354 | const server = app.listen(port); 355 | 356 | app.use(async ctx => { 357 | const stream = ctx.request.getStream(); 358 | 359 | let body = ''; 360 | stream.on('data', (chunk) => { 361 | body += chunk; 362 | }); 363 | stream.on('end', () => { 364 | 365 | outerBody = body; 366 | 367 | }); 368 | }); 369 | 370 | await fetch('http://localhost:'+port+'/foo/bar?a=1&b=2', { 371 | method: 'POST', 372 | headers: { 373 | 'accept': 'text/html', 374 | 'content-type': 'text/html; charset=utf-8', 375 | 376 | }, 377 | body: 'hello', 378 | }); 379 | 380 | server.close(); 381 | expect(outerBody).to.equal('hello'); 382 | 383 | }); 384 | 385 | }); 386 | 387 | }); 388 | -------------------------------------------------------------------------------- /test/node/response.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as http from 'node:http'; 3 | import * as http2 from 'node:http2'; 4 | import * as sinon from 'sinon'; 5 | import { Application, Headers } from '../../src/index.js'; 6 | import { NodeResponse } from '../../src/node/response.js'; 7 | import headersInterfaceTests from '../headers-interface-tests.js'; 8 | 9 | function getRes() { 10 | 11 | const request = new http.IncomingMessage(null as any); 12 | const inner = new http.ServerResponse(request); 13 | 14 | inner.setHeader('Content-Type', 'text/html; charset=utf-8'); 15 | inner.statusCode = 200; 16 | 17 | const outer = new NodeResponse(inner, 'http://localhost'); 18 | return outer; 19 | 20 | } 21 | 22 | describe('NodeResponse', () => { 23 | 24 | describe('initialization', () => { 25 | 26 | it('should have headers set correctly', () => { 27 | 28 | const res = getRes(); 29 | expect(res.headers.get('content-type')).to.eql('text/html; charset=utf-8'); 30 | 31 | }); 32 | 33 | it('should have status set correctly', () => { 34 | 35 | const res = getRes(); 36 | expect(res.status).to.equal(404); 37 | 38 | }); 39 | 40 | it('should have a "type" property containing "text/html"', () => { 41 | 42 | const res = getRes(); 43 | expect(res.type).to.equal('text/html'); 44 | 45 | }); 46 | 47 | it('should have a "type" property containing an empty string if no Content-Type was set.', () => { 48 | 49 | const res = getRes(); 50 | res.headers.delete('Content-Type'); 51 | expect(res.type).to.equal(''); 52 | 53 | }); 54 | 55 | 56 | it('should have a working "is()" function"', () => { 57 | 58 | const res = getRes(); 59 | expect(res.is('html')).to.equal(true); 60 | 61 | }); 62 | 63 | }); 64 | 65 | it('should update the Content-Type header when "type" is set', async () => { 66 | 67 | const req = await getRes(); 68 | req.type = 'text/plain'; 69 | expect(req.headers.get('Content-Type')).to.equal('text/plain'); 70 | 71 | }); 72 | 73 | 74 | describe('changing the status code', () => { 75 | 76 | it('should not fail', () => { 77 | 78 | const res = getRes(); 79 | res.status = 404; 80 | 81 | expect(res.status).to.equal(404); 82 | 83 | }); 84 | 85 | }); 86 | 87 | describe('sendInformational', () => { 88 | 89 | it('should send a 100 Status when called via HTTP/1', async () => { 90 | 91 | const res = getRes(); 92 | // @ts-expect-error - Ignoring 'private' accessor. 93 | const mock = sinon.mock(res.inner); 94 | 95 | const writeRawMock = mock.expects('_writeRaw'); 96 | writeRawMock.callsArgWith(2, null, true); 97 | 98 | await res.sendInformational(100); 99 | const body = 'HTTP/1.1 100 Continue\r\n\r\n'; 100 | 101 | expect(writeRawMock.calledOnce).to.equal(true); 102 | expect(writeRawMock.calledWith(body)).to.equal(true); 103 | 104 | mock.restore(); 105 | 106 | }); 107 | 108 | it('should send a 103 Status when called via HTTP/1', async () => { 109 | 110 | const res = getRes(); 111 | // @ts-expect-error - Ignoring 'private' accessor. 112 | const mock = sinon.mock(res.inner); 113 | 114 | const writeRawMock = mock.expects('_writeRaw'); 115 | writeRawMock.callsArgWith(2, null, true); 116 | 117 | await res.sendInformational(103, { 118 | Foo: 'bar', 119 | Many: ['1', '2'] 120 | }); 121 | 122 | const body = 'HTTP/1.1 103 Early Hints\r\nFoo: bar\r\nMany: 1\r\nMany: 2\r\n\r\n'; 123 | 124 | expect(writeRawMock.calledOnce).to.equal(true); 125 | expect(writeRawMock.calledWith(body)).to.equal(true); 126 | 127 | mock.restore(); 128 | 129 | }); 130 | 131 | it('should also correctly send the 103 status when headers are passed as a HeadersInterface', async () => { 132 | 133 | const res = getRes(); 134 | // @ts-expect-error - Ignoring 'private' accessor. 135 | const mock = sinon.mock(res.inner); 136 | 137 | const writeRawMock = mock.expects('_writeRaw'); 138 | writeRawMock.callsArgWith(2, null, true); 139 | 140 | await res.sendInformational(103, new Headers({ 141 | Foo: 'bar', 142 | Many: ['1', '2'] 143 | })); 144 | 145 | const body = 'HTTP/1.1 103 Early Hints\r\nfoo: bar\r\nmany: 1\r\nmany: 2\r\n\r\n'; 146 | 147 | expect(writeRawMock.calledOnce).to.equal(true); 148 | expect(writeRawMock.calledWith(body)).to.equal(true); 149 | 150 | mock.restore(); 151 | 152 | }); 153 | 154 | it('should send a 103 Status when called with a HTTP/2 response', async () => { 155 | 156 | const app = new Application(); 157 | const server = http2.createServer({}, app.callback()); 158 | let client: any; 159 | 160 | server.listen(8555); 161 | 162 | return new Promise( (resolve, reject) => { 163 | app.use(async ctx => { 164 | 165 | await ctx.response.sendInformational(103, { 166 | Foo: 'bar', 167 | Many: ['1', '2'] 168 | }); 169 | ctx.response.body = 'hello'; 170 | 171 | }); 172 | 173 | client = http2.connect('http://localhost:8555'); 174 | 175 | const req = client.request({':path': '/'}); 176 | req.on('headers', (headers: http2.IncomingHttpHeaders, flags: number) => { 177 | resolve(headers); 178 | }); 179 | req.setEncoding('utf-8'); 180 | req.on('end', () => { client.close(); }); 181 | req.end(); 182 | }).then((headers: any) => { 183 | client.close(); 184 | server.close(); 185 | expect(headers['many']).to.equal('1, 2'); 186 | expect(headers['foo']).to.equal('bar'); 187 | expect(headers[':status']).to.equal(103); 188 | }); 189 | }); 190 | 191 | }); 192 | 193 | }); 194 | 195 | describe('NodeResponseHeaders', () => { 196 | 197 | const res = getRes(); 198 | headersInterfaceTests(res.headers); 199 | 200 | }); 201 | 202 | -------------------------------------------------------------------------------- /test/polyfills.mjs: -------------------------------------------------------------------------------- 1 | import fetch, { 2 | Headers, 3 | Request, 4 | Response, 5 | } from 'node-fetch' 6 | 7 | if (!globalThis.fetch) { 8 | globalThis.fetch = fetch 9 | globalThis.Headers = Headers 10 | globalThis.Request = Request 11 | globalThis.Response = Response 12 | } 13 | -------------------------------------------------------------------------------- /test/request.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Headers, Request } from '../src/index.js'; 3 | import { HeadersObject } from '@curveball/kernel'; 4 | import { Readable } from 'node:stream'; 5 | 6 | class FakeRequest extends Request { 7 | 8 | constructor(method: string, path: string, headers: HeadersObject, body: any = '') { 9 | 10 | super(method, path, 'http://localhost'); 11 | this.headers = new Headers(headers); 12 | this.body = Buffer.from(body); 13 | 14 | } 15 | 16 | async rawBody(encoding?: string, limit?: string): Promise; 17 | async rawBody(encoding?: undefined, limit?: string): Promise; 18 | async rawBody(encoding?: undefined, limit?: string): Promise { 19 | 20 | if (encoding) { 21 | return (this.body as any).toString(encoding); 22 | } else { 23 | return (this.body as any); 24 | } 25 | 26 | } 27 | /** 28 | * getStream returns a Node.js readable stream. 29 | * 30 | * A stream can typically only be read once. 31 | */ 32 | getStream(): Readable { 33 | 34 | const s = new Readable(); 35 | s.push(this.body); 36 | return s; 37 | 38 | } 39 | 40 | } 41 | 42 | function getReq() { 43 | 44 | return new FakeRequest( 45 | 'POST', 46 | '/foo?a=1&b=2', 47 | { 48 | 'X-FOO': 'BAR', 49 | 'Accept': 'text/html', 50 | 'Content-Type': 'text/html', 51 | }, 52 | 'hello world', 53 | ); 54 | 55 | } 56 | 57 | describe('Request', () => { 58 | 59 | describe('constructing', async () => { 60 | 61 | it('should get its method set correctly', () => { 62 | 63 | expect(getReq().method).to.equal('POST'); 64 | 65 | }); 66 | 67 | it('should have its path set correctly', () => { 68 | 69 | expect(getReq().path).to.equal('/foo'); 70 | 71 | }); 72 | 73 | it('should have its headers set correctly', () => { 74 | 75 | expect(getReq().headers.get('x-foo')).to.eql('BAR'); 76 | 77 | }); 78 | 79 | }); 80 | 81 | describe('accepts function', () => { 82 | 83 | it('should work', async () => { 84 | 85 | const req = await getReq(); 86 | const result = req.accepts('application/json', 'text/html'); 87 | expect(result).to.equal('text/html'); 88 | 89 | }); 90 | 91 | it('should return false if there was no acceptable match.', async () => { 92 | 93 | const req = await getReq(); 94 | const result = req.accepts('application/json'); 95 | expect(result).to.equal(null); 96 | 97 | }); 98 | 99 | it('should return the first accepts header if no Accept header was provided.', async () => { 100 | 101 | const req = await getReq(); 102 | req.headers.delete('accept'); 103 | const result = req.accepts('application/json'); 104 | expect(result).to.equal('application/json'); 105 | 106 | }); 107 | 108 | }); 109 | 110 | 111 | it('should have a "query" property containing query parameters', async () => { 112 | 113 | const req = await getReq(); 114 | expect(req.query).to.eql({ 115 | a: '1', 116 | b: '2' 117 | }); 118 | 119 | }); 120 | 121 | it('should have a "type" property containing "text/html"', async () => { 122 | 123 | const req = await getReq(); 124 | expect(req.type).to.equal('text/html'); 125 | 126 | }); 127 | 128 | it('should have a working "is()" function"', () => { 129 | 130 | const res = getReq(); 131 | expect(res.is('html')).to.equal(true); 132 | 133 | }); 134 | 135 | it('should have a "type" property containing an empty string if no Content-Type was set.', async () => { 136 | 137 | const req = await getReq(); 138 | req.headers.delete('Content-Type'); 139 | expect(req.type).to.equal(''); 140 | 141 | }); 142 | 143 | it('should have a working prefer() function', () => { 144 | 145 | const req = new FakeRequest('GET', '/foo', { Prefer: 'handling=lenient, respond-async' }); 146 | expect(req.prefer('handling')).to.equal('lenient'); 147 | expect(req.prefer('respond-async')).to.equal(true); 148 | expect(req.prefer('return')).to.equal(false); 149 | 150 | }); 151 | 152 | }); 153 | -------------------------------------------------------------------------------- /test/websocket.ts: -------------------------------------------------------------------------------- 1 | import { Application } from '../src/index.js'; 2 | import { WebSocket } from 'ws'; 3 | import { UpgradeRequired } from '@curveball/http-errors'; 4 | import { expect } from 'chai'; 5 | 6 | describe('Websocket support', () => { 7 | 8 | it('should automatically handle Websockets', () => { 9 | 10 | const app = new Application(); 11 | app.use( ctx => { 12 | 13 | if (!ctx.webSocket) { 14 | throw new UpgradeRequired('Websocket is a must'); 15 | } 16 | 17 | ctx.webSocket.send('Hello'); 18 | 19 | 20 | }); 21 | const wss = app.listen(57001); 22 | 23 | return new Promise(res => { 24 | const ws = new WebSocket('ws://localhost:57001'); 25 | ws.on('message', (msg) => { 26 | 27 | expect(msg.toString()).to.equal('Hello'); 28 | ws.close(); 29 | wss.close(); 30 | res(); 31 | 32 | }); 33 | 34 | }); 35 | 36 | 37 | }); 38 | 39 | it('Should let users open a websocket-only port with listenWs', () => { 40 | 41 | const app = new Application(); 42 | app.use( ctx => { 43 | 44 | if (!ctx.webSocket) { 45 | throw new UpgradeRequired('Websocket is a must'); 46 | } 47 | 48 | ctx.webSocket.send('Hello'); 49 | 50 | 51 | }); 52 | const wss = app.listenWs(57001); 53 | 54 | return new Promise(res => { 55 | const ws = new WebSocket('ws://localhost:57001'); 56 | ws.on('message', (msg) => { 57 | 58 | expect(msg.toString()).to.equal('Hello'); 59 | ws.close(); 60 | wss.close(); 61 | res(); 62 | 63 | }); 64 | 65 | }); 66 | 67 | 68 | }); 69 | 70 | 71 | it('should start a websocket server with a hostname', () => { 72 | 73 | const app = new Application(); 74 | app.use( ctx => { 75 | 76 | if (!ctx.webSocket) { 77 | throw new UpgradeRequired('Websocket is a must'); 78 | } 79 | 80 | ctx.webSocket.send('Hello'); 81 | 82 | 83 | }); 84 | const wss = app.listenWs(57001, '0.0.0.0'); 85 | 86 | return new Promise(res => { 87 | const ws = new WebSocket('ws://0.0.0.0:57001'); 88 | ws.on('message', (msg) => { 89 | 90 | expect(msg.toString()).to.equal('Hello'); 91 | ws.close(); 92 | wss.close(); 93 | res(); 94 | 95 | }); 96 | 97 | }); 98 | 99 | 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "node16", 4 | "target": "es2022", 5 | 6 | "strict": true, 7 | "noFallthroughCasesInSwitch": true, 8 | "noUnusedLocals": true, 9 | 10 | "sourceMap": true, 11 | "outDir": "dist", 12 | "baseUrl": ".", 13 | "paths": { 14 | "*": [ 15 | "src/types/*" 16 | ] 17 | }, 18 | "lib": [ 19 | "DOM", 20 | "ES2022" 21 | ], 22 | "declaration": true 23 | }, 24 | "include": [ 25 | "src/**/*" 26 | ] 27 | } 28 | --------------------------------------------------------------------------------