├── .eslintrc.yml ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc.yml ├── LICENSE.md ├── README.md ├── bun.lockb ├── examples ├── basic.ts ├── error.ts ├── middleware.ts ├── router.ts └── types.ts ├── package.json ├── src ├── bagel.spec.ts ├── bagel.ts ├── index.ts ├── request.ts ├── response.ts ├── route.ts ├── router.spec.ts ├── router.ts └── utils │ ├── common.spec.ts │ ├── common.ts │ └── logger.ts └── tsconfig.json /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | extends: 5 | - eslint:recommended 6 | - plugin:@typescript-eslint/recommended 7 | overrides: [] 8 | parser: '@typescript-eslint/parser' 9 | parserOptions: 10 | ecmaVersion: latest 11 | sourceType: module 12 | plugins: 13 | - '@typescript-eslint' 14 | rules: 15 | indent: 16 | - error 17 | - 2 18 | linebreak-style: 19 | - error 20 | - unix 21 | quotes: 22 | - error 23 | - single 24 | semi: 25 | - error 26 | - always 27 | '@typescript-eslint/no-explicit-any': off -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: 'ci' 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Setup Bun 17 | run: | 18 | curl -fsSL https://bun.sh/install | bash 19 | echo "${HOME}/.bun/bin" >> $GITHUB_PATH 20 | - name: Build 21 | run: | 22 | bun install 23 | bun run build 24 | - name: Test 25 | run: bun wiptest 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | tabWidth: 2 2 | semi: true 3 | trailingComma: all 4 | singleQuote: true -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 KaKeng Loh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Bagel.js logo 3 | Bagel 4 |

5 | 6 | ![Bagel CI status](https://github.com/kakengloh/bagel/actions/workflows/ci.yml/badge.svg) 7 | ![Bagel NPM version](https://img.shields.io/npm/v/@kakengloh/bagel) 8 | 9 | **Bagel** is a tiny and expressive web framework for [Bun.js](https://bun.sh/) for building web APIs. 10 | 11 | Inspired by [Express.js](https://expressjs.com/) and [Koa.js](https://koajs.com/). 12 | 13 | Here we treat **Typescript** as first class citizen, hence every request handler supports **generic** and you may specify your own typing of request params, query, body and response body. 14 | 15 | ## Contents 16 | 17 | - [Features](#features) 18 | - [Examples](#examples) 19 | - [Benchmark](#benchmark) 20 | 21 | ## Features 22 | 23 | ✅ Routing 24 | 25 | ✅ Middlewares 26 | 27 | ✅ JSON parsing 28 | 29 | ✅ Strongly typed route handlers 30 | 31 | ## Installation 32 | 33 | ```bash 34 | bun add @kakengloh/bagel 35 | ``` 36 | 37 | ## Examples 38 | 39 | ### Basic 40 | 41 | ```typescript 42 | import { Bagel, Router } from '@kakengloh/bagel'; 43 | 44 | const app = new Bagel(); 45 | 46 | app.get('/', async (req, res) => res.send('Hello from Bagel.js!')); 47 | 48 | app.listen(3000); 49 | ``` 50 | 51 | ### Router 52 | 53 | ```typescript 54 | import { Bagel, Router } from '@kakengloh/bagel'; 55 | 56 | // Create items router 57 | const items = new Router(); 58 | items.get('/', async (req, res) => res.json({ items: [] })); 59 | 60 | // Create v1 router 61 | const v1 = new Router(); 62 | // Mount items router to v1 router 63 | v1.mount('/items', items); 64 | 65 | const app = new Bagel(); 66 | 67 | // Mount v1 router to app 68 | app.mount('/v1', v1); 69 | 70 | app.listen(3000); 71 | ``` 72 | 73 | ### Middleware 74 | 75 | ```typescript 76 | import { Bagel, Router } from '@kakengloh/bagel'; 77 | 78 | const app = new Bagel(); 79 | 80 | // Before middleware 81 | app.use(async (req, res, next) => { 82 | console.log('Before'); 83 | }); 84 | 85 | // Route handler 86 | app.get('/', async (req, res) => res.send('Hello from Bagel.js!')); 87 | 88 | // After middleware 89 | app.use(async (req, res, next) => { 90 | console.log('After'); 91 | }); 92 | 93 | app.listen(3000); 94 | ``` 95 | 96 | ### Strong typing 97 | 98 | ```typescript 99 | import { Bagel, Handler } from '@kakengloh/bagel'; 100 | 101 | // Entity 102 | interface Bread { 103 | bakeryId: string; 104 | name: string; 105 | price: number; 106 | } 107 | 108 | // Path parameters 109 | interface PathParams { 110 | bakeryId: string; 111 | } 112 | 113 | // Query parameters 114 | type QueryParams = Record; 115 | 116 | // Request body 117 | type RequestBody = Bread; 118 | 119 | // Response body 120 | interface ResponseBody { 121 | bread: Bread; 122 | } 123 | 124 | // Route handler with all types specified 125 | const createBread: Handler< 126 | PathParams, 127 | QueryParams, 128 | RequestBody, 129 | ResponseBody 130 | > = async (req, res) => { 131 | const { name, price } = req.body; // Typed inferred 132 | const { bakeryId } = req.params; // Typed inferred 133 | 134 | const bread: Bread = { 135 | bakeryId, 136 | name, 137 | price, 138 | }; 139 | 140 | return res.json({ bread }); // Typed checked 141 | }; 142 | 143 | const app = new Bagel(); 144 | app.post('/bakeries/:bakeryId/breads', createBread); 145 | 146 | app.listen(3000); 147 | ``` 148 | 149 | ### Error handling 150 | 151 | ```typescript 152 | import { Bagel } from '@kakengloh/bagel'; 153 | 154 | const app = new Bagel({ 155 | // Every error thrown will go through this function 156 | // Here you can return a custom response 157 | error: async (res, err) => { 158 | return res.status(400).json({ error: 'Bad request' }); 159 | }, 160 | }); 161 | 162 | app.get('/error', async () => { 163 | throw new Error('Some error'); 164 | }); 165 | 166 | app.listen(3000); 167 | ``` 168 | 169 | ## Benchmark 170 | 171 | Below is a simple benchmark of **Bagel.js** and **Express.js** conducted on my machine using [autocannon](https://github.com/mcollina/autocannon) (12 threads, 500 concurrent connections, 10 seconds) 172 | 173 | > The output shows that Bagel.js can handle ~2.67x more requests than Express.js 174 | 175 | Screenshot 2022-09-09 at 9 19 02 PM 176 | Screenshot 2022-09-09 at 9 15 42 PM 177 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kakengloh/bagel/8f68287c59b932f1472aaa76248c02e11bd4e3dd/bun.lockb -------------------------------------------------------------------------------- /examples/basic.ts: -------------------------------------------------------------------------------- 1 | import { Bagel } from '../src'; 2 | 3 | const app = new Bagel(); 4 | 5 | app.get('/', async (req, res) => res.send('Hello from Bagel.js!')); 6 | 7 | app.listen(3000); 8 | -------------------------------------------------------------------------------- /examples/error.ts: -------------------------------------------------------------------------------- 1 | import { Bagel } from '../src'; 2 | 3 | const app = new Bagel({ 4 | error: async (res, err) => { 5 | return res.status(400).json({ error: 'Bad request' }); 6 | }, 7 | }); 8 | 9 | app.get('/error', async () => { 10 | throw new Error('Some error'); 11 | }); 12 | 13 | app.listen(3000); 14 | -------------------------------------------------------------------------------- /examples/middleware.ts: -------------------------------------------------------------------------------- 1 | import { Bagel } from '../src'; 2 | 3 | const app = new Bagel(); 4 | 5 | app.use(async (req, res, next) => { 6 | console.log('Before'); 7 | }); 8 | 9 | app.get('/', async (req, res) => res.send('Hello from Bagel.js!')); 10 | 11 | app.use(async (req, res, next) => { 12 | console.log('After'); 13 | }); 14 | 15 | app.listen(3000); 16 | -------------------------------------------------------------------------------- /examples/router.ts: -------------------------------------------------------------------------------- 1 | import { Bagel, Router } from '../src'; 2 | 3 | const items = new Router(); 4 | items.get('/', async (req, res) => res.json({ items: [] })); 5 | 6 | const v1 = new Router(); 7 | v1.mount('/items', items); 8 | 9 | const app = new Bagel(); 10 | app.mount('/v1', v1); 11 | 12 | app.listen(3000); 13 | -------------------------------------------------------------------------------- /examples/types.ts: -------------------------------------------------------------------------------- 1 | import { Bagel, Handler } from '../src'; 2 | 3 | // Entity 4 | interface Bread { 5 | bakeryId: string; 6 | name: string; 7 | price: number; 8 | } 9 | 10 | // Path parameters 11 | interface PathParams { 12 | bakeryId: string; 13 | } 14 | 15 | // Query parameters 16 | type QueryParams = Record; 17 | 18 | // Request body 19 | type RequestBody = Bread; 20 | 21 | // Response body 22 | interface ResponseBody { 23 | bread: Bread; 24 | } 25 | 26 | // Route handler with all types specified 27 | const createBread: Handler< 28 | PathParams, 29 | QueryParams, 30 | RequestBody, 31 | ResponseBody 32 | > = async (req, res) => { 33 | const { name, price } = req.body; // Typed inferred 34 | const { bakeryId } = req.params; // Typed inferred 35 | 36 | const bread: Bread = { 37 | bakeryId, 38 | name, 39 | price, 40 | }; 41 | 42 | return res.json({ bread }); // Typed checked 43 | }; 44 | 45 | const app = new Bagel(); 46 | app.post('/bakeries/:bakeryId/breads', createBread); 47 | 48 | app.listen(3000); 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kakengloh/bagel", 3 | "version": "0.2.2", 4 | "author": { 5 | "name": "KaKeng Loh", 6 | "email": "kakengloh@gmail.com", 7 | "url": "https://github.com/kakengloh" 8 | }, 9 | "main": "dist/index.js", 10 | "types": "dist/index.d.ts", 11 | "files": [ 12 | "dist" 13 | ], 14 | "devDependencies": { 15 | "@types/sinon": "^10.0.13", 16 | "@typescript-eslint/eslint-plugin": "^5.36.2", 17 | "@typescript-eslint/parser": "^5.36.2", 18 | "bun-types": "^0.4.0", 19 | "eslint": "^8.23.0", 20 | "prettier": "^2.7.1", 21 | "sinon": "^15.0.1", 22 | "typescript": "^4.8.2" 23 | }, 24 | "description": "Tiny and expressive web framework for Bun.js", 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/kakengloh/bagel" 28 | }, 29 | "keywords": [ 30 | "bun", 31 | "framework", 32 | "express", 33 | "web", 34 | "http", 35 | "rest", 36 | "restful", 37 | "router", 38 | "app", 39 | "api", 40 | "typescript" 41 | ], 42 | "license": "MIT", 43 | "dependencies": { 44 | "path-to-regexp": "^6.2.1" 45 | }, 46 | "scripts": { 47 | "prepublish": "tsc", 48 | "build": "tsc" 49 | } 50 | } -------------------------------------------------------------------------------- /src/bagel.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'bun:test'; 2 | import sinon from 'sinon'; 3 | import { Bagel } from './bagel'; 4 | import { Router } from './router'; 5 | 6 | describe('register', () => { 7 | it('should register a GET / route with 2 handlers', () => { 8 | const app = new Bagel(); 9 | app.register( 10 | 'GET', 11 | '/', 12 | async () => true, 13 | async () => true, 14 | ); 15 | expect(app.routes.length).toBe(1); 16 | expect(app.routes[0].method).toBe('GET'); 17 | expect(app.routes[0].path).toBe('/'); 18 | expect(app.routes[0].handlers.length).toBe(2); 19 | }); 20 | }); 21 | 22 | describe('get', () => { 23 | it('should register a GET / route with 2 handlers', () => { 24 | const app = new Bagel(); 25 | app.get( 26 | '/', 27 | async () => true, 28 | async () => true, 29 | ); 30 | expect(app.routes.length).toBe(1); 31 | expect(app.routes[0].method).toBe('GET'); 32 | expect(app.routes[0].path).toBe('/'); 33 | expect(app.routes[0].handlers.length).toBe(2); 34 | }); 35 | }); 36 | 37 | describe('post', () => { 38 | it('should register a POST / route with 2 handlers', () => { 39 | const app = new Bagel(); 40 | app.post( 41 | '/', 42 | async () => true, 43 | async () => true, 44 | ); 45 | expect(app.routes.length).toBe(1); 46 | expect(app.routes[0].method).toBe('POST'); 47 | expect(app.routes[0].path).toBe('/'); 48 | expect(app.routes[0].handlers.length).toBe(2); 49 | }); 50 | }); 51 | 52 | describe('put', () => { 53 | it('should register a PUT / route with 2 handlers', () => { 54 | const app = new Bagel(); 55 | app.put( 56 | '/', 57 | async () => true, 58 | async () => true, 59 | ); 60 | expect(app.routes.length).toBe(1); 61 | expect(app.routes[0].method).toBe('PUT'); 62 | expect(app.routes[0].path).toBe('/'); 63 | expect(app.routes[0].handlers.length).toBe(2); 64 | }); 65 | }); 66 | 67 | describe('delete', () => { 68 | it('should register a DELETE / route with 2 handlers', () => { 69 | const app = new Bagel(); 70 | app.delete( 71 | '/', 72 | async () => true, 73 | async () => true, 74 | ); 75 | expect(app.routes.length).toBe(1); 76 | expect(app.routes[0].method).toBe('DELETE'); 77 | expect(app.routes[0].path).toBe('/'); 78 | expect(app.routes[0].handlers.length).toBe(2); 79 | }); 80 | }); 81 | 82 | describe('patch', () => { 83 | it('should register a PATCH / route with 2 handlers', () => { 84 | const app = new Bagel(); 85 | app.patch( 86 | '/', 87 | async () => true, 88 | async () => true, 89 | ); 90 | expect(app.routes.length).toBe(1); 91 | expect(app.routes[0].method).toBe('PATCH'); 92 | expect(app.routes[0].path).toBe('/'); 93 | expect(app.routes[0].handlers.length).toBe(2); 94 | }); 95 | }); 96 | 97 | describe('use', () => { 98 | it('should run middlewares', async () => { 99 | const app = new Bagel(); 100 | 101 | const fn = () => null; 102 | 103 | const spy = sinon.spy(fn); 104 | 105 | app.use(async (req, res, next) => { 106 | spy(); 107 | next(); 108 | }); 109 | 110 | app.get('/', async (_, res) => res.send('OK')); 111 | 112 | app.listen(9999); 113 | 114 | const response = await fetch('http://localhost:9999'); 115 | 116 | expect(await response.text()).toBe('OK'); 117 | expect(spy.calledOnce).toBeTruthy(); 118 | 119 | app.stop(); 120 | }); 121 | }); 122 | 123 | describe('options', () => { 124 | it('should register an OPTIONS / route with 2 handlers', () => { 125 | const app = new Bagel(); 126 | app.options( 127 | '/', 128 | async () => true, 129 | async () => true, 130 | ); 131 | expect(app.routes.length).toBe(1); 132 | expect(app.routes[0].method).toBe('OPTIONS'); 133 | expect(app.routes[0].path).toBe('/'); 134 | expect(app.routes[0].handlers.length).toBe(2); 135 | }); 136 | }); 137 | 138 | describe('mount', () => { 139 | it('should mount sub router', () => { 140 | const app = new Bagel(); 141 | 142 | const router = new Router(); 143 | router.get('/items', async () => true); 144 | router.post('/items', async () => true); 145 | 146 | app.mount('/v1', router); 147 | 148 | expect(app.routes.length).toBe(2); 149 | expect(app.routes[0].method).toBe('GET'); 150 | expect(app.routes[0].path).toBe('/v1/items'); 151 | expect(app.routes[0].handlers.length).toBe(1); 152 | 153 | expect(app.routes[1].method).toBe('POST'); 154 | expect(app.routes[1].path).toBe('/v1/items'); 155 | expect(app.routes[1].handlers.length).toBe(1); 156 | }); 157 | 158 | it('should call middlewares for subrouter', async () => { 159 | const app = new Bagel(); 160 | const spy = sinon.spy(() => null); 161 | 162 | app.use(async (req, res, next) => { 163 | spy(); 164 | next(); 165 | }); 166 | 167 | const router = new Router(); 168 | router.get('/items', async (_, res) => res.send('OK')); 169 | router.get('/users', async (_, res) => res.send('OK')); 170 | app.mount('/v1', router); 171 | app.listen(9007); 172 | 173 | const response = await fetch('http://localhost:9007/v1/items'); 174 | 175 | expect(response.status).toBe(200); 176 | expect(await response.text()).toBe('OK'); 177 | expect(spy.calledOnce).toBeTruthy(); 178 | 179 | app.stop(); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /src/bagel.ts: -------------------------------------------------------------------------------- 1 | import { Errorlike, ServeOptions, Server } from 'bun'; 2 | import { Router } from './router'; 3 | import { AnyHandler, BagelRequest, Handler, Method } from './request'; 4 | import { BagelResponse } from './response'; 5 | import { normalizeURLPath } from './utils/common'; 6 | import { Route } from './route'; 7 | import * as logger from './utils/logger'; 8 | 9 | type ListenCallback = (err?: Errorlike) => void; 10 | 11 | interface BagelOptions { 12 | error?: (res: BagelResponse, err: Errorlike) => Promise; 13 | } 14 | 15 | export class Bagel { 16 | private readonly opts: BagelOptions; 17 | public readonly routes: Route[]; 18 | private readonly middlewares: Handler[]; 19 | private server?: Server; 20 | 21 | constructor(opts: BagelOptions = {}) { 22 | this.opts = opts; 23 | this.routes = []; 24 | this.middlewares = []; 25 | } 26 | 27 | use(...middlewares: AnyHandler[]): Bagel { 28 | // Store middlewares 29 | this.middlewares.push(...middlewares); 30 | 31 | // Append middlewares to existing routes 32 | this.routes.forEach((route) => { 33 | route.addHandlers(...middlewares); 34 | }); 35 | 36 | return this; 37 | } 38 | 39 | get(path: string, ...handlers: AnyHandler[]): Bagel { 40 | this.register('GET', path, ...this.middlewares, ...handlers); 41 | return this; 42 | } 43 | 44 | post(path: string, ...handlers: AnyHandler[]): Bagel { 45 | this.register('POST', path, ...this.middlewares, ...handlers); 46 | return this; 47 | } 48 | 49 | put(path: string, ...handlers: AnyHandler[]): Bagel { 50 | this.register('PUT', path, ...this.middlewares, ...handlers); 51 | return this; 52 | } 53 | 54 | delete(path: string, ...handlers: AnyHandler[]): Bagel { 55 | this.register('DELETE', path, ...this.middlewares, ...handlers); 56 | return this; 57 | } 58 | 59 | patch(path: string, ...handlers: AnyHandler[]): Bagel { 60 | this.register('PATCH', path, ...this.middlewares, ...handlers); 61 | return this; 62 | } 63 | 64 | options(path: string, ...handlers: AnyHandler[]): Bagel { 65 | this.register('OPTIONS', path, ...this.middlewares, ...handlers); 66 | return this; 67 | } 68 | 69 | register(method: Method, path: string, ...handlers: AnyHandler[]): Bagel { 70 | this.routes.push(new Route(method, path, handlers)); 71 | return this; 72 | } 73 | 74 | mount(prefix: string, router: Router): Bagel { 75 | router.routes.forEach((route) => { 76 | this.register( 77 | route.method, 78 | normalizeURLPath(prefix + route.path), 79 | ...this.middlewares, 80 | ...route.handlers, 81 | ); 82 | }); 83 | 84 | return this; 85 | } 86 | 87 | listen(port: number, callback?: ListenCallback) { 88 | const fetch: ServeOptions['fetch'] = async (req) => { 89 | const { pathname } = new URL(req.url); 90 | 91 | // Find matching route 92 | const route = this.routes.find((route) => 93 | route.match(req.method as Method, pathname), 94 | ); 95 | 96 | // Return 404 if there is no handlers for the route 97 | if (!route) { 98 | return new Response('Not found', { status: 404 }); 99 | } 100 | 101 | const { handlers } = route; 102 | 103 | // Construct Bagel Request from Bun Request 104 | const bagelRequest = await BagelRequest.from(req, route.params(pathname)); 105 | // Initialize Bagel Response instance 106 | const bagelResponse = new BagelResponse(); 107 | 108 | // Execute endpoint handlers 109 | let index = 0; 110 | while (index < handlers.length) { 111 | const next = async () => { 112 | const handler = handlers[index++]; 113 | if (!handler) return; 114 | await handler(bagelRequest, bagelResponse, next); 115 | }; 116 | 117 | await next(); 118 | } 119 | 120 | return bagelResponse.done(); 121 | }; 122 | 123 | const error: ServeOptions['error'] = (err: Errorlike) => { 124 | logger.error(err); 125 | 126 | // Default status 500 127 | const bagelResponse = new BagelResponse().status(500); 128 | bagelResponse.send(''); 129 | 130 | // Run custom error function if exists 131 | if (this.opts.error) { 132 | this.opts.error?.(bagelResponse, err); 133 | return bagelResponse.done(); 134 | } 135 | 136 | return bagelResponse.done(); 137 | }; 138 | 139 | this.server = Bun.serve({ 140 | port, 141 | fetch, 142 | error, 143 | }); 144 | 145 | logger.info(`Bun is running on port ${port} (Press CTRL+C to quit)`); 146 | 147 | callback?.(); 148 | } 149 | 150 | stop() { 151 | if (!this.server) { 152 | console.warn('Server has not started yet'); 153 | return; 154 | } 155 | this.server.stop(); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './bagel'; 2 | export * from './router'; 3 | export * from './request'; 4 | export * from './response'; 5 | -------------------------------------------------------------------------------- /src/request.ts: -------------------------------------------------------------------------------- 1 | import { normalizeURLPath } from './utils/common'; 2 | import { BagelResponse } from './response'; 3 | 4 | export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS'; 5 | 6 | export type Handler< 7 | TPathParams = Record, 8 | TQueryParams = Record, 9 | TRequestBody = Record, 10 | TResponseBody = Record, 11 | > = ( 12 | req: BagelRequest, 13 | res: BagelResponse, 14 | next: Next, 15 | ) => Promise; 16 | 17 | export type AnyHandler = Handler; 18 | 19 | export type Next = () => Promise; 20 | 21 | export interface BagelRequestConstructor< 22 | TPathParams, 23 | TQueryParams, 24 | TBodyParams, 25 | > { 26 | method: Method; 27 | path: string; 28 | url: string; 29 | headers: Record; 30 | params: TPathParams; 31 | query: TQueryParams; 32 | body: TBodyParams; 33 | } 34 | 35 | export class BagelRequest< 36 | TPathParams = Record, 37 | TQueryParams = Record, 38 | TBodyParams = Record, 39 | > { 40 | public readonly method: Method; 41 | public readonly path: string; 42 | public readonly url: string; 43 | public readonly headers: Record; 44 | public readonly params: TPathParams; 45 | public readonly query: TQueryParams; 46 | public readonly body: TBodyParams; 47 | 48 | constructor( 49 | options: BagelRequestConstructor, 50 | ) { 51 | this.method = options.method; 52 | this.path = options.path; 53 | this.url = options.url; 54 | this.headers = options.headers; 55 | this.params = options.params; 56 | this.query = options.query; 57 | this.body = options.body; 58 | } 59 | 60 | static async from(req: Request, params: object): Promise { 61 | const { searchParams, pathname } = new URL(req.url); 62 | 63 | const body = await req.json(); 64 | 65 | if (body instanceof Error) { 66 | throw body; 67 | } 68 | 69 | return new BagelRequest({ 70 | method: req.method as Method, 71 | path: normalizeURLPath(pathname), 72 | url: req.url, 73 | query: Object.fromEntries(searchParams.entries()), 74 | params, 75 | body: body as Record, 76 | headers: Object.fromEntries(req.headers.entries()), 77 | }); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/response.ts: -------------------------------------------------------------------------------- 1 | export class BagelResponse> { 2 | private opts: ResponseInit; 3 | private response: Response | undefined; 4 | 5 | constructor() { 6 | this.opts = {}; 7 | } 8 | 9 | done() { 10 | return this.response ?? new Response(''); 11 | } 12 | 13 | status(code: number): BagelResponse { 14 | this.opts.status = code; 15 | return this; 16 | } 17 | 18 | option(opts: ResponseInit): BagelResponse { 19 | this.opts = opts; 20 | return this; 21 | } 22 | 23 | setHeader(key: string, value: any): BagelResponse { 24 | this.opts.headers = (this.opts.headers || {}) as Record; 25 | this.opts.headers[key] = value; 26 | return this; 27 | } 28 | 29 | json(body: TResponseBody): void { 30 | if (this.response) { 31 | throw new Error('Response is already set'); 32 | } 33 | this.response = Response.json(body, this.opts); 34 | } 35 | 36 | send(body: string): void { 37 | if (this.response) { 38 | throw new Error('Response is already set'); 39 | } 40 | this.response = new Response(body, this.opts); 41 | } 42 | 43 | sendStatus(code: number): void { 44 | if (this.response) { 45 | throw new Error('Response is already set'); 46 | } 47 | 48 | this.response = new Response('OK', { 49 | ...this.opts, 50 | status: code, 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/route.ts: -------------------------------------------------------------------------------- 1 | import { pathToRegexp, match } from 'path-to-regexp'; 2 | import { Handler, Method } from './request'; 3 | 4 | export class Route { 5 | constructor( 6 | public readonly method: Method, 7 | public readonly path: string, 8 | public readonly handlers: Handler[], 9 | ) { 10 | match(path); 11 | } 12 | 13 | addHandlers(...handlers: Handler[]) { 14 | this.handlers.push(...handlers); 15 | } 16 | 17 | match(method: Method, path: string): boolean { 18 | if (this.method !== method) { 19 | return false; 20 | } 21 | 22 | return pathToRegexp(this.path).test(path); 23 | } 24 | 25 | params(path: string) { 26 | const fn = match(this.path, { decode: decodeURIComponent }); 27 | const result = fn(path); 28 | 29 | if (typeof result === 'boolean' || !('params' in result)) { 30 | throw new Error('Failed to parse path params'); 31 | } 32 | 33 | return result.params; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/router.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'bun:test'; 2 | import { Router } from './router'; 3 | import { Bagel } from './bagel'; 4 | 5 | describe('register', () => { 6 | it('should register a GET / route with 2 handlers', () => { 7 | const router = new Router(); 8 | router.register( 9 | 'GET', 10 | '/', 11 | async () => true, 12 | async () => true, 13 | ); 14 | expect(router.routes.length).toBe(1); 15 | expect(router.routes[0].method).toBe('GET'); 16 | expect(router.routes[0].path).toBe('/'); 17 | expect(router.routes[0].handlers.length).toBe(2); 18 | }); 19 | }); 20 | 21 | describe('get', () => { 22 | it('should register a GET / route with 2 handlers', () => { 23 | const router = new Router(); 24 | router.get( 25 | '/', 26 | async () => true, 27 | async () => true, 28 | ); 29 | expect(router.routes.length).toBe(1); 30 | expect(router.routes[0].method).toBe('GET'); 31 | expect(router.routes[0].path).toBe('/'); 32 | expect(router.routes[0].handlers.length).toBe(2); 33 | }); 34 | }); 35 | 36 | describe('post', () => { 37 | it('should register a POST / route with 2 handlers', () => { 38 | const router = new Router(); 39 | router.post( 40 | '/', 41 | async () => true, 42 | async () => true, 43 | ); 44 | expect(router.routes.length).toBe(1); 45 | expect(router.routes[0].method).toBe('POST'); 46 | expect(router.routes[0].path).toBe('/'); 47 | expect(router.routes[0].handlers.length).toBe(2); 48 | }); 49 | }); 50 | 51 | describe('put', () => { 52 | it('should register a PUT / route with 2 handlers', () => { 53 | const router = new Router(); 54 | router.put( 55 | '/', 56 | async () => true, 57 | async () => true, 58 | ); 59 | expect(router.routes.length).toBe(1); 60 | expect(router.routes[0].method).toBe('PUT'); 61 | expect(router.routes[0].path).toBe('/'); 62 | expect(router.routes[0].handlers.length).toBe(2); 63 | }); 64 | }); 65 | 66 | describe('delete', () => { 67 | it('should register a DELETE / route with 2 handlers', () => { 68 | const router = new Router(); 69 | router.delete( 70 | '/', 71 | async () => true, 72 | async () => true, 73 | ); 74 | expect(router.routes.length).toBe(1); 75 | expect(router.routes[0].method).toBe('DELETE'); 76 | expect(router.routes[0].path).toBe('/'); 77 | expect(router.routes[0].handlers.length).toBe(2); 78 | }); 79 | }); 80 | 81 | describe('patch', () => { 82 | it('should register a PATCH / route with 2 handlers', () => { 83 | const router = new Router(); 84 | router.patch( 85 | '/', 86 | async () => true, 87 | async () => true, 88 | ); 89 | expect(router.routes.length).toBe(1); 90 | expect(router.routes[0].method).toBe('PATCH'); 91 | expect(router.routes[0].path).toBe('/'); 92 | expect(router.routes[0].handlers.length).toBe(2); 93 | }); 94 | }); 95 | 96 | describe('options', () => { 97 | it('should register a PATCH / route with 2 handlers', () => { 98 | const router = new Router(); 99 | router.options( 100 | '/', 101 | async () => true, 102 | async () => true, 103 | ); 104 | expect(router.routes.length).toBe(1); 105 | expect(router.routes[0].method).toBe('OPTIONS'); 106 | expect(router.routes[0].path).toBe('/'); 107 | expect(router.routes[0].handlers.length).toBe(2); 108 | }); 109 | }); 110 | 111 | describe('mount', () => { 112 | const router = new Router(); 113 | router.get('/', async () => 'OK'); 114 | 115 | const itemsRouter = new Router(); 116 | itemsRouter.get('/', async () => true); 117 | 118 | router.mount('/items', itemsRouter); 119 | 120 | expect(router.routes.length).toBe(2); 121 | expect(router.routes[0].method).toBe('GET'); 122 | expect(router.routes[0].path).toBe('/'); 123 | expect(router.routes[0].handlers.length).toBe(1); 124 | 125 | expect(router.routes[1].method).toBe('GET'); 126 | expect(router.routes[1].path).toBe('/items'); 127 | expect(router.routes[1].handlers.length).toBe(1); 128 | }); 129 | 130 | describe('listen', () => { 131 | it('should return response text', async () => { 132 | const app = new Bagel(); 133 | app.get('/', async (req, res) => res.sendStatus(200)); 134 | app.listen(9001); 135 | 136 | const response = await fetch('http://localhost:9001'); 137 | expect(response.status).toBe(200); 138 | const text = await response.text(); 139 | expect(text).toBe('OK'); 140 | 141 | app.stop(); 142 | }); 143 | 144 | it('should return response JSON', async () => { 145 | const app = new Bagel(); 146 | app.get('/', async (req, res) => res.json({ hello: 'world' })); 147 | app.listen(9002); 148 | 149 | const response = await fetch('http://localhost:9002'); 150 | expect(response.status).toBe(200); 151 | const json = await response.json>(); 152 | expect(json.hello).toBe('world'); 153 | 154 | app.stop(); 155 | }); 156 | 157 | it('should parse path params', async () => { 158 | const app = new Bagel(); 159 | app.get('/path/:var', async (req, res) => res.json(req.params)); 160 | app.listen(9003); 161 | 162 | const response = await fetch('http://localhost:9003/path/hello'); 163 | expect(response.status).toBe(200); 164 | const json = await response.json>(); 165 | expect(json.var).toBe('hello'); 166 | 167 | app.stop(); 168 | }); 169 | 170 | it('should parse query params', async () => { 171 | const app = new Bagel(); 172 | app.get('/', async (req, res) => res.json(req.query)); 173 | app.listen(9004); 174 | 175 | const response = await fetch('http://localhost:9004/?a=1&b=2'); 176 | expect(response.status).toBe(200); 177 | const json = await response.json>(); 178 | expect(json.a).toBe('1'); 179 | expect(json.b).toBe('2'); 180 | 181 | app.stop(); 182 | }); 183 | 184 | it('should parse json body', async () => { 185 | const app = new Bagel(); 186 | app.post('/', async (req, res) => res.json(req.body)); 187 | app.listen(9005); 188 | 189 | const response = await fetch('http://localhost:9005', { 190 | method: 'POST', 191 | body: JSON.stringify({ hello: 'world' }), 192 | }); 193 | 194 | expect(response.status).toBe(200); 195 | 196 | const json = await response.json>(); 197 | expect(json.hello).toBe('world'); 198 | 199 | app.stop(); 200 | }); 201 | 202 | it('should return response with 500 status code', async () => { 203 | const app = new Bagel(); 204 | app.get('/', async () => { 205 | throw new Error('error'); 206 | }); 207 | app.listen(9006); 208 | 209 | const response = await fetch('http://localhost:9006'); 210 | expect(response.status).toBe(500); 211 | 212 | app.stop(); 213 | }); 214 | }); 215 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import { Route } from './route'; 2 | import { AnyHandler, Handler, Method } from './request'; 3 | import { normalizeURLPath } from './utils/common'; 4 | 5 | export class Router { 6 | public readonly routes: Route[]; 7 | private readonly middlewares: Handler[]; 8 | 9 | constructor() { 10 | this.routes = []; 11 | this.middlewares = []; 12 | } 13 | 14 | use(...middlewares: AnyHandler[]): Router { 15 | // Store middlewares 16 | this.middlewares.push(...middlewares); 17 | 18 | // Append middlewares to existing routes 19 | this.routes.forEach((route) => { 20 | route.addHandlers(...middlewares); 21 | }); 22 | 23 | return this; 24 | } 25 | 26 | get(path: string, ...handlers: AnyHandler[]): Router { 27 | this.register('GET', path, ...this.middlewares, ...handlers); 28 | return this; 29 | } 30 | 31 | post(path: string, ...handlers: AnyHandler[]): Router { 32 | this.register('POST', path, ...this.middlewares, ...handlers); 33 | return this; 34 | } 35 | 36 | put(path: string, ...handlers: AnyHandler[]): Router { 37 | this.register('PUT', path, ...this.middlewares, ...handlers); 38 | return this; 39 | } 40 | 41 | delete(path: string, ...handlers: AnyHandler[]): Router { 42 | this.register('DELETE', path, ...this.middlewares, ...handlers); 43 | return this; 44 | } 45 | 46 | patch(path: string, ...handlers: AnyHandler[]): Router { 47 | this.register('PATCH', path, ...this.middlewares, ...handlers); 48 | return this; 49 | } 50 | 51 | options(path: string, ...handlers: AnyHandler[]): Router { 52 | this.register('OPTIONS', path, ...this.middlewares, ...handlers); 53 | return this; 54 | } 55 | 56 | register(method: Method, path: string, ...handlers: AnyHandler[]) { 57 | this.routes.push(new Route(method, path, handlers)); 58 | } 59 | 60 | mount(prefix: string, router: Router): Router { 61 | router.routes.forEach((route) => { 62 | this.register( 63 | route.method, 64 | normalizeURLPath(prefix + route.path), 65 | ...this.middlewares, 66 | ...route.handlers, 67 | ); 68 | }); 69 | 70 | return this; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/utils/common.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'bun:test'; 2 | import { normalizeURLPath } from './common'; 3 | 4 | describe('normalizeURLPath', () => { 5 | const data = [ 6 | { path: '/', expected: '/' }, 7 | { path: '/items', expected: '/items' }, 8 | { path: '/items/', expected: '/items' }, 9 | { path: 'items/', expected: '/items' }, 10 | { path: '//items/', expected: '/items' }, 11 | { path: '/items//', expected: '/items' }, 12 | ]; 13 | 14 | data.forEach((entry) => { 15 | it(`normalize "${entry.path}" to "${entry.expected}"`, () => { 16 | const result = normalizeURLPath(entry.path); 17 | expect(result).toBe(entry.expected); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/utils/common.ts: -------------------------------------------------------------------------------- 1 | export const normalizeURLPath = (text: string): string => { 2 | // Prepend slash 3 | if (text[0] !== '/') { 4 | text = '/' + text; 5 | } 6 | 7 | if (text === '/') { 8 | return text; 9 | } 10 | 11 | // Remove trailing slash 12 | return decodeURI(text).replace(/\/+/g, '/').replace(/\/+$/, ''); 13 | }; 14 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | export const info = (...contents: unknown[]) => { 2 | console.info('INFO:', ...contents); 3 | }; 4 | 5 | export const error = (...contents: unknown[]) => { 6 | console.error('ERROR:', ...contents); 7 | }; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | /* Projects */ 5 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 6 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 7 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 8 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 9 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 10 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 11 | /* Language and Environment */ 12 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 13 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 14 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 15 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 16 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 17 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 18 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 19 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 20 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 21 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 22 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 23 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 24 | /* Modules */ 25 | "module": "commonjs", /* Specify what module code is generated. */ 26 | // "rootDir": "./", /* Specify the root folder within your source files. */ 27 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 28 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 29 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 30 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 31 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 32 | "types": [ 33 | "bun-types" 34 | ], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 37 | // "resolveJsonModule": true, /* Enable importing .json files. */ 38 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 43 | /* Emit */ 44 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 45 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 46 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 47 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 48 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 49 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 50 | // "removeComments": true, /* Disable emitting comments. */ 51 | // "noEmit": true, /* Disable emitting files from a compilation. */ 52 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 53 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 54 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 55 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 56 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 57 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 58 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 59 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 60 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 61 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 62 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 63 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 64 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 65 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 66 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 67 | /* Interop Constraints */ 68 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 69 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 70 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 71 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 72 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 73 | /* Type Checking */ 74 | "strict": true, /* Enable all strict type-checking options. */ 75 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 76 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 77 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 78 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 79 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 80 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 81 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 82 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 83 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 84 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 85 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 86 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 87 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 88 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 89 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 90 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 91 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 92 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 93 | /* Completeness */ 94 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 95 | "skipLibCheck": true, /* Skip type checking all .d.ts files. */ 96 | }, 97 | "exclude": [ 98 | "node_modules", 99 | "dist", 100 | "examples", 101 | "**/*.spec.ts" 102 | ] 103 | } --------------------------------------------------------------------------------