├── .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 |
3 | Bagel
4 |
5 |
6 | 
7 | 
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 |
176 |
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 | }
--------------------------------------------------------------------------------