bun-router 0.7.4-experimental.0 → 0.7.4-experimental.12
Sign up to get free protection for your applications and to get access to all the features.
- package/.eslintrc.json +2 -1
- package/README.md +5 -0
- package/examples/basic.ts +4 -4
- package/examples/dynamic.ts +8 -8
- package/examples/logger.ts +2 -2
- package/examples/ssr/index.ts +8 -0
- package/examples/ssr/pages/foo.tsx +7 -0
- package/examples/ssr/pages/home.tsx +7 -0
- package/examples/static.ts +1 -0
- package/examples/todo.ts +22 -22
- package/examples/tsx/components/user.tsx +7 -0
- package/examples/tsx/index.ts +20 -0
- package/index.ts +1 -0
- package/lib/fs/filetree.ts +75 -0
- package/lib/fs/fsys.ts +23 -13
- package/lib/http/http.ts +13 -0
- package/lib/http/status.ts +64 -64
- package/lib/logger/color.ts +25 -25
- package/lib/logger/logger.d.ts +1 -1
- package/lib/logger/logger.ts +23 -7
- package/lib/router/context.ts +14 -8
- package/lib/router/{tree.ts → routeTree.ts} +33 -9
- package/lib/router/router.d.ts +2 -1
- package/lib/router/router.ts +64 -31
- package/package.json +9 -6
- package/tests/router.test.ts +21 -20
- package/tsconfig.json +5 -2
- package/examples/db.ts +0 -10
- package/lib/util/strings.ts +0 -3
package/.eslintrc.json
CHANGED
package/README.md
CHANGED
@@ -29,6 +29,10 @@ router.serve();
|
|
29
29
|
```
|
30
30
|
|
31
31
|
##### Static
|
32
|
+
Read a directory and serve it's contents. `.tsx` and `.html` files are rendered by default, everything else is served, including the extension.
|
33
|
+
|
34
|
+
Ex: `/assets/gopher.png` would serve a `.png` image. `/home` would be `.tsx` or `.html` depending on extension.
|
35
|
+
|
32
36
|
```ts
|
33
37
|
import { Router } from 'bun-router';
|
34
38
|
|
@@ -77,3 +81,4 @@ router.get('/', ctx => ctx.render(Home('Hello World')))
|
|
77
81
|
router.serve();
|
78
82
|
```
|
79
83
|
|
84
|
+
|
package/examples/basic.ts
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
import { Router, http } from '..';
|
2
2
|
|
3
|
-
const router = Router();
|
3
|
+
const router = Router(3000, {enableFileLogging: false});
|
4
4
|
|
5
5
|
router.add('/', 'GET', () => http.json(200, 'ok'));
|
6
6
|
|
7
7
|
router.add('/user/:name', 'GET', (ctx) => {
|
8
|
-
|
9
|
-
|
10
|
-
|
8
|
+
const name = ctx.params.get('name');
|
9
|
+
if (!name) return http.json(500, 'no name');
|
10
|
+
return http.json(200, name);
|
11
11
|
});
|
12
12
|
|
13
13
|
router.serve();
|
package/examples/dynamic.ts
CHANGED
@@ -4,16 +4,16 @@ import { Context } from '../lib/router/router.d';
|
|
4
4
|
const home = () => new Response('Welcome Home', { status: 200 });
|
5
5
|
|
6
6
|
const subreddit = (ctx: Context) => {
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
}
|
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 });
|
10
|
+
};
|
11
11
|
|
12
12
|
const user = (ctx: Context) => {
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
}
|
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 });
|
16
|
+
};
|
17
17
|
|
18
18
|
const r = Router();
|
19
19
|
|
package/examples/logger.ts
CHANGED
package/examples/static.ts
CHANGED
package/examples/todo.ts
CHANGED
@@ -1,45 +1,45 @@
|
|
1
1
|
import { Router, http } from '..';
|
2
2
|
|
3
3
|
const Todo = () => {
|
4
|
-
|
4
|
+
const list: Record<string, string> = {};
|
5
5
|
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
}
|
6
|
+
return {
|
7
|
+
add: (key: string, value: string) => { list[key] = value; },
|
8
|
+
get: (key: string) => list[key],
|
9
|
+
remove: (key: string) => { delete list[key]; },
|
10
|
+
size: () => Object.entries(list).length,
|
11
|
+
export: () => list,
|
12
|
+
};
|
13
|
+
};
|
14
14
|
|
15
15
|
const todo = Todo();
|
16
16
|
|
17
17
|
const r = Router();
|
18
18
|
|
19
19
|
r.add('/api/new', 'POST', ctx => {
|
20
|
-
|
21
|
-
|
22
|
-
|
20
|
+
const query = new URL(ctx.request.url).searchParams;
|
21
|
+
const key = query.get('key');
|
22
|
+
const content = query.get('content');
|
23
23
|
|
24
|
-
|
25
|
-
|
26
|
-
|
24
|
+
if (!key || !content) return http.message(400, 'invalid query params');
|
25
|
+
ctx.logger.message(`Adding ${key} with ${content}`);
|
26
|
+
todo.add(key, content);
|
27
27
|
|
28
|
-
|
28
|
+
return ctx.json(200, { message: 'ok' });
|
29
29
|
});
|
30
30
|
|
31
31
|
r.add('/api/todo/:key', 'GET', ctx => {
|
32
|
-
|
33
|
-
|
32
|
+
const key = ctx.params.get('key');
|
33
|
+
if (!key) return http.message(400, 'invalid params');
|
34
34
|
|
35
|
-
|
36
|
-
|
35
|
+
const content = todo.get(key);
|
36
|
+
if (!content) return http.notFound();
|
37
37
|
|
38
|
-
|
38
|
+
return ctx.json(200, {key: key, content: content});
|
39
39
|
});
|
40
40
|
|
41
41
|
r.add('/api/get/all', 'GET', ctx => {
|
42
|
-
|
42
|
+
return ctx.json(200, todo.export());
|
43
43
|
});
|
44
44
|
|
45
45
|
r.serve();
|
@@ -0,0 +1,20 @@
|
|
1
|
+
import { Router, http } from '../../';
|
2
|
+
import { User } from './components/user';
|
3
|
+
|
4
|
+
const router = Router();
|
5
|
+
|
6
|
+
router.get('/', () => {
|
7
|
+
return http.ok();
|
8
|
+
});
|
9
|
+
|
10
|
+
|
11
|
+
router.get('/u/:username', async ctx => {
|
12
|
+
const username = ctx.params.get('username');
|
13
|
+
|
14
|
+
if (!username) return http.message(400, 'invalid username');
|
15
|
+
|
16
|
+
return http.render(User(username));
|
17
|
+
});
|
18
|
+
|
19
|
+
router.serve();
|
20
|
+
|
package/index.ts
CHANGED
@@ -0,0 +1,75 @@
|
|
1
|
+
import path from 'node:path';
|
2
|
+
|
3
|
+
type File = {
|
4
|
+
name: string;
|
5
|
+
path: string;
|
6
|
+
extension: string;
|
7
|
+
children: Map<string, File>;
|
8
|
+
isLast: boolean;
|
9
|
+
};
|
10
|
+
|
11
|
+
function createFile(name: string): File {
|
12
|
+
return {
|
13
|
+
name,
|
14
|
+
path: '',
|
15
|
+
extension: '',
|
16
|
+
children: new Map(),
|
17
|
+
isLast: false,
|
18
|
+
};
|
19
|
+
}
|
20
|
+
|
21
|
+
const FileTree = (dir: string) => {
|
22
|
+
const root = createFile(dir);
|
23
|
+
|
24
|
+
const addFile = (filepath: string) => {
|
25
|
+
const pathParts = filepath.split(path.sep);
|
26
|
+
let current = root;
|
27
|
+
|
28
|
+
for (let i = 0; i < pathParts.length; i++) {
|
29
|
+
const part = pathParts[i];
|
30
|
+
if (!current.children.has(part)) {
|
31
|
+
current.children.set(part, createFile(part));
|
32
|
+
}
|
33
|
+
current = current.children.get(part)!;
|
34
|
+
}
|
35
|
+
|
36
|
+
current.isLast = true;
|
37
|
+
current.path = filepath;
|
38
|
+
current.extension = path.extname(filepath);
|
39
|
+
};
|
40
|
+
|
41
|
+
const getFilesByExtension = (extension: string): string[] => {
|
42
|
+
let current = root;
|
43
|
+
const files: string[] = [];
|
44
|
+
|
45
|
+
for (const [name, file] of current.children) {
|
46
|
+
if (file.extension === extension) {
|
47
|
+
files.push(file.path);
|
48
|
+
}
|
49
|
+
current = current.children.get(name)!;
|
50
|
+
}
|
51
|
+
|
52
|
+
return files;
|
53
|
+
};
|
54
|
+
|
55
|
+
const getFileByName = (filepath: string): string | undefined => {
|
56
|
+
let current = root;
|
57
|
+
const pathParts = filepath.split(path.sep);
|
58
|
+
for (let i = 0; i < pathParts.length; i++) {
|
59
|
+
const part = pathParts[i];
|
60
|
+
if (current.children.has(part)) {
|
61
|
+
current = current.children.get(part)!;
|
62
|
+
} else {
|
63
|
+
return;
|
64
|
+
}
|
65
|
+
}
|
66
|
+
|
67
|
+
if (!current.isLast) return;
|
68
|
+
|
69
|
+
return current.path;
|
70
|
+
};
|
71
|
+
|
72
|
+
return { addFile, getFilesByExtension, getFileByName };
|
73
|
+
};
|
74
|
+
|
75
|
+
export { FileTree };
|
package/lib/fs/fsys.ts
CHANGED
@@ -1,29 +1,39 @@
|
|
1
|
-
import { BunFile } from
|
1
|
+
import { BunFile } from 'bun';
|
2
2
|
import fs from 'node:fs/promises';
|
3
3
|
import path from 'path';
|
4
4
|
|
5
5
|
// check if the file path is a directory
|
6
6
|
const isDir = async (fp: string): Promise<boolean> => (await fs.lstat(fp)).isDirectory();
|
7
7
|
|
8
|
+
// recursively read a directory and call a handler function on each file
|
8
9
|
async function readDir(dirpath: string, handler: (filepath: string, entry: BunFile) => void) {
|
9
|
-
|
10
|
+
const files = await fs.readdir(dirpath);
|
10
11
|
|
11
|
-
|
12
|
-
|
12
|
+
for (const file of files) {
|
13
|
+
const bunFile = Bun.file(file);
|
13
14
|
|
14
|
-
|
15
|
+
if (typeof bunFile.name === 'undefined') return;
|
15
16
|
|
16
|
-
|
17
|
-
|
17
|
+
const fp = path.join(dirpath, bunFile.name);
|
18
|
+
const isdir = await isDir(fp);
|
18
19
|
|
19
|
-
|
20
|
-
|
21
|
-
|
20
|
+
if (isdir) await readDir(fp, handler);
|
21
|
+
else handler(fp, bunFile);
|
22
|
+
}
|
22
23
|
}
|
23
24
|
|
24
|
-
|
25
|
-
|
25
|
+
// resolve module paths relative to the current working directory
|
26
|
+
function resolveModulePath(module: string) {
|
27
|
+
return path.join(process.cwd(), module);
|
26
28
|
}
|
27
29
|
|
30
|
+
function exists(filepath: string): boolean {
|
31
|
+
try {
|
32
|
+
fs.access(filepath);
|
33
|
+
return true;
|
34
|
+
} catch (err) {
|
35
|
+
return false;
|
36
|
+
}
|
37
|
+
}
|
28
38
|
|
29
|
-
export { readDir,
|
39
|
+
export { readDir, resolveModulePath, exists };
|
package/lib/http/http.ts
CHANGED
@@ -1,6 +1,11 @@
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
2
2
|
import { httpStatusCodes } from './status';
|
3
|
+
import { ReactNode } from 'react';
|
4
|
+
import { renderToReadableStream } from 'react-dom/server';
|
3
5
|
|
6
|
+
// http is a collection of functions that return a Response
|
7
|
+
// object with the appropriate status code and content type
|
8
|
+
// e.g. http.ok() returns a 200 response
|
4
9
|
const http = {
|
5
10
|
ok: async (msg?: string): Promise<Response> => {
|
6
11
|
return Promise.resolve(new Response(msg ?? httpStatusCodes[200], {
|
@@ -43,6 +48,14 @@ const http = {
|
|
43
48
|
headers: { 'Content-Type': contentType}
|
44
49
|
}));
|
45
50
|
},
|
51
|
+
render: async (component: ReactNode): Promise<Response> => {
|
52
|
+
const stream = await renderToReadableStream(component);
|
53
|
+
return new Response(stream, {
|
54
|
+
status: 200,
|
55
|
+
statusText: httpStatusCodes[200],
|
56
|
+
headers: {'Content-Type': 'text/html; charset=utf-8'}
|
57
|
+
});
|
58
|
+
},
|
46
59
|
noContent: async (): Promise<Response> => Promise.resolve(new Response('no content', {
|
47
60
|
status: 204,
|
48
61
|
statusText: 'no content',
|
package/lib/http/status.ts
CHANGED
@@ -1,66 +1,66 @@
|
|
1
1
|
const httpStatusCodes: { [key: number]: string } = {
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
2
|
+
100: 'Continue',
|
3
|
+
101: 'Switching Protocols',
|
4
|
+
102: 'Processing',
|
5
|
+
103: 'Early Hints',
|
6
|
+
200: 'OK',
|
7
|
+
201: 'Created',
|
8
|
+
202: 'Accepted',
|
9
|
+
203: 'Non-Authoritative Information',
|
10
|
+
204: 'No Content',
|
11
|
+
205: 'Reset Content',
|
12
|
+
206: 'Partial Content',
|
13
|
+
207: 'Multi-Status',
|
14
|
+
208: 'Already Reported',
|
15
|
+
226: 'IM Used',
|
16
|
+
300: 'Multiple Choices',
|
17
|
+
301: 'Moved Permanently',
|
18
|
+
302: 'Found',
|
19
|
+
303: 'See Other',
|
20
|
+
304: 'Not Modified',
|
21
|
+
305: 'Use Proxy',
|
22
|
+
307: 'Temporary Redirect',
|
23
|
+
308: 'Permanent Redirect',
|
24
|
+
400: 'Bad Request',
|
25
|
+
401: 'Unauthorized',
|
26
|
+
402: 'Payment Required',
|
27
|
+
403: 'Forbidden',
|
28
|
+
404: 'Not Found',
|
29
|
+
405: 'Method Not Allowed',
|
30
|
+
406: 'Not Acceptable',
|
31
|
+
407: 'Proxy Authentication Required',
|
32
|
+
408: 'Request Timeout',
|
33
|
+
409: 'Conflict',
|
34
|
+
410: 'Gone',
|
35
|
+
411: 'Length Required',
|
36
|
+
412: 'Precondition Failed',
|
37
|
+
413: 'Payload Too Large',
|
38
|
+
414: 'URI Too Long',
|
39
|
+
415: 'Unsupported Media Type',
|
40
|
+
416: 'Range Not Satisfiable',
|
41
|
+
417: 'Expectation Failed',
|
42
|
+
418: 'I\'m a Teapot',
|
43
|
+
421: 'Misdirected Request',
|
44
|
+
422: 'Unprocessable Entity',
|
45
|
+
423: 'Locked',
|
46
|
+
424: 'Failed Dependency',
|
47
|
+
425: 'Too Early',
|
48
|
+
426: 'Upgrade Required',
|
49
|
+
428: 'Precondition Required',
|
50
|
+
429: 'Too Many Requests',
|
51
|
+
431: 'Request Header Fields Too Large',
|
52
|
+
451: 'Unavailable For Legal Reasons',
|
53
|
+
500: 'Internal Server Error',
|
54
|
+
501: 'Not Implemented',
|
55
|
+
502: 'Bad Gateway',
|
56
|
+
503: 'Service Unavailable',
|
57
|
+
504: 'Gateway Timeout',
|
58
|
+
505: 'HTTP Version Not Supported',
|
59
|
+
506: 'Variant Also Negotiates',
|
60
|
+
507: 'Insufficient Storage',
|
61
|
+
508: 'Loop Detected',
|
62
|
+
510: 'Not Extended',
|
63
|
+
511: 'Network Authentication Required',
|
64
|
+
};
|
65
65
|
|
66
|
-
|
66
|
+
export { httpStatusCodes };
|
package/lib/logger/color.ts
CHANGED
@@ -1,36 +1,36 @@
|
|
1
1
|
const Colors: Record<string,string> = {
|
2
|
-
|
2
|
+
reset: '\x1b[0m',
|
3
3
|
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
4
|
+
// foreground
|
5
|
+
black: '\x1b[30m',
|
6
|
+
red: '\x1b[31m',
|
7
|
+
green: '\x1b[32m',
|
8
|
+
yellow: '\x1b[33m',
|
9
|
+
blue: '\x1b[34m',
|
10
|
+
magenta: '\x1b[35m',
|
11
|
+
cyan: '\x1b[36m',
|
12
|
+
white: '\x1b[37m',
|
13
13
|
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
14
|
+
// background
|
15
|
+
bgBlack: '\x1b[40m',
|
16
|
+
bgRed: '\x1b[41m',
|
17
|
+
bgGreen: '\x1b[42m',
|
18
|
+
bgYellow: '\x1b[43m',
|
19
|
+
bgBlue: '\x1b[44m',
|
20
|
+
bgMagenta: '\x1b[45m',
|
21
|
+
bgCyan: '\x1b[46m',
|
22
|
+
bgWhite: '\x1b[47m',
|
23
|
+
} as const;
|
24
24
|
|
25
25
|
|
26
26
|
|
27
27
|
function color(foreground: string, background: string, message: string) {
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
28
|
+
const _foreground = Colors[foreground];
|
29
|
+
const _background = Colors[background];
|
30
|
+
const reset = Colors.reset;
|
31
|
+
return `${_foreground}${_background}${message}${reset}`;
|
32
32
|
}
|
33
33
|
|
34
34
|
|
35
35
|
|
36
|
-
export { color }
|
36
|
+
export { color };
|
package/lib/logger/logger.d.ts
CHANGED
package/lib/logger/logger.ts
CHANGED
@@ -1,5 +1,4 @@
|
|
1
1
|
import { color } from './color';
|
2
|
-
import { BunLogger } from './logger.d';
|
3
2
|
|
4
3
|
|
5
4
|
const TITLE = `
|
@@ -9,8 +8,24 @@ _ _
|
|
9
8
|
|___|___|_|_| |_| |___|___|_| |___|_|
|
10
9
|
|
11
10
|
`;
|
12
|
-
const VERSION = '0.7.4-experimental';
|
13
|
-
const Logger = (
|
11
|
+
const VERSION = '0.7.4-experimental.12';
|
12
|
+
const Logger = (enableFileLogging: boolean) => {
|
13
|
+
const file = Bun.file('bun-router.log');
|
14
|
+
const writer = enableFileLogging ? file.writer() : null;
|
15
|
+
|
16
|
+
function stripAnsi(str: string) {
|
17
|
+
const ansiRegex = /\u001B\[(?:[0-9]{1,3}(?:;[0-9]{1,3})*)?[m|K]/g;
|
18
|
+
return str.replace(ansiRegex, '');
|
19
|
+
}
|
20
|
+
|
21
|
+
async function write(message: string) {
|
22
|
+
await Bun.write(Bun.stdout, message);
|
23
|
+
if (writer) {
|
24
|
+
writer.write(stripAnsi(message));
|
25
|
+
writer.flush();
|
26
|
+
}
|
27
|
+
}
|
28
|
+
|
14
29
|
return {
|
15
30
|
info: async (statusCode: number, routePath: string, method: string, message?: string) => {
|
16
31
|
const { stamp } = timestamp((new Date(Date.now())));
|
@@ -19,7 +34,7 @@ const Logger = (): BunLogger => {
|
|
19
34
|
|
20
35
|
message = `${source}: ${setColor(statusCode)}: ${rp} ${(method === 'GET') ? ' ->' : ' <-'} ${method} ${ message ?? ''}\n`;
|
21
36
|
|
22
|
-
await
|
37
|
+
await write(message);
|
23
38
|
|
24
39
|
},
|
25
40
|
error: async (statusCode: number, routePath: string, method: string, error: Error) => {
|
@@ -29,7 +44,7 @@ const Logger = (): BunLogger => {
|
|
29
44
|
|
30
45
|
const message = `${source}: ${setColor(statusCode)}: ${rp} ${(method === 'GET') ? ' -> ' : ' <-'} ${error.message}\n`;
|
31
46
|
|
32
|
-
await
|
47
|
+
await write(message);
|
33
48
|
},
|
34
49
|
warn: async (message: string) => {
|
35
50
|
const { stamp } = timestamp((new Date(Date.now())));
|
@@ -38,7 +53,7 @@ const Logger = (): BunLogger => {
|
|
38
53
|
|
39
54
|
message = `${source} : ${messageColor}\n`;
|
40
55
|
|
41
|
-
await
|
56
|
+
await write(message);
|
42
57
|
},
|
43
58
|
message: async (message: string) => {
|
44
59
|
const { stamp } = timestamp((new Date(Date.now())));
|
@@ -47,8 +62,9 @@ const Logger = (): BunLogger => {
|
|
47
62
|
|
48
63
|
message = `${source}: ${messageColor}\n`;
|
49
64
|
|
50
|
-
await
|
65
|
+
await write(message);
|
51
66
|
},
|
67
|
+
|
52
68
|
};
|
53
69
|
};
|
54
70
|
|
package/lib/router/context.ts
CHANGED
@@ -5,9 +5,10 @@ import { Logger } from '../logger/logger';
|
|
5
5
|
import { http } from './router';
|
6
6
|
import { ReactNode } from 'react';
|
7
7
|
|
8
|
-
|
9
|
-
|
8
|
+
// createContext creates a context object
|
9
|
+
async function createContext(path: string, route: Route, request: Request, enableFileLogging: boolean): Promise<Context> {
|
10
10
|
const query = new URLSearchParams(path);
|
11
|
+
const params = extractParams(path, route);
|
11
12
|
const formData = isMultiPartForm(request.headers) ? await request.formData() : new FormData();
|
12
13
|
|
13
14
|
return Promise.resolve({
|
@@ -15,22 +16,25 @@ async function createContext(path: string, route: Route, request: Request): Prom
|
|
15
16
|
request,
|
16
17
|
query,
|
17
18
|
formData,
|
18
|
-
logger: Logger(),
|
19
|
+
logger: Logger(enableFileLogging),
|
19
20
|
json: (statusCode: number, data: any) => http.json(statusCode, data),
|
20
21
|
render: async (component: ReactNode) => await renderStream(component),
|
21
22
|
});
|
22
23
|
}
|
23
24
|
|
24
|
-
|
25
|
+
// extractParams extracts the parameters from the path
|
26
|
+
// and returns a map of key/value pairs
|
27
|
+
// e.g. /users/:id => /users/123 => { id: 123 }
|
28
|
+
function extractParams(pattern: string, route: Route): Map<string, string> {
|
25
29
|
const params: Map<string, string> = new Map();
|
26
|
-
const pathSegments =
|
30
|
+
const pathSegments = pattern.split('/');
|
27
31
|
const routeSegments = route.path.split('/');
|
28
32
|
|
29
33
|
if (pathSegments.length !== routeSegments.length) return params;
|
30
34
|
|
31
35
|
for (let i = 0; i < pathSegments.length; i++) {
|
32
36
|
if (routeSegments[i][0] === ':') {
|
33
|
-
const key = routeSegments[i].
|
37
|
+
const key = routeSegments[i].slice(1);
|
34
38
|
const value = pathSegments[i];
|
35
39
|
params.set(key, value);
|
36
40
|
}
|
@@ -39,17 +43,19 @@ function extractParams(path: string, route: Route): Map<string, string> {
|
|
39
43
|
return params;
|
40
44
|
}
|
41
45
|
|
46
|
+
// getContentType returns the content type from the headers
|
42
47
|
function getContentType(headers: Headers): string {
|
43
48
|
const contentType = headers.get('Content-Type');
|
44
|
-
|
45
|
-
return contentType;
|
49
|
+
return contentType ?? '';
|
46
50
|
}
|
47
51
|
|
52
|
+
// isMultiPartForm returns true if the content type is multipart/form-data
|
48
53
|
function isMultiPartForm(headers: Headers): boolean {
|
49
54
|
const contentType = getContentType(headers);
|
50
55
|
return contentType.includes('multipart/form-data');
|
51
56
|
}
|
52
57
|
|
58
|
+
// renderStream renders the component to a readable stream
|
53
59
|
async function renderStream(children: ReactNode) {
|
54
60
|
const stream = await renderToReadableStream(children);
|
55
61
|
return new Response(stream, { headers: { 'Content-Type': 'text/html' } });
|
@@ -1,7 +1,6 @@
|
|
1
1
|
import { HttpHandler, Route } from './router.d';
|
2
2
|
import { http } from '../http/http';
|
3
3
|
import { createContext } from './context';
|
4
|
-
import { splitPath } from '../util/strings';
|
5
4
|
|
6
5
|
const createRoute = (path: string, method: string, handler: HttpHandler): Route => {
|
7
6
|
const route: Route = {
|
@@ -19,8 +18,9 @@ const createRoute = (path: string, method: string, handler: HttpHandler): Route
|
|
19
18
|
const RouteTree = () => {
|
20
19
|
const root = createRoute('', 'GET', () => http.notFound());
|
21
20
|
|
22
|
-
|
23
|
-
|
21
|
+
function addRoute (pattern: string, method: string, handler: HttpHandler){
|
22
|
+
console.log(pattern);
|
23
|
+
const pathParts = pattern.split('/');
|
24
24
|
let current = root;
|
25
25
|
|
26
26
|
for (let i = 0; i < pathParts.length; i++) {
|
@@ -36,11 +36,11 @@ const RouteTree = () => {
|
|
36
36
|
|
37
37
|
current.handler = handler;
|
38
38
|
current.isLast = true;
|
39
|
-
current.path =
|
40
|
-
}
|
39
|
+
current.path = pattern;
|
40
|
+
}
|
41
41
|
|
42
|
-
function findRoute(
|
43
|
-
const pathParts =
|
42
|
+
function findRoute(pathname: string): Route | undefined {
|
43
|
+
const pathParts = pathname.split('/');
|
44
44
|
let current = root;
|
45
45
|
for (let i = 0; i < pathParts.length; i++) {
|
46
46
|
const part = pathParts[i];
|
@@ -55,8 +55,32 @@ const RouteTree = () => {
|
|
55
55
|
return current;
|
56
56
|
}
|
57
57
|
|
58
|
-
|
58
|
+
function size() {
|
59
|
+
let count = 0;
|
60
|
+
function traverse(route: Route) {
|
61
|
+
count++;
|
62
|
+
for (const child of route.children.values()) {
|
63
|
+
traverse(child);
|
64
|
+
}
|
65
|
+
}
|
66
|
+
traverse(root);
|
67
|
+
return count;
|
68
|
+
}
|
69
|
+
|
70
|
+
function list() {
|
71
|
+
const routes: Route[] = [];
|
72
|
+
function traverse(route: Route) {
|
73
|
+
routes.push(route);
|
74
|
+
for (const child of route.children.values()) {
|
75
|
+
traverse(child);
|
76
|
+
}
|
77
|
+
}
|
78
|
+
traverse(root);
|
79
|
+
return routes;
|
80
|
+
}
|
81
|
+
|
82
|
+
return { addRoute, findRoute, size, list };
|
59
83
|
|
60
84
|
};
|
61
85
|
|
62
|
-
export { RouteTree, createContext
|
86
|
+
export { RouteTree, createContext };
|
package/lib/router/router.d.ts
CHANGED
package/lib/router/router.ts
CHANGED
@@ -1,96 +1,129 @@
|
|
1
1
|
import path from 'path';
|
2
2
|
import { Database } from 'bun:sqlite';
|
3
|
-
import { Route, BunRouter, RouterOptions, Options
|
3
|
+
import { Route, BunRouter, RouterOptions, Options } from './router.d';
|
4
4
|
import { httpStatusCodes } from '../http/status';
|
5
5
|
import { readDir } from '../fs/fsys';
|
6
6
|
import { Logger, startMessage } from '../logger/logger';
|
7
7
|
import { http } from '../http/http';
|
8
|
-
import { RouteTree } from './
|
8
|
+
import { RouteTree } from './routeTree';
|
9
9
|
import { createContext } from './context';
|
10
10
|
|
11
|
-
|
12
11
|
const Router: BunRouter = (port?: number | string, options?: RouterOptions<Options>) => {
|
13
12
|
const { addRoute, findRoute } = RouteTree();
|
14
|
-
const logger = Logger();
|
15
|
-
|
13
|
+
const logger = Logger(options?.enableFileLogging ?? false);
|
14
|
+
|
15
|
+
// load a component from the root directory relative to the cwd
|
16
|
+
async function loadComponent(root: string, name: string) {
|
17
|
+
const module = await import(path.join(process.cwd(), root, name));
|
18
|
+
return module.default;
|
19
|
+
}
|
20
|
+
|
21
|
+
// extract the path, extension, and base name from a file path
|
22
|
+
function extractPathExtBase(pattern: string, pathname: string) {
|
23
|
+
const extension = path.extname(pathname);
|
24
|
+
let base = encodeURIComponent(path.basename(pathname));
|
25
|
+
|
26
|
+
if (extension === '.html' || extension === '.tsx') base = base.replace(extension, '');
|
27
|
+
|
28
|
+
let patternPath = [pattern, base].join('/');
|
29
|
+
|
30
|
+
if (base === 'index') patternPath = pattern;
|
31
|
+
|
32
|
+
return { patternPath, extension, base };
|
33
|
+
}
|
34
|
+
|
35
|
+
// check if a file exists
|
36
|
+
async function exists(fp: string) {
|
37
|
+
const f = Bun.file(fp);
|
38
|
+
return await f.exists();
|
39
|
+
}
|
40
|
+
|
16
41
|
return {
|
17
42
|
// add a route to the router tree
|
18
43
|
add: (pattern, method, callback) => { addRoute(pattern, method, callback); },
|
19
|
-
get: (pattern
|
44
|
+
get: (pattern, callback) => { addRoute(pattern, 'GET', callback); },
|
20
45
|
post: (pattern, callback) => { addRoute(pattern, 'POST', callback); },
|
21
46
|
put: (pattern, callback) => { addRoute(pattern, 'PUT', callback);},
|
22
47
|
delete: (pattern, callback) => { addRoute(pattern, 'DELETE', callback); },
|
23
48
|
|
24
|
-
// add
|
49
|
+
// add static routes to the router tree
|
50
|
+
// .tsx and .html are rendered as components, or pages
|
51
|
+
// all other file extensions are served as files
|
52
|
+
// the root directory is traversed recursively
|
25
53
|
static: async (pattern: string, root: string) => {
|
54
|
+
if (!exists(root)) return console.error(`Directory not found: ${root}`);
|
26
55
|
await readDir(root, async (fp) => {
|
27
|
-
const
|
28
|
-
const ext = path.extname(pure);
|
29
|
-
|
30
|
-
let base = path.basename(pure);
|
31
|
-
|
32
|
-
if (ext === '.html') base = base.replace(ext, '');
|
33
|
-
|
34
|
-
if (pattern[0] !== '/') pattern = '/' + pattern;
|
35
|
-
|
36
|
-
let patternPath = pattern + base;
|
37
|
-
|
38
|
-
if (base === 'index') patternPath = pattern;
|
39
|
-
|
56
|
+
const { patternPath, extension, base } = extractPathExtBase(pattern, fp);
|
40
57
|
const route: Route = {
|
41
58
|
children: new Map(),
|
42
|
-
dynamicPath:
|
59
|
+
dynamicPath: pattern,
|
43
60
|
isLast: true,
|
44
|
-
path: patternPath,
|
61
|
+
path: patternPath.startsWith('//') ? patternPath.slice(1) : patternPath, // remove the leading '/' if it exists
|
45
62
|
method: 'GET',
|
46
|
-
handler: async () =>
|
63
|
+
handler: async () => {
|
64
|
+
if (extension === '.tsx') {
|
65
|
+
const component = await loadComponent(root, base);
|
66
|
+
return await http.render(component());
|
67
|
+
} else {
|
68
|
+
return await http.file(200, fp);
|
69
|
+
}
|
70
|
+
},
|
47
71
|
};
|
48
72
|
|
49
73
|
addRoute(route.path, 'GET', route.handler);
|
50
74
|
});
|
75
|
+
|
51
76
|
},
|
52
|
-
// start
|
77
|
+
// start listening for requests
|
53
78
|
serve: () => {
|
54
79
|
startMessage(port ?? 3000);
|
55
|
-
const opts: Options = { db: ':memory:' };
|
80
|
+
const opts: Options = { db: ':memory:', enableFileLogging: false };
|
56
81
|
|
57
82
|
Bun.serve({
|
58
83
|
port: port ?? 3000,
|
59
84
|
...options,
|
60
85
|
async fetch(req) {
|
61
86
|
const url = new URL(req.url);
|
62
|
-
const
|
87
|
+
const pathname = url.pathname;
|
63
88
|
|
64
89
|
// set the database
|
65
90
|
if (options) {
|
66
91
|
const o = options as Options;
|
67
92
|
opts.db = o.db;
|
93
|
+
opts.enableFileLogging = o.enableFileLogging;
|
68
94
|
}
|
69
95
|
|
70
|
-
const route = findRoute(
|
96
|
+
const route = findRoute(pathname);
|
71
97
|
|
72
|
-
// if the route exists,
|
98
|
+
// if the route exists, call the handler
|
73
99
|
if (route) {
|
74
100
|
if (route.method !== req.method) {
|
75
101
|
logger.info(405, url.pathname, req.method, httpStatusCodes[405]);
|
76
102
|
return Promise.resolve(http.methodNotAllowed());
|
77
103
|
}
|
78
104
|
|
79
|
-
|
105
|
+
// create a context for the handler
|
106
|
+
const context = await createContext(pathname, route, req, opts.enableFileLogging);
|
80
107
|
context.db = new Database(opts.db);
|
81
108
|
|
109
|
+
// call the handler
|
82
110
|
const response = await route.handler(context);
|
83
111
|
|
84
112
|
logger.info(response.status, url.pathname, req.method, httpStatusCodes[response.status]);
|
85
113
|
return Promise.resolve(response);
|
86
114
|
}
|
87
115
|
|
88
|
-
// if no route is found, return 404
|
116
|
+
// if no route is found, return 404
|
89
117
|
const response = await http.notFound();
|
90
118
|
|
91
119
|
logger.info(response.status, url.pathname, req.method, httpStatusCodes[response.status]);
|
92
120
|
return Promise.resolve(http.notFound());
|
93
|
-
|
121
|
+
},
|
122
|
+
// if an error occurs, return a 500 response
|
123
|
+
error(error) {
|
124
|
+
return new Response(`<pre>${error}\n${error.stack}</pre>`, {
|
125
|
+
headers: { 'Content-Type': 'text/html' },
|
126
|
+
});
|
94
127
|
}
|
95
128
|
});
|
96
129
|
},
|
package/package.json
CHANGED
@@ -3,19 +3,22 @@
|
|
3
3
|
"module": "index.ts",
|
4
4
|
"type": "module",
|
5
5
|
"devDependencies": {
|
6
|
-
"@types/react
|
6
|
+
"@types/react": "^18.2.23",
|
7
|
+
"@types/react-dom": "^18.2.8",
|
7
8
|
"@typescript-eslint/eslint-plugin": "^6.7.0",
|
8
9
|
"@typescript-eslint/parser": "^6.7.0",
|
9
10
|
"bun-types": "latest",
|
10
11
|
"eslint": "^8.49.0",
|
11
|
-
"eslint-plugin-react": "^7.33.2"
|
12
|
-
"react-dom": "^18.2.0"
|
12
|
+
"eslint-plugin-react": "^7.33.2"
|
13
13
|
},
|
14
14
|
"peerDependencies": {
|
15
15
|
"typescript": "^5.0.0"
|
16
16
|
},
|
17
|
-
"version": "0.7.4-experimental.
|
17
|
+
"version": "0.7.4-experimental.12",
|
18
18
|
"dependencies": {
|
19
|
-
"eslint-plugin-react-hooks": "^4.6.0"
|
20
|
-
|
19
|
+
"eslint-plugin-react-hooks": "^4.6.0",
|
20
|
+
"react": "^18.2.0",
|
21
|
+
"react-dom": "^18.2.0"
|
22
|
+
},
|
23
|
+
"types": "./lib/router.d.ts"
|
21
24
|
}
|
package/tests/router.test.ts
CHANGED
@@ -1,35 +1,36 @@
|
|
1
1
|
import { describe, test, expect } from 'bun:test';
|
2
2
|
|
3
3
|
describe('Router', () => {
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
4
|
+
test('Serve', async () => {
|
5
|
+
const proc = Bun.spawn(['./tests/serve.test.sh'], {
|
6
|
+
onExit: (_proc, _exitCode, _signalCode , error) => {
|
7
|
+
if (error) console.error(error);
|
8
|
+
},
|
9
|
+
});
|
10
10
|
|
11
|
-
|
11
|
+
const text = await new Response(proc.stdout).text();
|
12
12
|
|
13
|
-
|
13
|
+
const hasFailed = text.includes('Failed');
|
14
14
|
|
15
|
-
|
15
|
+
if (hasFailed) console.log(text);
|
16
16
|
|
17
|
-
|
17
|
+
expect(hasFailed).toBe(false);
|
18
18
|
|
19
|
-
|
20
|
-
|
19
|
+
proc.kill(0);
|
20
|
+
});
|
21
21
|
|
22
|
-
|
23
|
-
|
22
|
+
test('Static', async() => {
|
23
|
+
const proc = Bun.spawn(['./tests/static.test.sh']);
|
24
24
|
|
25
|
-
|
25
|
+
const text = await new Response(proc.stdout).text();
|
26
26
|
|
27
|
-
|
28
|
-
|
27
|
+
const hasFailed = text.includes('Failed');
|
28
|
+
if (hasFailed) console.log(text);
|
29
29
|
|
30
|
-
|
30
|
+
expect(hasFailed).toBe(false);
|
31
31
|
|
32
|
-
|
33
|
-
|
32
|
+
proc.kill(0);
|
33
|
+
});
|
34
34
|
});
|
35
35
|
|
36
|
+
|
package/tsconfig.json
CHANGED
package/examples/db.ts
DELETED
package/lib/util/strings.ts
DELETED