bun-router 0.7.3 → 0.7.4-experimental.2
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/.eslintrc.json +34 -0
- package/README.md +28 -0
- package/bun.lockb +0 -0
- package/examples/basic.ts +3 -3
- package/examples/dynamic.ts +8 -8
- package/examples/logger.ts +2 -2
- package/examples/ssr/index.ts +7 -0
- package/examples/ssr/pages/foo.tsx +7 -0
- package/examples/ssr/pages/home.tsx +7 -0
- package/examples/todo.ts +22 -22
- package/lib/fs/filetree.ts +76 -0
- package/lib/fs/fsys.ts +22 -11
- package/lib/http/http.ts +74 -67
- package/lib/logger/logger.ts +59 -59
- package/lib/resolver/resolveTSX.ts +28 -0
- package/lib/router/context.ts +41 -34
- package/lib/router/router.d.ts +4 -3
- package/lib/router/router.ts +103 -88
- package/lib/router/tree.ts +53 -54
- package/package.json +11 -2
- package/tsconfig.json +4 -2
- package/examples/db.ts +0 -10
package/.eslintrc.json
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
{
|
2
|
+
"env": {
|
3
|
+
"browser": true,
|
4
|
+
"es2021": true
|
5
|
+
},
|
6
|
+
"extends": [
|
7
|
+
"eslint:recommended",
|
8
|
+
"plugin:@typescript-eslint/recommended",
|
9
|
+
"plugin:react/recommended"
|
10
|
+
],
|
11
|
+
"parser": "@typescript-eslint/parser",
|
12
|
+
"parserOptions": {
|
13
|
+
"ecmaVersion": "latest",
|
14
|
+
"sourceType": "module"
|
15
|
+
},
|
16
|
+
"plugins": [
|
17
|
+
"@typescript-eslint",
|
18
|
+
"react"
|
19
|
+
],
|
20
|
+
"rules": {
|
21
|
+
"indent": [
|
22
|
+
"warn",
|
23
|
+
"tab"
|
24
|
+
],
|
25
|
+
"quotes": [
|
26
|
+
"error",
|
27
|
+
"single"
|
28
|
+
],
|
29
|
+
"semi": [
|
30
|
+
"error",
|
31
|
+
"always"
|
32
|
+
]
|
33
|
+
}
|
34
|
+
}
|
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
|
|
@@ -53,4 +57,28 @@ router.post('/register', ctx => {
|
|
53
57
|
|
54
58
|
```
|
55
59
|
|
60
|
+
##### JSX
|
61
|
+
```tsx
|
62
|
+
// ./pages/home.tsx
|
63
|
+
export default const Home = (title: string) => {
|
64
|
+
return (
|
65
|
+
<main>
|
66
|
+
<h1>{ title }</h1>
|
67
|
+
</main>
|
68
|
+
);
|
69
|
+
};
|
70
|
+
```
|
71
|
+
|
72
|
+
```ts
|
73
|
+
// ./index.ts
|
74
|
+
import { Router } from 'bun-router';
|
75
|
+
import Home from './pages/home';
|
76
|
+
|
77
|
+
const router = Router();
|
78
|
+
|
79
|
+
router.get('/', ctx => ctx.render(Home('Hello World')))
|
80
|
+
|
81
|
+
router.serve();
|
82
|
+
```
|
83
|
+
|
56
84
|
|
package/bun.lockb
CHANGED
Binary file
|
package/examples/basic.ts
CHANGED
@@ -5,9 +5,9 @@ const router = Router();
|
|
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/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,76 @@
|
|
1
|
+
import { splitFilePath } from '../fs/fsys';
|
2
|
+
import path from 'node:path';
|
3
|
+
|
4
|
+
type File = {
|
5
|
+
name: string;
|
6
|
+
path: string;
|
7
|
+
extension: string;
|
8
|
+
children: Map<string, File>;
|
9
|
+
isLast: boolean;
|
10
|
+
};
|
11
|
+
|
12
|
+
function createFile(name: string): File {
|
13
|
+
return {
|
14
|
+
name,
|
15
|
+
path: '',
|
16
|
+
extension: '',
|
17
|
+
children: new Map(),
|
18
|
+
isLast: false,
|
19
|
+
};
|
20
|
+
}
|
21
|
+
|
22
|
+
const FileTree = (dir: string) => {
|
23
|
+
const root = createFile(dir);
|
24
|
+
|
25
|
+
const addFile = (_path: string) => {
|
26
|
+
const pathParts = splitFilePath(_path);
|
27
|
+
let current = root;
|
28
|
+
|
29
|
+
for (let i = 0; i < pathParts.length; i++) {
|
30
|
+
const part = pathParts[i];
|
31
|
+
if (!current.children.has(part)) {
|
32
|
+
current.children.set(part, createFile(part));
|
33
|
+
}
|
34
|
+
current = current.children.get(part)!;
|
35
|
+
}
|
36
|
+
|
37
|
+
current.isLast = true;
|
38
|
+
current.path = _path;
|
39
|
+
current.extension = path.extname(_path);
|
40
|
+
};
|
41
|
+
|
42
|
+
const getFilesByExtension = (extension: string): string[] => {
|
43
|
+
let current = root;
|
44
|
+
const files: string[] = [];
|
45
|
+
|
46
|
+
for (const [name, file] of current.children) {
|
47
|
+
if (file.extension === extension) {
|
48
|
+
files.push(file.path);
|
49
|
+
}
|
50
|
+
current = current.children.get(name)!;
|
51
|
+
}
|
52
|
+
|
53
|
+
return files;
|
54
|
+
};
|
55
|
+
|
56
|
+
const getFileByName = (path: string): string | undefined => {
|
57
|
+
let current = root;
|
58
|
+
const pathParts = splitFilePath(path);
|
59
|
+
for (let i = 0; i < pathParts.length; i++) {
|
60
|
+
const part = pathParts[i];
|
61
|
+
if (current.children.has(part)) {
|
62
|
+
current = current.children.get(part)!;
|
63
|
+
} else {
|
64
|
+
return;
|
65
|
+
}
|
66
|
+
}
|
67
|
+
|
68
|
+
if (!current.isLast) return;
|
69
|
+
|
70
|
+
return current.path;
|
71
|
+
};
|
72
|
+
|
73
|
+
return { addFile, getFilesByExtension, getFileByName };
|
74
|
+
};
|
75
|
+
|
76
|
+
export { FileTree };
|
package/lib/fs/fsys.ts
CHANGED
@@ -1,25 +1,36 @@
|
|
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
|
+
}
|
23
|
+
}
|
24
|
+
|
25
|
+
// get the extension of a file (unnecessary)
|
26
|
+
function ext(p: string): string {
|
27
|
+
return path.extname(p);
|
28
|
+
}
|
29
|
+
|
30
|
+
// split a file path into an array of strings (unnecessary)
|
31
|
+
function splitFilePath(p: string): string[] {
|
32
|
+
return p.split(path.sep);
|
22
33
|
}
|
23
34
|
|
24
35
|
|
25
|
-
export { readDir }
|
36
|
+
export { readDir, ext, splitFilePath };
|
package/lib/http/http.ts
CHANGED
@@ -1,77 +1,84 @@
|
|
1
|
-
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
2
|
+
import { httpStatusCodes } from './status';
|
3
|
+
import { ReactNode } from 'react';
|
4
|
+
import { renderToReadableStream } from 'react-dom/server';
|
2
5
|
|
3
6
|
const http = {
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
7
|
+
ok: async (msg?: string): Promise<Response> => {
|
8
|
+
return Promise.resolve(new Response(msg ?? httpStatusCodes[200], {
|
9
|
+
status: 200,
|
10
|
+
statusText: httpStatusCodes[200],
|
11
|
+
}));
|
12
|
+
},
|
13
|
+
json: async (statusCode: number, data: any): Promise<Response> => {
|
14
|
+
const jsonString = JSON.stringify(data);
|
15
|
+
return Promise.resolve(new Response(jsonString, {
|
16
|
+
status: statusCode,
|
17
|
+
statusText: httpStatusCodes[statusCode],
|
18
|
+
headers: {'Content-Type': 'application/json'},
|
19
|
+
}));
|
20
|
+
},
|
21
|
+
html: async (statusCode: number, content: string): Promise<Response> => {
|
22
|
+
return Promise.resolve(new Response(content, {
|
23
|
+
status: statusCode,
|
24
|
+
statusText: httpStatusCodes[statusCode],
|
25
|
+
headers: {'Content-Type': 'text/html; charset=utf-8'}
|
26
|
+
}));
|
27
|
+
},
|
28
|
+
file: async (statusCode: number, fp: string): Promise<Response> => {
|
29
|
+
const file = Bun.file(fp);
|
30
|
+
const exists = await file.exists();
|
28
31
|
|
29
|
-
|
32
|
+
if (!exists) return http.notFound(`File not found: ${fp}`);
|
30
33
|
|
31
|
-
|
32
|
-
|
34
|
+
const content = await file.arrayBuffer();
|
35
|
+
if (!content) return http.noContent();
|
33
36
|
|
34
|
-
|
37
|
+
let contentType = 'text/html; charset=utf-9';
|
35
38
|
|
36
|
-
|
37
|
-
|
39
|
+
if (file.type.includes('image'))
|
40
|
+
contentType = file.type + '; charset=utf-8';
|
38
41
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
42
|
+
return Promise.resolve(new Response(content, {
|
43
|
+
status: statusCode,
|
44
|
+
statusText: httpStatusCodes[statusCode],
|
45
|
+
headers: { 'Content-Type': contentType}
|
46
|
+
}));
|
47
|
+
},
|
48
|
+
render: async (component: ReactNode): Promise<Response> => {
|
49
|
+
const stream = await renderToReadableStream(component);
|
50
|
+
return new Response(stream, { status: 200, statusText: httpStatusCodes[200]});
|
51
|
+
},
|
52
|
+
noContent: async (): Promise<Response> => Promise.resolve(new Response('no content', {
|
53
|
+
status: 204,
|
54
|
+
statusText: 'no content',
|
55
|
+
})),
|
56
|
+
notFound: async(msg?: string): Promise<Response> => {
|
57
|
+
const response = new Response(msg ?? 'not found', {
|
58
|
+
status: 404,
|
59
|
+
statusText: httpStatusCodes[404],
|
60
|
+
headers: {'Content-Type': 'text/html'},
|
61
|
+
});
|
55
62
|
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
63
|
+
return Promise.resolve(response);
|
64
|
+
},
|
65
|
+
methodNotAllowed: async (msg?: string): Promise<Response> => {
|
66
|
+
const response = new Response(msg ?? 'method not allowed', {
|
67
|
+
status: 405,
|
68
|
+
statusText: httpStatusCodes[405],
|
69
|
+
headers: {'Content-Type': 'text/html'},
|
70
|
+
});
|
64
71
|
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
}
|
72
|
+
return Promise.resolve(response);
|
73
|
+
},
|
74
|
+
message: async (status: number, msg?: string): Promise<Response> => {
|
75
|
+
const response = new Response(msg ?? '?', {
|
76
|
+
status: status,
|
77
|
+
statusText: httpStatusCodes[status],
|
78
|
+
headers: {'Content-Type': 'text/html; charset-utf-8'},
|
79
|
+
});
|
80
|
+
return Promise.resolve(response);
|
81
|
+
},
|
82
|
+
};
|
76
83
|
|
77
|
-
export { http }
|
84
|
+
export { http };
|
package/lib/logger/logger.ts
CHANGED
@@ -8,87 +8,87 @@ _ _
|
|
8
8
|
| . | | | | | _| . | | | _| -_| _|
|
9
9
|
|___|___|_|_| |_| |___|___|_| |___|_|
|
10
10
|
|
11
|
-
|
12
|
-
const VERSION = '0.7.
|
11
|
+
`;
|
12
|
+
const VERSION = '0.7.4-experimental';
|
13
13
|
const Logger = (): BunLogger => {
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
14
|
+
return {
|
15
|
+
info: async (statusCode: number, routePath: string, method: string, message?: string) => {
|
16
|
+
const { stamp } = timestamp((new Date(Date.now())));
|
17
|
+
const source = color('green', 'bgBlack', `[bun-router ${stamp}]`);
|
18
|
+
const rp = color('white', 'bgBlack', routePath);
|
19
19
|
|
20
|
-
|
20
|
+
message = `${source}: ${setColor(statusCode)}: ${rp} ${(method === 'GET') ? ' ->' : ' <-'} ${method} ${ message ?? ''}\n`;
|
21
21
|
|
22
|
-
|
22
|
+
await Bun.write(Bun.stdout, message);
|
23
23
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
24
|
+
},
|
25
|
+
error: async (statusCode: number, routePath: string, method: string, error: Error) => {
|
26
|
+
const { stamp } = timestamp((new Date(Date.now())));
|
27
|
+
const source = color('black', 'bgRed', `[error ${stamp}]`);
|
28
|
+
const rp = color('white', 'bgBlack', routePath);
|
29
29
|
|
30
|
-
|
30
|
+
const message = `${source}: ${setColor(statusCode)}: ${rp} ${(method === 'GET') ? ' -> ' : ' <-'} ${error.message}\n`;
|
31
31
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
32
|
+
await Bun.write(Bun.stdout, message);
|
33
|
+
},
|
34
|
+
warn: async (message: string) => {
|
35
|
+
const { stamp } = timestamp((new Date(Date.now())));
|
36
|
+
const source = color('black', 'bgYellow', `[warning ${stamp}]`);
|
37
|
+
const messageColor = color('yellow', 'bgBlack', message);
|
38
38
|
|
39
|
-
|
39
|
+
message = `${source} : ${messageColor}\n`;
|
40
40
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
41
|
+
await Bun.write(Bun.stdout, message);
|
42
|
+
},
|
43
|
+
message: async (message: string) => {
|
44
|
+
const { stamp } = timestamp((new Date(Date.now())));
|
45
|
+
const source = color('black', 'bgCyan', `[message ${stamp}]`);
|
46
|
+
const messageColor = color('yellow', 'bgBlack', message);
|
47
47
|
|
48
|
-
|
48
|
+
message = `${source}: ${messageColor}\n`;
|
49
49
|
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
}
|
50
|
+
await Bun.write(Bun.stdout, message);
|
51
|
+
},
|
52
|
+
};
|
53
|
+
};
|
54
54
|
|
55
55
|
function timestamp(date: Date) {
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
56
|
+
const month = pad(date.getMonth());
|
57
|
+
const day = pad(date.getDate());
|
58
|
+
const hour = pad(date.getHours());
|
59
|
+
const minute = pad(date.getMinutes());
|
60
|
+
const seconds = pad(date.getSeconds());
|
61
|
+
const stamp = `${hour}:${minute}:${seconds}`;
|
62
|
+
|
63
|
+
return {month, day, hour, minute, stamp};
|
64
64
|
}
|
65
65
|
|
66
66
|
function setColor(n: number, text?: string){
|
67
|
-
|
67
|
+
const s = ` [${String(n)}${text ?? ''}] `;
|
68
68
|
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
69
|
+
if (n < 100) return color('black', 'bgYellow', s);
|
70
|
+
else if (n >= 100 && n < 200) return color('black', 'bgCyan', s);
|
71
|
+
else if (n >= 200 && n < 300) return color('black', 'bgGreen', s);
|
72
|
+
else if (n >= 300 && n < 400) return color('black', 'bgRed', s);
|
73
|
+
else if (n >= 400 && n < 500) return color('black', 'bgRed', s);
|
74
|
+
else if (n >= 500) return color('white', 'bgRed', s);
|
75
75
|
|
76
|
-
|
76
|
+
return color('white', 'bgBlack', `[${s}]`).trim();
|
77
77
|
}
|
78
78
|
|
79
79
|
function startMessage(port: number | string) {
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
80
|
+
const { stamp } = timestamp((new Date(Date.now())));
|
81
|
+
const source = color('green', 'bgBlack', `[bun-router ${stamp}]`);
|
82
|
+
const portColor = color('green', 'bgBlack', String(port));
|
83
|
+
const msg = `${source}: Starting Server on :${portColor}\n`;
|
84
|
+
const version = color('red', 'bgBlack', `v${VERSION}\n`);
|
85
|
+
|
86
|
+
Bun.write(Bun.stdout, TITLE + '\n' + version);
|
87
|
+
Bun.write(Bun.stdout, msg);
|
88
88
|
}
|
89
89
|
|
90
90
|
function pad(n: number) {
|
91
|
-
|
91
|
+
return String(n).padStart(2, '0');
|
92
92
|
}
|
93
93
|
|
94
|
-
export { Logger, startMessage }
|
94
|
+
export { Logger, startMessage };
|
@@ -0,0 +1,28 @@
|
|
1
|
+
import { FileTree } from '../fs/filetree';
|
2
|
+
import { readDir } from '../fs/fsys';
|
3
|
+
import { ComponentType } from 'react';
|
4
|
+
|
5
|
+
async function createFileTree(root: string) {
|
6
|
+
const tree = FileTree(root);
|
7
|
+
|
8
|
+
await readDir(root, (fp) => {
|
9
|
+
tree.addFile(fp);
|
10
|
+
});
|
11
|
+
|
12
|
+
return tree;
|
13
|
+
}
|
14
|
+
|
15
|
+
async function resolveModules(root: string) {
|
16
|
+
const tree = await createFileTree(root);
|
17
|
+
const files = tree.getFilesByExtension('.tsx');
|
18
|
+
const modules: ComponentType[] = [];
|
19
|
+
|
20
|
+
for (const file of files) {
|
21
|
+
const module = await import(file) as ComponentType;
|
22
|
+
modules.push(module);
|
23
|
+
}
|
24
|
+
|
25
|
+
return modules;
|
26
|
+
}
|
27
|
+
|
28
|
+
export { resolveModules };
|
package/lib/router/context.ts
CHANGED
@@ -1,51 +1,58 @@
|
|
1
|
-
|
2
|
-
import {
|
3
|
-
import {
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
2
|
+
import { Route, Context } from './router.d';
|
3
|
+
import { renderToReadableStream } from 'react-dom/server';
|
4
|
+
import { Logger } from '../logger/logger';
|
5
|
+
import { http } from './router';
|
6
|
+
import { ReactNode } from 'react';
|
4
7
|
|
5
8
|
async function createContext(path: string, route: Route, request: Request): Promise<Context> {
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
9
|
+
const params = extractParams(path, route);
|
10
|
+
const query = new URLSearchParams(path);
|
11
|
+
const formData = isMultiPartForm(request.headers) ? await request.formData() : new FormData();
|
12
|
+
|
13
|
+
return Promise.resolve({
|
14
|
+
params,
|
15
|
+
request,
|
16
|
+
query,
|
17
|
+
formData,
|
18
|
+
logger: Logger(),
|
19
|
+
json: (statusCode: number, data: any) => http.json(statusCode, data),
|
20
|
+
render: async (component: ReactNode) => await renderStream(component),
|
21
|
+
});
|
18
22
|
}
|
19
23
|
|
20
24
|
function extractParams(path: string, route: Route): Map<string, string> {
|
21
|
-
|
22
|
-
|
23
|
-
|
25
|
+
const params: Map<string, string> = new Map();
|
26
|
+
const pathSegments = path.split('/');
|
27
|
+
const routeSegments = route.path.split('/');
|
24
28
|
|
25
|
-
|
29
|
+
if (pathSegments.length !== routeSegments.length) return params;
|
26
30
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
31
|
+
for (let i = 0; i < pathSegments.length; i++) {
|
32
|
+
if (routeSegments[i][0] === ':') {
|
33
|
+
const key = routeSegments[i].replace(':', '');
|
34
|
+
const value = pathSegments[i];
|
35
|
+
params.set(key, value);
|
36
|
+
}
|
37
|
+
}
|
34
38
|
|
35
|
-
|
39
|
+
return params;
|
36
40
|
}
|
37
41
|
|
38
42
|
function getContentType(headers: Headers): string {
|
39
|
-
|
40
|
-
|
41
|
-
|
43
|
+
const contentType = headers.get('Content-Type');
|
44
|
+
if (!contentType) return '';
|
45
|
+
return contentType;
|
42
46
|
}
|
43
47
|
|
44
48
|
function isMultiPartForm(headers: Headers): boolean {
|
45
|
-
|
46
|
-
|
49
|
+
const contentType = getContentType(headers);
|
50
|
+
return contentType.includes('multipart/form-data');
|
47
51
|
}
|
48
52
|
|
53
|
+
async function renderStream(children: ReactNode) {
|
54
|
+
const stream = await renderToReadableStream(children);
|
55
|
+
return new Response(stream, { headers: { 'Content-Type': 'text/html' } });
|
56
|
+
}
|
49
57
|
|
50
|
-
|
51
|
-
export { createContext }
|
58
|
+
export { createContext };
|
package/lib/router/router.d.ts
CHANGED
@@ -1,4 +1,5 @@
|
|
1
|
-
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
2
|
+
import { TLSWebSocketServeOptions, WebSocketServeOptions, ServeOptions, TLSServeOptions } from 'bun';
|
2
3
|
import { Logger } from '../logger/logger';
|
3
4
|
import { Database } from 'bun:sqlite';
|
4
5
|
|
@@ -20,7 +21,6 @@ type Route = {
|
|
20
21
|
handler: HttpHandler;
|
21
22
|
isLast: boolean;
|
22
23
|
}
|
23
|
-
|
24
24
|
type Context = {
|
25
25
|
db?: Database;
|
26
26
|
formData: FormData | Promise<FormData>;
|
@@ -29,6 +29,7 @@ type Context = {
|
|
29
29
|
params: Map<string, string>;
|
30
30
|
query: URLSearchParams;
|
31
31
|
request: Request;
|
32
|
+
render: (component: React.ReactNode) => Response | Promise<Response>;
|
32
33
|
};
|
33
34
|
|
34
35
|
type HttpHandler = (ctx: Context) => Response | Promise<Response>
|
@@ -43,4 +44,4 @@ type RouterOptions<Options> = ServeOptions
|
|
43
44
|
| TLSWebSocketServeOptions<Options>
|
44
45
|
| undefined
|
45
46
|
|
46
|
-
export { Context , Route, BunRouter, RouterOptions, Options, HttpHandler }
|
47
|
+
export { Context , Route, BunRouter, RouterOptions, Options, HttpHandler };
|
package/lib/router/router.ts
CHANGED
@@ -5,96 +5,111 @@ 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 './tree';
|
8
|
+
import { RouteTree } from './tree';
|
9
9
|
import { createContext } from './context';
|
10
10
|
|
11
|
-
|
12
11
|
const Router: BunRouter = (port?: number | string, options?: RouterOptions<Options>) => {
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
12
|
+
const { addRoute, findRoute } = RouteTree();
|
13
|
+
const logger = Logger();
|
14
|
+
|
15
|
+
async function loadComponent(name: string) {
|
16
|
+
const module = await import(name);
|
17
|
+
return module.default;
|
18
|
+
}
|
19
|
+
|
20
|
+
return {
|
21
|
+
// add a route to the router tree
|
22
|
+
add: (pattern, method, callback) => { addRoute(pattern, method, callback); },
|
23
|
+
get: (pattern: string, callback: HttpHandler) => { addRoute(pattern, 'GET', callback); },
|
24
|
+
post: (pattern, callback) => { addRoute(pattern, 'POST', callback); },
|
25
|
+
put: (pattern, callback) => { addRoute(pattern, 'PUT', callback);},
|
26
|
+
delete: (pattern, callback) => { addRoute(pattern, 'DELETE', callback); },
|
23
27
|
|
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
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
28
|
+
// add static routes to the router tree
|
29
|
+
// .tsx and .html are rendered as components
|
30
|
+
// all other file extensions are served as files
|
31
|
+
// the root directory is traversed recursively
|
32
|
+
static: async (pattern: string, root: string) => {
|
33
|
+
await readDir(root, async (fp) => {
|
34
|
+
const pure = path.join('.', fp);
|
35
|
+
const ext = path.extname(pure);
|
36
|
+
|
37
|
+
let base = path.basename(pure);
|
38
|
+
|
39
|
+
//FIXME: this can be improved
|
40
|
+
if (ext === '.html') base = base.replace(ext, '');
|
41
|
+
if (ext === '.tsx') base = base.replace(ext, '');
|
42
|
+
|
43
|
+
if (pattern[0] !== '/') pattern = '/' + pattern;
|
44
|
+
|
45
|
+
let patternPath = pattern + base;
|
46
|
+
|
47
|
+
if (base === 'index') patternPath = pattern;
|
48
|
+
|
49
|
+
const route: Route = {
|
50
|
+
children: new Map(),
|
51
|
+
dynamicPath: '',
|
52
|
+
isLast: true,
|
53
|
+
path: patternPath,
|
54
|
+
method: 'GET',
|
55
|
+
handler: async () => {
|
56
|
+
if (ext === '.tsx') {
|
57
|
+
const component = await loadComponent(path.join(root, patternPath));
|
58
|
+
return await http.render(component());
|
59
|
+
} else {
|
60
|
+
return await http.file(200, pure);
|
61
|
+
}
|
62
|
+
},
|
63
|
+
};
|
64
|
+
|
65
|
+
addRoute(route.path, 'GET', route.handler);
|
66
|
+
});
|
67
|
+
},
|
68
|
+
// start the server
|
69
|
+
serve: () => {
|
70
|
+
startMessage(port ?? 3000);
|
71
|
+
const opts: Options = { db: ':memory:' };
|
72
|
+
|
73
|
+
Bun.serve({
|
74
|
+
port: port ?? 3000,
|
75
|
+
...options,
|
76
|
+
async fetch(req) {
|
77
|
+
const url = new URL(req.url);
|
78
|
+
const path = url.pathname;
|
79
|
+
|
80
|
+
// set the database
|
81
|
+
if (options) {
|
82
|
+
const o = options as Options;
|
83
|
+
opts.db = o.db;
|
84
|
+
}
|
85
|
+
|
86
|
+
const route = findRoute(path);
|
87
|
+
|
88
|
+
// if the route exists, execute the handler
|
89
|
+
if (route) {
|
90
|
+
if (route.method !== req.method) {
|
91
|
+
logger.info(405, url.pathname, req.method, httpStatusCodes[405]);
|
92
|
+
return Promise.resolve(http.methodNotAllowed());
|
93
|
+
}
|
94
|
+
|
95
|
+
const context = await createContext(path, route, req);
|
96
|
+
context.db = new Database(opts.db);
|
97
|
+
|
98
|
+
const response = await route.handler(context);
|
99
|
+
|
100
|
+
logger.info(response.status, url.pathname, req.method, httpStatusCodes[response.status]);
|
101
|
+
return Promise.resolve(response);
|
102
|
+
}
|
103
|
+
|
104
|
+
// if no route is found, return 404
|
105
|
+
const response = await http.notFound();
|
90
106
|
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
export { Router, http }
|
107
|
+
logger.info(response.status, url.pathname, req.method, httpStatusCodes[response.status]);
|
108
|
+
return Promise.resolve(http.notFound());
|
109
|
+
}
|
110
|
+
});
|
111
|
+
},
|
112
|
+
};
|
113
|
+
};
|
114
|
+
|
115
|
+
export { Router, http };
|
package/lib/router/tree.ts
CHANGED
@@ -1,63 +1,62 @@
|
|
1
|
-
import { HttpHandler, Route } from
|
2
|
-
import { http } from
|
1
|
+
import { HttpHandler, Route } from './router.d';
|
2
|
+
import { http } from '../http/http';
|
3
3
|
import { createContext } from './context';
|
4
|
-
|
5
|
-
const splitPath = (s: string): string[] => s.split('/').filter(x => x !== '');
|
4
|
+
import { splitPath } from '../util/strings';
|
6
5
|
|
7
6
|
const createRoute = (path: string, method: string, handler: HttpHandler): Route => {
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
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;
|
18
17
|
};
|
19
18
|
|
20
19
|
const RouteTree = () => {
|
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
|
-
|
20
|
+
const root = createRoute('', 'GET', () => http.notFound());
|
21
|
+
|
22
|
+
const addRoute = (path: string, method: string, handler: HttpHandler) => {
|
23
|
+
const pathParts = splitPath(path);
|
24
|
+
let current = root;
|
25
|
+
|
26
|
+
for (let i = 0; i < pathParts.length; i++) {
|
27
|
+
const part = pathParts[i];
|
28
|
+
if (part.startsWith(':')) {
|
29
|
+
current.dynamicPath = part;
|
30
|
+
}
|
31
|
+
if (!current.children.has(part)) {
|
32
|
+
current.children.set(part, createRoute(part, method, handler));
|
33
|
+
}
|
34
|
+
current = current.children.get(part)!;
|
35
|
+
}
|
36
|
+
|
37
|
+
current.handler = handler;
|
38
|
+
current.isLast = true;
|
39
|
+
current.path = path;
|
40
|
+
};
|
41
|
+
|
42
|
+
function findRoute(path: string): Route | undefined {
|
43
|
+
const pathParts = splitPath(path);
|
44
|
+
let current = root;
|
45
|
+
for (let i = 0; i < pathParts.length; i++) {
|
46
|
+
const part = pathParts[i];
|
47
|
+
if (current.children.has(part)) {
|
48
|
+
current = current.children.get(part)!;
|
49
|
+
} else if (current.dynamicPath) {
|
50
|
+
current = current.children.get(current.dynamicPath)!;
|
51
|
+
} else {
|
52
|
+
return;
|
53
|
+
}
|
54
|
+
}
|
55
|
+
return current;
|
56
|
+
}
|
57
|
+
|
58
|
+
return { addRoute, findRoute };
|
60
59
|
|
61
60
|
};
|
62
61
|
|
63
|
-
export { RouteTree, createContext }
|
62
|
+
export { RouteTree, createContext };
|
package/package.json
CHANGED
@@ -3,10 +3,19 @@
|
|
3
3
|
"module": "index.ts",
|
4
4
|
"type": "module",
|
5
5
|
"devDependencies": {
|
6
|
-
"
|
6
|
+
"@types/react-dom": "^18.2.7",
|
7
|
+
"@typescript-eslint/eslint-plugin": "^6.7.0",
|
8
|
+
"@typescript-eslint/parser": "^6.7.0",
|
9
|
+
"bun-types": "latest",
|
10
|
+
"eslint": "^8.49.0",
|
11
|
+
"eslint-plugin-react": "^7.33.2",
|
12
|
+
"react-dom": "^18.2.0"
|
7
13
|
},
|
8
14
|
"peerDependencies": {
|
9
15
|
"typescript": "^5.0.0"
|
10
16
|
},
|
11
|
-
"version": "0.7.
|
17
|
+
"version": "0.7.4-experimental.2",
|
18
|
+
"dependencies": {
|
19
|
+
"eslint-plugin-react-hooks": "^4.6.0"
|
20
|
+
}
|
12
21
|
}
|
package/tsconfig.json
CHANGED
package/examples/db.ts
DELETED