bun-router 0.7.4-experimental.0 → 0.7.4-experimental.12

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 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 }