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 CHANGED
@@ -29,6 +29,7 @@
29
29
  "semi": [
30
30
  "error",
31
31
  "always"
32
- ]
32
+ ],
33
+ "no-control-regex": "off"
33
34
  }
34
35
  }
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
- const name = ctx.params.get('name');
9
- if (!name) return http.json(500, 'no name');
10
- return http.json(200, name);
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();
@@ -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
- 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
- }
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
- 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
- }
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
 
@@ -3,9 +3,9 @@ import { Router, http } from '..';
3
3
  const r = Router();
4
4
 
5
5
  r.get('/', ctx => {
6
- ctx.logger.debug('hello from home');
6
+ ctx.logger.debug('hello from home');
7
7
 
8
- return http.ok();
8
+ return http.ok();
9
9
  });
10
10
 
11
11
  r.serve();
@@ -0,0 +1,8 @@
1
+ import { Router, http } from '../../';
2
+
3
+ const router = Router();
4
+
5
+ router.add('/', 'GET', () => http.ok());
6
+ router.static('/page', './examples/ssr/pages');
7
+
8
+ router.serve();
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+
3
+ export default function Foo() {
4
+ return (
5
+ <h1>Foo </h1>
6
+ );
7
+ }
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+
3
+ export default function Home() {
4
+ return (
5
+ <h1>Hello World</h1>
6
+ );
7
+ }
@@ -3,4 +3,5 @@ import { Router } from '..';
3
3
  const r = Router(3001);
4
4
 
5
5
  r.static('/', './examples/pages');
6
+
6
7
  r.serve();
package/examples/todo.ts CHANGED
@@ -1,45 +1,45 @@
1
1
  import { Router, http } from '..';
2
2
 
3
3
  const Todo = () => {
4
- const list: Record<string, string> = {};
4
+ const list: Record<string, string> = {};
5
5
 
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
- }
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
- const query = new URL(ctx.request.url).searchParams;
21
- const key = query.get('key');
22
- const content = query.get('content');
20
+ const query = new URL(ctx.request.url).searchParams;
21
+ const key = query.get('key');
22
+ const content = query.get('content');
23
23
 
24
- if (!key || !content) return http.message(400, 'invalid query params');
25
- ctx.logger.message(`Adding ${key} with ${content}`);
26
- todo.add(key, content);
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
- return ctx.json(200, { message: 'ok' });
28
+ return ctx.json(200, { message: 'ok' });
29
29
  });
30
30
 
31
31
  r.add('/api/todo/:key', 'GET', ctx => {
32
- const key = ctx.params.get('key');
33
- if (!key) return http.message(400, 'invalid params');
32
+ const key = ctx.params.get('key');
33
+ if (!key) return http.message(400, 'invalid params');
34
34
 
35
- const content = todo.get(key);
36
- if (!content) return http.notFound();
35
+ const content = todo.get(key);
36
+ if (!content) return http.notFound();
37
37
 
38
- return ctx.json(200, {key: key, content: content});
38
+ return ctx.json(200, {key: key, content: content});
39
39
  });
40
40
 
41
41
  r.add('/api/get/all', 'GET', ctx => {
42
- return ctx.json(200, todo.export());
42
+ return ctx.json(200, todo.export());
43
43
  });
44
44
 
45
45
  r.serve();
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+
3
+ export function User(username: string) {
4
+ return (
5
+ <h1>{ username }</h1>
6
+ );
7
+ }
@@ -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
@@ -1,3 +1,4 @@
1
1
  export * from './lib/router/router';
2
2
  export * from './lib/fs/fsys';
3
3
  export * from './lib/logger/logger';
4
+ export * from './lib/router/router.d';
@@ -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 "bun";
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
- const files = await fs.readdir(dirpath);
10
+ const files = await fs.readdir(dirpath);
10
11
 
11
- for (const file of files) {
12
- const bunFile = Bun.file(file);
12
+ for (const file of files) {
13
+ const bunFile = Bun.file(file);
13
14
 
14
- if (typeof bunFile.name === 'undefined') return
15
+ if (typeof bunFile.name === 'undefined') return;
15
16
 
16
- const fp = path.join(dirpath, bunFile.name);
17
- const isdir = await isDir(fp);
17
+ const fp = path.join(dirpath, bunFile.name);
18
+ const isdir = await isDir(fp);
18
19
 
19
- if (isdir) await readDir(fp, handler);
20
- else handler(fp, bunFile);
21
- }
20
+ if (isdir) await readDir(fp, handler);
21
+ else handler(fp, bunFile);
22
+ }
22
23
  }
23
24
 
24
- function ext(path: string): string {
25
- return path.split('.').pop() || '';
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, ext }
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',
@@ -1,66 +1,66 @@
1
1
  const httpStatusCodes: { [key: number]: string } = {
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
- };
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
- export { httpStatusCodes }
66
+ export { httpStatusCodes };
@@ -1,36 +1,36 @@
1
1
  const Colors: Record<string,string> = {
2
- reset: "\x1b[0m",
2
+ reset: '\x1b[0m',
3
3
 
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",
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
- // 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;
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
- const _foreground = Colors[foreground];
29
- const _background = Colors[background];
30
- const reset = Colors.reset;
31
- return `${_foreground}${_background}${message}${reset}`;
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 };
@@ -5,4 +5,4 @@ type BunLogger = {
5
5
  message: (message: string) => void,
6
6
  }
7
7
 
8
- export { BunLogger }
8
+ export { BunLogger };
@@ -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 = (): BunLogger => {
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 Bun.write(Bun.stdout, message);
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 Bun.write(Bun.stdout, message);
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 Bun.write(Bun.stdout, message);
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 Bun.write(Bun.stdout, message);
65
+ await write(message);
51
66
  },
67
+
52
68
  };
53
69
  };
54
70
 
@@ -5,9 +5,10 @@ import { Logger } from '../logger/logger';
5
5
  import { http } from './router';
6
6
  import { ReactNode } from 'react';
7
7
 
8
- async function createContext(path: string, route: Route, request: Request): Promise<Context> {
9
- const params = extractParams(path, route);
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
- function extractParams(path: string, route: Route): Map<string, string> {
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 = path.split('/');
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].replace(':', '');
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
- if (!contentType) return '';
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
- const addRoute = (path: string, method: string, handler: HttpHandler) => {
23
- const pathParts = splitPath(path);
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 = path;
40
- };
39
+ current.path = pattern;
40
+ }
41
41
 
42
- function findRoute(path: string): Route | undefined {
43
- const pathParts = splitPath(path);
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
- return { addRoute, findRoute };
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 as createContext };
86
+ export { RouteTree, createContext };
@@ -35,7 +35,8 @@ type Context = {
35
35
  type HttpHandler = (ctx: Context) => Response | Promise<Response>
36
36
 
37
37
  type Options = {
38
- db: string,
38
+ db: string;
39
+ enableFileLogging: boolean;
39
40
  }
40
41
 
41
42
  type RouterOptions<Options> = ServeOptions
@@ -1,96 +1,129 @@
1
1
  import path from 'path';
2
2
  import { Database } from 'bun:sqlite';
3
- import { Route, BunRouter, RouterOptions, Options, HttpHandler } from './router.d';
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 './tree';
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: string, callback: HttpHandler) => { addRoute(pattern, 'GET', callback); },
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 a static route to the router tree
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 pure = path.join('.', fp);
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 () => await http.file(200, pure),
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 the server
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 path = url.pathname;
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(path);
96
+ const route = findRoute(pathname);
71
97
 
72
- // if the route exists, execute the handler
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
- const context = await createContext(path, route, req);
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-dom": "^18.2.7",
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.0",
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
  }
@@ -1,35 +1,36 @@
1
1
  import { describe, test, expect } from 'bun:test';
2
2
 
3
3
  describe('Router', () => {
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
- });
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
- const text = await new Response(proc.stdout).text();
11
+ const text = await new Response(proc.stdout).text();
12
12
 
13
- const hasFailed = text.includes('Failed');
13
+ const hasFailed = text.includes('Failed');
14
14
 
15
- if (hasFailed) console.log(text);
15
+ if (hasFailed) console.log(text);
16
16
 
17
- expect(hasFailed).toBe(false);
17
+ expect(hasFailed).toBe(false);
18
18
 
19
- proc.kill(0);
20
- })
19
+ proc.kill(0);
20
+ });
21
21
 
22
- test('Static', async() => {
23
- const proc = Bun.spawn(['./tests/static.test.sh']);
22
+ test('Static', async() => {
23
+ const proc = Bun.spawn(['./tests/static.test.sh']);
24
24
 
25
- const text = await new Response(proc.stdout).text();
25
+ const text = await new Response(proc.stdout).text();
26
26
 
27
- const hasFailed = text.includes('Failed');
28
- if (hasFailed) console.log(text);
27
+ const hasFailed = text.includes('Failed');
28
+ if (hasFailed) console.log(text);
29
29
 
30
- expect(hasFailed).toBe(false);
30
+ expect(hasFailed).toBe(false);
31
31
 
32
- proc.kill(0);
33
- });
32
+ proc.kill(0);
33
+ });
34
34
  });
35
35
 
36
+
package/tsconfig.json CHANGED
@@ -17,6 +17,9 @@
17
17
  "noEmit": true,
18
18
  "types": [
19
19
  "bun-types" // add Bun global
20
- ]
21
- }
20
+ ],
21
+ "paths": {
22
+ "/examples/*": ["./examples/*"]
23
+ }
24
+ },
22
25
  }
package/examples/db.ts DELETED
@@ -1,10 +0,0 @@
1
- import { Router, http } from '..';
2
-
3
- const router = Router(3000, {db: './dbs/test.db'});
4
-
5
- router.add('/', 'GET', async (ctx) => {
6
- const formData = await ctx.formData;
7
- const name = formData?.get('name');
8
- return http.ok();
9
- });
10
-
@@ -1,3 +0,0 @@
1
- const splitPath = (path: string): string[] => path.split('/').filter(Boolean);
2
-
3
- export { splitPath }