bun-router 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,7 +56,7 @@ r.serve();
56
56
 
57
57
  **SQLite**
58
58
  ```ts
59
- import { router, json } from '..';
59
+ import { router, json } from 'bun-router';
60
60
  import { Database } from 'bun:sqlite';
61
61
 
62
62
  const r = router(3000, {db: './examples/dbs/test.db'});
package/examples/basic.ts CHANGED
@@ -6,13 +6,8 @@ r.add('/', 'GET', () => http.json(200, 'ok'));
6
6
 
7
7
  r.add('/user/:name', 'GET', (ctx) => {
8
8
  const name = ctx.params.get('name');
9
+ if (!name) return http.json(500, 'no name');
9
10
  return http.json(200, name);
10
11
  });
11
12
 
12
- r.add('/user/:name/:id', 'GET', (ctx) => {
13
- const name = ctx.params.get('name');
14
- const id = ctx.params.get('id');
15
- return http.json(200, {name: name, id: id});
16
- });
17
-
18
13
  r.serve();
@@ -1,29 +1,24 @@
1
1
  import { router, http } from '..';
2
2
  import { Context } from '../lib/router/router.d';
3
3
 
4
- const handler = (ctx: Context) => {
5
- const name = ctx.params.get('name');
6
- if (typeof name === 'undefined' || name === '') return http.html(500, '<h6 style="color: red">User Undefined</h6>');
7
- return http.html(200, `<h4>Hello, ${name}!</h4>`);
8
- }
4
+ const home = (ctx: Context) => new Response('Welcome Home', { status: 200 });
9
5
 
10
- const space = (ctx: Context) => {
11
- const name = ctx.params.get('name');
12
- if (typeof name === 'undefined' || name === '') return http.html(404, `<h6 style="color: red">Space [${name}] Not Found</h6>`);
13
- return http.html(200, `<h4>Welcome to ${name}!`)
6
+ const subreddit = (ctx: Context) => {
7
+ const sub = ctx.params.get('subreddit');
8
+ if (!sub) return http.json(400, { error: 'no subreddit provided' });
9
+ return http.json(200, { subreddit: sub });
14
10
  }
15
11
 
16
- const handleSettings = (ctx: Context) => {
17
- const name = ctx.params.get('name');
18
- if (typeof name === 'undefined' || name === '') return http.html(404, `<h6 style="color: red">User Not Found</h6>`);
19
- return http.html(200, `<h4>Settings for ${name}</h4>`)
12
+ const user = (ctx: Context) => {
13
+ const user = ctx.params.get('user');
14
+ if (!user) return http.json(400, { error: 'no user provided' });
15
+ return http.json(200, { user: user });
20
16
  }
21
17
 
22
18
  const r = router();
23
19
 
24
- r.add('/u/:name', 'GET', handler);
25
- r.add('/s/:name', 'GET', space);
26
- r.add('/u/:name/settings', 'GET', handleSettings);
27
-
20
+ r.add('/', 'GET', home);
21
+ r.add('/r/:subreddit', 'GET', subreddit);
22
+ r.add('/u/:user', 'GET', user);
28
23
 
29
24
  r.serve();
@@ -10,8 +10,7 @@ const http = {
10
10
  }));
11
11
  },
12
12
  html: async (statusCode: number, content: string): Promise<Response> => {
13
- content = Bun.escapeHTML(content);
14
- return Promise.resolve(new Response(Bun.escapeHTML(content), {
13
+ return Promise.resolve(new Response(content, {
15
14
  status: statusCode,
16
15
  statusText: httpStatusCodes[statusCode],
17
16
  headers: {'Content-Type': 'text/html; charset=utf-8'}
@@ -50,6 +49,15 @@ const http = {
50
49
 
51
50
  return Promise.resolve(response);
52
51
  },
52
+ methodNotAllowed: async (msg?: string): Promise<Response> => {
53
+ const response = new Response(msg ?? 'method not allowed', {
54
+ status: 405,
55
+ statusText: httpStatusCodes[405],
56
+ headers: {'Content-Type': 'text/html'},
57
+ });
58
+
59
+ return Promise.resolve(response);
60
+ },
53
61
  message: async (status: number, msg?: string): Promise<Response> => {
54
62
  const response = new Response(msg ?? '?', {
55
63
  status: status,
@@ -20,7 +20,7 @@ const Colors: Record<string,string> = {
20
20
  bgMagenta: "\x1b[45m",
21
21
  bgCyan: "\x1b[46m",
22
22
  bgWhite: "\x1b[47m",
23
- };
23
+ } as const;
24
24
 
25
25
 
26
26
  const color = (c: string, bkg: string, msg: string) => {
@@ -3,6 +3,14 @@ import {Logger} from './logger.d';
3
3
 
4
4
  const pad = (n: number) => String(n).padStart(2, '0');
5
5
 
6
+ const TITLE = `
7
+ _ _
8
+ | |_ _ _ ___ ___ ___ _ _| |_ ___ ___
9
+ | . | | | | | _| . | | | _| -_| _|
10
+ |___|___|_|_| |_| |___|___|_| |___|_|
11
+
12
+ `
13
+
6
14
  const timestamp = (date: Date) => {
7
15
  const month = pad(date.getMonth());
8
16
  const day = pad(date.getDate());
@@ -48,6 +56,7 @@ const logger = (): Logger => {
48
56
  const portColor = color('green', 'bgBlack', String(port));
49
57
  const msg = `${source}: Starting Server on :${portColor}\n`;
50
58
 
59
+ await Bun.write(Bun.stdout, TITLE);
51
60
  await Bun.write(Bun.stdout, msg);
52
61
  },
53
62
  info: async (statusCode: number, routePath: string, method: string, message?: string) => {
@@ -2,25 +2,28 @@ import { TLSOptions, TLSWebSocketServeOptions, WebSocketServeOptions, ServeOptio
2
2
  import { Logger } from '../logger/logger';
3
3
  import { Database } from 'bun:sqlite';
4
4
 
5
+ type HttpHandler = (ctx: Context) => Response | Promise<Response>
5
6
 
6
7
  type Context = {
7
- cookies: Map<string, string>,
8
- db: Database,
9
- formData: FormData | Promise<FormData> | undefined,
10
- json: (statusCode: number, data: any) => Response | Promise<Response>,
11
- logger: Logger,
12
- params: Map<string, string>,
13
- query: URLSearchParams,
14
- request: Request,
15
- route: Route,
16
- token?: string,
8
+ cookies: Map<string, string>;
9
+ db?: Database;
10
+ formData: FormData | Promise<FormData> | undefined;
11
+ json: (statusCode: number, data: any) => Response | Promise<Response>;
12
+ logger: Logger;
13
+ params: Map<string, string>;
14
+ query: URLSearchParams;
15
+ request: Request;
16
+ token?: string;
17
17
  };
18
18
 
19
19
 
20
20
  type Route = {
21
- pattern: string,
22
- method: string,
23
- callback: (req: Context) => Response | Promise<Response>
21
+ children: Map<string, Route>;
22
+ path: string;
23
+ dynamicPath: string;
24
+ method: string;
25
+ handler: HttpHandler;
26
+ isLast: boolean;
24
27
  }
25
28
 
26
29
  type Options = {
@@ -35,13 +38,11 @@ type RouterOptions<Options> = ServeOptions
35
38
 
36
39
 
37
40
  type Router = (port?: number | string, options?: RouterOptions) => {
38
- add: (pattern: string, method: string, callback: (req: Context) => Response | Promise<Response>) => void,
39
- GET: (pattern: string, callback: (ctx: Context) => Response | Promise<Response>) => void,
40
- POST: (pattern: string, callback: (ctx: Context) => Response | Promise<Response>) => void,
41
- static: (pattern: string, root: string) => void,
42
- serve: () => void,
41
+ add: (pattern: string, method: string, callback: (req: Context) => Response | Promise<Response>) => void;
42
+ static: (pattern: string, root: string) => void;
43
+ serve: () => void;
43
44
  }
44
45
 
45
46
 
46
47
 
47
- export { Context , Route, Router, RouterOptions, Options }
48
+ export { Context , Route, Router, RouterOptions, Options, HttpHandler }
@@ -1,17 +1,15 @@
1
1
  import path from 'path';
2
2
  import { Database } from 'bun:sqlite';
3
- import { Route, Router, Context, RouterOptions, Options } from './router.d';
3
+ import { Route, Router, Context, RouterOptions, Options, HttpHandler } from './router.d';
4
4
  import { httpStatusCodes } from '../http/status';
5
5
  import { readDir } from '../fs/fsys';
6
6
  import { logger } from '../logger/logger';
7
- import { Logger } from '../logger/logger.d';
8
7
  import { http } from '../http/generic-methods';
8
+ import {Radix, createContext} from './tree';
9
9
 
10
- // extract dynamic URL parameters
11
- // if the route pattern is /:foo and the request URL is /bar: {foo: 'bar'}
12
- const extract = (route: Route, ctx: Context) => {
10
+ const extract = (path: string, ctx: Context) => {
13
11
  const url = new URL(ctx.request.url);
14
- const pathSegments = route.pattern.split('/');
12
+ const pathSegments = path.split('/');
15
13
  const urlSegments = url.pathname.split('/');
16
14
 
17
15
  if (pathSegments.length !== urlSegments.length) return
@@ -19,10 +17,10 @@ const extract = (route: Route, ctx: Context) => {
19
17
  return {
20
18
  params: () => {
21
19
  for (let i = 0; i < pathSegments.length; i++) {
22
- if ((pathSegments[i][0] === ':')) {
20
+ if((pathSegments[i][0] === ':')) {
23
21
  const k = pathSegments[i].replace(':', '');
24
22
  const v = urlSegments[i];
25
- ctx.params.set(k, v);
23
+ ctx.params.set(k,v);
26
24
  }
27
25
  }
28
26
  }
@@ -30,67 +28,16 @@ const extract = (route: Route, ctx: Context) => {
30
28
 
31
29
  }
32
30
 
33
- // ensure the route pattern matches the request URL
34
- const match = (route: Route, ctx: Context): boolean => {
35
- const url = new URL(ctx.request.url);
36
- const patternRegex = new RegExp('^' + route.pattern.replace(/:[^/]+/g, '([^/]+)') + '$');
37
- const matches = url.pathname.match(patternRegex);
38
-
39
- if (matches && route.method === ctx.request.method) {
40
- const extractor = extract(route, ctx);
41
- extractor?.params();
42
-
43
- return true;
44
- }
45
-
46
- return false;
47
- }
48
-
49
- // set the context for the reuest
50
- const setContext = (req: Request, lgr: Logger, opts: Options, route: Route): Context => {
51
- const token = req.headers.get('Authorization');
52
- return {
53
- token: token ?? '',
54
- cookies: new Map(),
55
- formData: req.formData(),
56
- request: req,
57
- params: new Map(),
58
- query: new URL(req.url).searchParams,
59
- db: new Database(opts.db ?? ':memory:'),
60
- logger: lgr,
61
- route: route,
62
- json: (statusCode: number, data: any) => http.json(statusCode, data),
63
- }
64
- }
65
-
66
31
  const router: Router = (port?: number | string, options?: RouterOptions<Options>) => {
67
- const routes: Array<Route> = new Array();
32
+ const {addRoute, findRoute} = Radix();
68
33
  const lgr = logger();
69
34
 
70
35
  return {
71
- // add a new route
72
- add: (pattern: string, method: string, callback: (ctx: Context) => Response | Promise<Response>) => {
73
- routes.push({
74
- pattern: pattern,
75
- method: method,
76
- callback: callback,
77
- })
78
- },
79
- GET: (pattern: string, callback: (ctx: Context) => Response | Promise<Response>) => {
80
- routes.push({
81
- pattern: pattern,
82
- method: 'GET',
83
- callback: callback,
84
- });
85
- },
86
- POST: (pattern: string, callback: (ctx: Context) => Response | Promise<Response>) => {
87
- routes.push({
88
- pattern: pattern,
89
- method: 'POST',
90
- callback: callback,
91
- });
36
+ // add a route to the router tree
37
+ add: (pattern: string, method: string, callback: HttpHandler) => {
38
+ addRoute(pattern, method, callback);
92
39
  },
93
- // add a route for static files
40
+ // add a static route to the router tree
94
41
  static: async (pattern: string, root: string) => {
95
42
  await readDir(root, async (fp, _) => {
96
43
  const pure = path.join('.', fp);
@@ -107,12 +54,15 @@ const router: Router = (port?: number | string, options?: RouterOptions<Options>
107
54
  if (base === 'index') patternPath = pattern;
108
55
 
109
56
  const route: Route = {
110
- pattern: patternPath,
57
+ children: new Map(),
58
+ dynamicPath: '',
59
+ isLast: true,
60
+ path: patternPath,
111
61
  method: 'GET',
112
- callback: async () => await http.file(200, pure),
62
+ handler: async () => await http.file(200, pure),
113
63
  };
114
64
 
115
- routes.push(route);
65
+ addRoute(route.path, 'GET', route.handler);
116
66
  });
117
67
  },
118
68
  // start the server
@@ -120,52 +70,44 @@ const router: Router = (port?: number | string, options?: RouterOptions<Options>
120
70
  lgr.start(port ?? 3000);
121
71
  let opts: Options = { db: ':memory:' };
122
72
 
73
+ // TODO: add support for TLS and WebSockets
123
74
  Bun.serve({
124
75
  port: port ?? 3000,
125
76
  ...options,
126
77
  async fetch(req) {
127
78
  const url = new URL(req.url);
79
+ let path = url.pathname;
128
80
 
81
+ // set the database
129
82
  if (options) {
130
83
  let o = options as Options;
131
84
  opts.db = o.db;
132
85
  }
133
86
 
134
- let statusCode = 404;
87
+ const route = findRoute(path);
135
88
 
136
- for (const route of routes) {
137
- const ctx = setContext(req, lgr, opts, route);
138
-
139
- if (match(route, ctx) || route.pattern === url.pathname) {
140
- if (route.method === ctx.request.method) {
141
- const res = await route.callback(ctx);
89
+ // if the route exists, execute the handler
90
+ if (route) {
91
+ if (route.method !== req.method) {
92
+ lgr.info(405, url.pathname, req.method, httpStatusCodes[405]);
93
+ return Promise.resolve(http.methodNotAllowed());
94
+ }
142
95
 
143
- let cookieValue: string[] = [];
144
- if (ctx.cookies.size !== 0) {
145
- for (const [key, value] of ctx.cookies) {
146
- cookieValue.push(`${key}=${value}`);
147
- }
148
- }
96
+ const context = createContext(path, route, req);
97
+ context.db = new Database(opts.db);
149
98
 
150
- res.headers.set('Set-Cookie', cookieValue.join('; '));
99
+ const response = await route.handler(context);
151
100
 
152
- statusCode = res.status;
101
+ lgr.info(response.status, url.pathname, req.method, httpStatusCodes[response.status]);
102
+ return Promise.resolve(response);
103
+ }
153
104
 
154
- lgr.info(res.status, route.pattern, req.method, httpStatusCodes[res.status]);
155
- return Promise.resolve(res);
156
- } else {
157
- const res = new Response(httpStatusCodes[405], {
158
- status: 405,
159
- statusText: httpStatusCodes[405]
160
- });
161
- lgr.info(405, route.pattern, req.method, httpStatusCodes[405])
162
- return Promise.resolve(res);
163
- }
164
- }
165
- }
105
+ // if no route is found, return 404
106
+ const response = await http.notFound();
107
+
108
+ lgr.info(response.status, url.pathname, req.method, httpStatusCodes[response.status]);
109
+ return Promise.resolve(http.notFound());
166
110
 
167
- lgr.info(statusCode, url.pathname, req.method, httpStatusCodes[statusCode]);
168
- return Promise.resolve(http.message(statusCode, httpStatusCodes[statusCode]));
169
111
  }
170
112
  });
171
113
  },
@@ -0,0 +1,90 @@
1
+ import { HttpHandler, Context, Route } from "./router.d";
2
+ import { http } from "../http/generic-methods";
3
+
4
+ const splitPath = (s: string): string[] => s.split('/').filter(x => x !== '');
5
+
6
+ const createRoute = (path: string, method: string, handler: HttpHandler): Route => {
7
+ const route: Route = {
8
+ children: new Map(),
9
+ path: path,
10
+ dynamicPath: '',
11
+ method: method,
12
+ handler: handler,
13
+ isLast: false
14
+ };
15
+
16
+ return route;
17
+ };
18
+
19
+ const extractParams = (path: string, route: Route, params: Map<string, string>) => {
20
+ const pathParts = splitPath(path);
21
+ const routeParts = splitPath(route.path);
22
+
23
+ for (let i = 0; i < routeParts.length; i++) {
24
+ const part = routeParts[i];
25
+ if (part.startsWith(':')) {
26
+ params.set(part.slice(1), pathParts[i]);
27
+ }
28
+ }
29
+ };
30
+
31
+ const createContext = (path: string, route: Route, req: Request): Context => {
32
+ const params: Map<string, string> = new Map();
33
+
34
+ if (route) extractParams(path, route, params);
35
+
36
+ return {
37
+ params: params,
38
+ request: req,
39
+ query: new URLSearchParams(path),
40
+ cookies: new Map(),
41
+ formData: undefined,
42
+ logger: undefined,
43
+ json: (statusCode: number, data: any) => http.json(statusCode, data),
44
+ }
45
+ };
46
+
47
+ const Radix = () => {
48
+ let root = createRoute('', 'GET', () => http.notFound());
49
+
50
+ const addRoute = (path: string, method: string, handler: HttpHandler) => {
51
+ const pathParts = splitPath(path);
52
+ let current = root;
53
+
54
+ for (let i = 0; i < pathParts.length; i++) {
55
+ const part = pathParts[i];
56
+ if (part.startsWith(':')) {
57
+ current.dynamicPath = part;
58
+ }
59
+ if (!current.children.has(part)) {
60
+ current.children.set(part, createRoute(part, method, handler));
61
+ }
62
+ current = current.children.get(part)!;
63
+ }
64
+
65
+ current.handler = handler;
66
+ current.isLast = true;
67
+ current.path = path;
68
+ };
69
+
70
+ const findRoute = (path: string): Route | undefined => {
71
+ const pathParts = splitPath(path);
72
+ let current = root;
73
+ for (let i = 0; i < pathParts.length; i++) {
74
+ const part = pathParts[i];
75
+ if (current.children.has(part)) {
76
+ current = current.children.get(part)!;
77
+ } else if (current.dynamicPath) {
78
+ current = current.children.get(current.dynamicPath)!;
79
+ } else {
80
+ return;
81
+ }
82
+ }
83
+ return current;
84
+ }
85
+
86
+ return { addRoute, findRoute }
87
+
88
+ };
89
+
90
+ export { Radix, createContext }
package/package.json CHANGED
@@ -8,5 +8,5 @@
8
8
  "peerDependencies": {
9
9
  "typescript": "^5.0.0"
10
10
  },
11
- "version": "0.6.0"
11
+ "version": "0.7.0"
12
12
  }
@@ -1,68 +1,4 @@
1
1
  import { describe, test, expect } from 'bun:test';
2
- import { extract } from '..';
3
- import { Context, Route } from '../lib/router/router.d';
4
-
5
- describe('URL Params', () => {
6
- test('/user/:name', () => {
7
- const route: Route = {
8
- pattern: '/user/:name',
9
- method: 'GET',
10
- callback: () => new Response('ok'),
11
- };
12
-
13
- const ctx: Context = {
14
- request: new Request('http://localhost:3000/user/foo'),
15
- params: new Map(),
16
- };
17
-
18
- const extractor = extract(route, ctx);
19
-
20
- extractor?.params();
21
-
22
- const name = ctx.params.get('name');
23
- expect(name).toBe('foo');
24
- });
25
-
26
- test('/user/:name/:id', () => {
27
- const route: Route = {
28
- pattern: '/user/:name/:id',
29
- method: 'GET',
30
- callback: () => new Response('ok'),
31
- };
32
-
33
- const ctx: Context = {
34
- request: new Request('http://localhost:3000/user/foo/123'),
35
- params: new Map(),
36
- };
37
-
38
- const extractor = extract(route, ctx);
39
-
40
- extractor?.params();
41
-
42
- const name = ctx.params.get('name');
43
- const id = ctx.params.get('id');
44
-
45
- expect(name).toBe('foo');
46
- expect(id).toBe('123');
47
- });
48
-
49
- test('/foo', () => {
50
- const route: Route = {
51
- pattern: '/foo',
52
- method: 'GET',
53
- callback: () => new Response('ok'),
54
- }
55
-
56
- const ctx: Context = {
57
- request: new Request('http://localhost:3000/foo'),
58
- params: new Map(),
59
- }
60
-
61
- const url = new URL(ctx.request.url);
62
-
63
- expect(url.pathname).toBe(route.pattern);
64
- });
65
- });
66
2
 
67
3
  describe('Router', () => {
68
4
  test('Serve', async () => {
@@ -1,22 +0,0 @@
1
- import { router, http } from '..';
2
-
3
- const r = router(3000, {db: './examples/dbs/test.db'});
4
-
5
- r.add('/u/new/:name', 'GET', (ctx) => {
6
- const name = ctx.params.get('name');
7
- const rando = Math.floor(Math.random()*1000);
8
-
9
- ctx.db.run(`INSERT INTO test VALUES(${rando}, "${name}")`);
10
-
11
- return http.json(200, {message: 'ok'});
12
- });
13
-
14
- r.add('/u/:name', 'GET', (ctx) => {
15
- const name = ctx.params.get('name');
16
- const data = ctx.db.query(`SELECT * FROM test WHERE name = "${name}";`).get();
17
- const d = data as {id: number, name: string};
18
-
19
- return d ? http.json(200, d) : new Response('not found', {status: 404});
20
- });
21
-
22
- r.serve();