galbe 0.1.8 → 0.1.13

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.
@@ -32,7 +32,7 @@ jobs:
32
32
  git config user.name "${GITHUB_ACTOR}"
33
33
  - name: Install dependencies & build
34
34
  run: |
35
- bun install & bun run build
35
+ bun install && bun run build
36
36
  - name: Release
37
37
  run: |
38
38
  npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN
@@ -0,0 +1,114 @@
1
+ # Context
2
+
3
+ An instance of the context object is created when a new request is initiated and carrieds out along durring all the request lifecycle.
4
+ See the [Lifecycle](lifecycle) section to get more details.
5
+
6
+ Its purpose is to carrie all the relevent information about the request and to allow sharing informations between each step of the request lifecycle.
7
+
8
+ ## Definition
9
+
10
+ A context has the following properties:
11
+
12
+ **request**
13
+
14
+ An instance of the [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object created by th server.
15
+
16
+ **headers**
17
+
18
+ A javascript object representing the `headers` of the current request.
19
+
20
+ - key (string): header name
21
+ - value: (string | [schema defined](schemas.md#headers)): header value
22
+
23
+ ```js
24
+ {
25
+ "accept": "*/*",
26
+ "accept-encoding": "gzip, deflate, br",
27
+ "cookie": "Cookie_1=value; Cookie_2=value",
28
+ "host": "localhost:3000",
29
+ "user-agent": "galbe/1.0.0"
30
+ }
31
+ ```
32
+
33
+ **params**
34
+
35
+ A javascript object representing the request `parameters` of the current request.
36
+
37
+ - key (string): parameter name
38
+ - value: (string | [schema defined](schemas.md#params)): parameter value
39
+
40
+ ```js
41
+ galbe.get('/default/:p1/foo/:p2', ctx => console.log(ctx.params))
42
+ // GET /default/four/foo/2
43
+ { p1: "four", p2: "2" }
44
+ ```
45
+
46
+ **query**
47
+
48
+ A javascript object representing the request `query parameters` of the current request.
49
+
50
+ - key (string): query parameter name
51
+ - value: (string | [schema defined](schemas.md#query)): query parameter value
52
+
53
+ ```js
54
+ galbe.get('/test', ctx => console.log(ctx.params))
55
+ // GET /test?one=1&two=2
56
+ { one: "1", two: "2" }
57
+ ```
58
+
59
+ **body**
60
+
61
+ The body payload of the incoming request. The body type is computed according to the following rules.
62
+
63
+ If no [Schema](schemas.md) is defined, Galbe will parse the body type according to `content-type` Header value:
64
+
65
+ - `text/.*`: string
66
+ - `application/json`: object
67
+ - `application/x-www-form-urlencoded`: { [key: string]: any }
68
+ - `multipart/form-data`: { [key: string]:
69
+ { headers: { name: string; type?: string; filename?: string };
70
+ content: any
71
+ } }
72
+ - `other`: AsyncGenerator\<Uint8Array\>
73
+
74
+ If a [Schema](schemas.md) is defined, Galbe will parse the body type according to the [Schema.body](schemas.md#body) defined for the current route.
75
+
76
+ **set**
77
+
78
+ The set property contains modifiable properties which purpose are to give informations to the Response parser.
79
+
80
+ - `status`: Set the response status
81
+ - `headers`: Set the response headers
82
+
83
+ ```js
84
+ galbe.get('/example', ctx => {
85
+ ctx.set.status = 418
86
+ return "I don't do coffee"
87
+ })
88
+ ```
89
+
90
+ **state**
91
+
92
+ The state property purpose is to carry custom user object accross request lifecycle. In general it is used to share informations between the [hooks](hooks.md) and the [handler](handler.md).
93
+
94
+ - key (string): user defined key
95
+ - value (any): user defined object
96
+
97
+ ```js
98
+ galbe.get(
99
+ '/example',
100
+ [
101
+ ctx => {
102
+ ctx.state['foo'] = 'bar'
103
+ }
104
+ ],
105
+ ctx => {
106
+ return ctx.state.foo
107
+ }
108
+ )
109
+ ```
110
+
111
+ ```bash
112
+ $ curl http://localhost:3000/example
113
+ bar
114
+ ```
@@ -0,0 +1,54 @@
1
+ # Error handler
2
+
3
+ Any error happening during a request lifecycle will be intercepted by the error handler.
4
+
5
+ You can customize the default error handling behavior by defining a custom error handler using Galbe's intance `onError` method.
6
+
7
+ ```js
8
+ const galbe = new Galbe()
9
+ galbe.onError(customErrorHandler)
10
+ ```
11
+
12
+ ## Definition
13
+
14
+ The error handler should be a function that takes two aguments: an [Error](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) and a [Context](context.md). This function may potentially return a [Response type](handler.md#response-types).
15
+
16
+ ```js
17
+ galbe.onErrorHandler((error, ctx) => {
18
+ if (error.status === 500) {
19
+ return new Response(`Server error ❌`, { status: 500 })
20
+ }
21
+ if (error.status === 404) {
22
+ return new Response(`Not found 🔎`, { status: 404 })
23
+ }
24
+ })
25
+ ```
26
+
27
+ The `error` argument could be any type of error thrown by your application. If the error originates from Galbe framework, it will be an instance of [RequestError](#request-error).
28
+
29
+ For instance, the [Router](router.md) will throw a `RequestError` with a `404` status if no route matches the incoming request path. Similarly, the Parser will throw a `RequestError` with a `400` status.
30
+
31
+ ## Request Error
32
+
33
+ The `RequestError` class is utilized to instanciate a runtime request error in Galbe. It has two optional attributes: a `status` and a `payload`.
34
+
35
+ If your application throws a `RequestError` instance, Galbe will, by default, construct a Response from your `RequestError` and send it back to the client.
36
+
37
+ ```js
38
+ import { Galbe, RequestError } from 'galbe'
39
+
40
+ const galbe = new Galbe()
41
+
42
+ galbe.get('/coffee', () => throw new RequestError({ status: 418, payload: '🫖' }))
43
+ ```
44
+
45
+ When called, above endpoint should respond:
46
+
47
+ ```bash
48
+ $ curl -i http://localhost:3000/coffee
49
+ HTTP/1.1 418 I'm a Teapot
50
+ Content-Type: application/json
51
+ Content-Length: 6
52
+
53
+ "🫖"
54
+ ```
@@ -8,14 +8,14 @@ Designed with simplicity in mind, Galbe allows you to quickly create and set up
8
8
 
9
9
  To start developing your Galbe project, you first need to install [Bun](https://bun.sh).
10
10
 
11
- ## Automatic Installation
11
+ ## Automatic installation
12
12
 
13
13
  This is the recommended way of setting up a Galbe project.
14
14
 
15
15
  ```bash
16
- bun create galbe app
17
- cd app
18
- bun install
16
+ $ bun create galbe app
17
+ $ cd app
18
+ $ bun install
19
19
  ```
20
20
 
21
21
  This will create a new project under `app` directory and install it.
@@ -23,7 +23,7 @@ This will create a new project under `app` directory and install it.
23
23
  Now you can start the dev server by running:
24
24
 
25
25
  ```bash
26
- bun dev
26
+ $ bun dev
27
27
  ```
28
28
 
29
29
  This will start a web server on `localhost:3000`.
@@ -31,20 +31,20 @@ This will start a web server on `localhost:3000`.
31
31
  To verify that the project was setup correctly and is running, try to reach `localhost:3000/hello` endpoint, this should return following greeting message:
32
32
 
33
33
  ```bash
34
- curl localhost:3000/hello
34
+ $ curl localhost:3000/hello
35
35
  Hello from Galbe!
36
36
  ```
37
37
 
38
38
  > [!TIP]
39
39
  > By default, the dev server automatically reloads on every file change.
40
40
 
41
- ## Manual Installation
41
+ ## Manual installation
42
42
 
43
43
  Init a new Bun project and add Galbe as dependency:
44
44
 
45
45
  ```bash
46
- bun init
47
- bun add galbe
46
+ $ bun init
47
+ $ bun add galbe
48
48
  ```
49
49
 
50
50
  Open `package.json` file and add the following scripts:
@@ -80,7 +80,7 @@ This is the recommended way to proceed but it is not mandatory. Galbe instances
80
80
  ### Galbe CLI
81
81
 
82
82
  ```bash
83
- galbe <command> <argument> [options]
83
+ $ galbe <command> <argument> [options]
84
84
  ```
85
85
 
86
86
  Here are the available commands:
@@ -171,7 +171,7 @@ export default new Galbe(config)
171
171
  > export default config
172
172
  > ```
173
173
 
174
- ## Project Structure
174
+ ## Project structure
175
175
 
176
176
  One key aspect of Galbe, is its versatility in terms of project structure. This is partly allowed by the [Automatic Route Analyzer](routes.md#automatic-route-analyzer) and the `routes` config property which defaults to `src/**/*.route.{js,ts}`.
177
177
 
package/docs/handler.md CHANGED
@@ -1 +1,105 @@
1
1
  # Handler
2
+
3
+ A handler is a function that gets executed when a request matches the route definition. It is responsible for processing the request and sending a response.
4
+
5
+ ## Handler declaration
6
+
7
+ The handler should be declared as last argument of the [Route Definition](routes.md#route-defintion) method.
8
+
9
+ ```js
10
+ galbe.get('foo', schema, [hook1, hook2], ctx => {})
11
+ ```
12
+
13
+ Handler are called after the last hook call, or right after the request parsing if no hook is declared. To get a better understanding of the request lifecycle, you can refer to the [Lifecycle](lifecycle.md) section.
14
+
15
+ ## Handler definition
16
+
17
+ ```js
18
+ const handler = ctx => {
19
+ const { name } = ctx.query
20
+ return `Hello ${name}!`
21
+ }
22
+ ```
23
+
24
+ The handler function takes a `context` object as single argument and might return a `response`.
25
+
26
+ **context**
27
+
28
+ The `context` object contains the request information as well as a `set` object that serves as a response modifier. You can find more detailed informations about the `context` object in the [Context](context.md) section.
29
+
30
+ **response**
31
+
32
+ To send a response, your handler can return an object. The response sent will depend on the type of the object returned. There are four types of responses that can be returned by a handler method. More about that in the next section.
33
+
34
+ ## Response types
35
+
36
+ > [!NOTE]
37
+ > This section only cover response body payloads, to return specific response headers and/or status, you should define them with the `context.set` object before the return statement. More about it in the [Context](context.md) section.
38
+
39
+ ### String
40
+
41
+ Case where `string` is returned by the handler.
42
+
43
+ - status: 200
44
+ - content-type: `text-plain`
45
+
46
+ **Example**
47
+
48
+ ```js
49
+ galbe.get('/example', ctx => {
50
+ return 'Hello Mom!'
51
+ })
52
+ ```
53
+
54
+ ### Object
55
+
56
+ Case where an `object` is returned by the handler.
57
+
58
+ - status: 200
59
+ - content-type: `application/json`
60
+
61
+ **Example**
62
+
63
+ ```js
64
+ galbe.get('/example', ctx => {
65
+ return 'Hello Mom!'
66
+ })
67
+ ```
68
+
69
+ ### Response instance
70
+
71
+ Case where a [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) instance is returned by the handler.
72
+
73
+ In that case, `context.set` properties are not taken into account to contruct the response.
74
+
75
+ **Example**
76
+
77
+ <!-- prettier-ignore -->
78
+ ```js
79
+ galbe.get('/example', ctx => {
80
+ return new Response(
81
+ 'Hello Mom',
82
+ { status: 200, headers: { 'content-type': 'text/plain' }
83
+ })
84
+ })
85
+ ```
86
+
87
+ ### Generator
88
+
89
+ Case where a [Generator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator) instance is returned by the handler.
90
+
91
+ - status: 200
92
+ - content-type: `text/event-stream`
93
+
94
+ **Example**
95
+
96
+ ```js
97
+ async function* generator(array) {
98
+ for (const item of array) {
99
+ await Bun.sleep(500)
100
+ yield item
101
+ }
102
+ }
103
+
104
+ galbe.get('/example', ctx => generator(['one', 'two', 'three']))
105
+ ```
package/docs/hooks.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Hooks provide a simple way to perform specific actions before and/or after reaching a specific route endpoint.
4
4
 
5
- ## Hook Definition
5
+ ## Hook definition
6
6
 
7
7
  ```ts
8
8
  const hook = (context, next) => {
@@ -16,24 +16,26 @@ The hook takes only two arguments, a `context` object and a `next` function.
16
16
 
17
17
  **context**
18
18
 
19
- The `context` object contains the request information along with a state property that is modifiable and preserved across all hooks and the handler. It is useful for sharing information or objects across hooks and handler. You can find more information about it in the [Context]() section.
19
+ The `context` object contains the request information along with a state property that is modifiable and preserved across all hooks and the handler. It is useful for sharing information or objects across hooks and handler. You can find more information about it in the [Context](context.md) section.
20
20
 
21
21
  **next**
22
22
 
23
23
  The `next` function calls the next hook in the hook list or the handler if the current hook is the last one declared. The `next` function should be called at most once. If it is omitted, Galbe will call it automatically at the end of the execution of the current hook.
24
24
 
25
25
  > [!TIP]
26
- > Hooks are interruptible objects, meaning they can return a response at any moment. This provides a powerful mechanism for implementing custom logic, such as authentication, authorization, caching, and more."
26
+ > Hooks are interruptible objects, meaning they can return a response at any moment. This provides a powerful mechanism for implementing custom logic, such as authentication, authorization, caching, and more.
27
+ >
28
+ > To learn more about response types, ou can take a look at the [Response types](handler.md#response-types) section.
27
29
 
28
- ## Hooks Declaration
30
+ ## Hooks declaration
29
31
 
30
- Hooks should be declared just before the handler method in the [Route Definition]() method as a list of Hooks.
32
+ Hooks should be declared just before the handler method in the [Route Definition](routes.md#route-defintion) method as a list of Hooks.
31
33
 
32
34
  ```ts
33
35
  galbe.get('foo', [ hook1, hook2, ... ], ctx => {})
34
36
  ```
35
37
 
36
- Hooks are called just before the [Handler]() in the order that they have been declared in the hook list of the [Route Definition](). To get a better understanding of hooks execution during the request lifecycle, you can refer to the [Lifecycle]() section.
38
+ Hooks are called just before the [Handler](handler.md) in the order that they have been declared in the hook list of the [Route Definition](routes.md#route-defintion). To get a better understanding of hooks execution during the request lifecycle, you can refer to the [Lifecycle](lifecycle.md) section.
37
39
 
38
40
  ### Examples
39
41
 
@@ -53,7 +55,7 @@ galbe.get('example', [hook1, hook2], ctx => {
53
55
  ```
54
56
 
55
57
  ```bash
56
- curl http://localhost:3000/example
58
+ $ curl http://localhost:3000/example
57
59
  hook1
58
60
  hook2
59
61
  handler
@@ -79,7 +81,7 @@ galbe.get('example', [hook1, hook2], ctx => {
79
81
  ```
80
82
 
81
83
  ```bash
82
- curl http://localhost:3000/example
84
+ $ curl http://localhost:3000/example
83
85
  hook1 start
84
86
  hook2 start
85
87
  handler
package/docs/router.md CHANGED
@@ -1 +1,10 @@
1
1
  # Router
2
+
3
+ Galbe router employs a hybrid approach to store and locating routes.
4
+
5
+ The static routes are maintained in a Map structure. This ensure that any incoming request path matching a static route is resolved in a constant time `O(1)`.
6
+
7
+ > [!NOTE]
8
+ > A static route is a route that doesn't contain any parameter (e.g.,`:param`) or wildcards `*`.
9
+
10
+ All other routes are stored in a [Trie](https://en.wikipedia.org/wiki/Trie)-like data structure. The time complexity of the search operation in this case is `O(n)`, where `n` represents the number of segments in the incoming request path.
package/docs/routes.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Routes are the entry points for handling client requests in a Galbe application. In this section, we'll cover how to define routes, the various options available for route definitions, and how to use the Automatic Route Analyzer to simplify route setup.
4
4
 
5
- ## Route Definition
5
+ ## Route definition
6
6
 
7
7
  Here is how to define routes in Galbe.
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "galbe",
3
- "version": "0.1.8",
3
+ "version": "0.1.13",
4
4
  "description": "Fast, lightweight and highly customizable JavaScript web framework based on Bun",
5
5
  "author": "Pierre Caillaud M (https://github.com/pierre-cm)",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -80,7 +80,6 @@ const galbeMethod = <
80
80
  [header: string]: string
81
81
  }
82
82
  status?: number
83
- redirect?: string
84
83
  }
85
84
  }
86
85
  return {
@@ -123,7 +122,10 @@ export class Galbe {
123
122
  constructor(config?: GalbeConfig) {
124
123
  this.config = config ?? {}
125
124
  this.config.routes = this.config.routes ?? true
126
- this.router = new GalbeRouter(this.config?.basePath || '')
125
+ this.router = new GalbeRouter({
126
+ prefix: this.config?.basePath || '',
127
+ cacheEnabled: this.config?.router?.cacheEnabled
128
+ })
127
129
  }
128
130
  private add(route: any) {
129
131
  this.router.add(route)
package/src/parser.ts CHANGED
@@ -9,16 +9,14 @@ import type {
9
9
  MultipartFormData,
10
10
  STUrlFormValues,
11
11
  STMultipartFormValues,
12
- STUnion,
13
12
  STLiteral,
14
- STArray,
15
13
  STSchema
16
14
  } from './schema'
17
15
 
18
16
  import { readableStreamToArrayBuffer } from 'bun'
19
17
  import { Kind, Optional, Stream } from './schema'
20
18
  import { validate } from './validator'
21
- import { RequestError, $T } from './index'
19
+ import { InternalError, RequestError } from './index'
22
20
 
23
21
  const textDecoder = new TextDecoder()
24
22
  const textEncoder = new TextEncoder()
@@ -39,10 +37,10 @@ const MP_HEADER_RX = /^multipart\/form-data/
39
37
 
40
38
  export const requestBodyParser = async (
41
39
  body: ReadableStream | null,
42
- headers: { 'content-type'?: string; 'content-length'?: string },
40
+ headers: Record<string, string>,
43
41
  schema?: STBody
44
42
  ) => {
45
- const { 'content-type': contentType, 'content-length': _contentLength } = headers
43
+ const contentType = headers?.['content-type']
46
44
  const kind = schema?.[Kind]
47
45
  const isStream = schema && Stream in schema
48
46
  try {
@@ -60,19 +58,19 @@ export const requestBodyParser = async (
60
58
  } else if (schema?.[Optional]) {
61
59
  return null
62
60
  } else {
63
- throw new RequestError({ status: 400, error: { body: `Not a valid ${kind}` } })
61
+ throw new RequestError({ status: 400, payload: { body: `Not a valid ${kind}` } })
64
62
  }
65
63
  } else {
66
64
  if (contentType === BA_HEADER) {
67
65
  return new Uint8Array()
68
66
  } else if (contentType === JSON_HEADER) {
69
- throw new RequestError({ status: 400, error: { body: 'Not a valid json' } })
67
+ throw new RequestError({ status: 400, payload: { body: 'Not a valid json' } })
70
68
  } else if (contentType?.match(TXT_HEADER_RX)) {
71
- throw new RequestError({ status: 400, error: { body: 'Not a valid text' } })
69
+ throw new RequestError({ status: 400, payload: { body: 'Not a valid text' } })
72
70
  } else if (contentType?.match(FORM_HEADER_RX)) {
73
- throw new RequestError({ status: 400, error: { body: 'Not a valid form' } })
71
+ throw new RequestError({ status: 400, payload: { body: 'Not a valid form' } })
74
72
  } else if (contentType?.match(MP_HEADER_RX)) {
75
- throw new RequestError({ status: 400, error: { body: 'Not a valid multipart form' } })
73
+ throw new RequestError({ status: 400, payload: { body: 'Not a valid multipart form' } })
76
74
  } else return null
77
75
  }
78
76
  } else {
@@ -91,7 +89,7 @@ export const requestBodyParser = async (
91
89
  : contentType?.match(MP_HEADER_RX)
92
90
  ? 'multipartForm'
93
91
  : contentType
94
- throw new RequestError({ status: 400, error: { body: `Expected ${kind}, received ${received}` } })
92
+ throw new RequestError({ status: 400, payload: { body: `Expected ${kind}, received ${received}` } })
95
93
  } else if (!contentType || contentType === BA_HEADER) {
96
94
  if (!schema) {
97
95
  if (isStream) return rsToAsyncIterator(body)
@@ -104,7 +102,14 @@ export const requestBodyParser = async (
104
102
  }
105
103
  } else if (contentType === JSON_HEADER) {
106
104
  if (!schema) {
107
- return await streamToString(body, $T.object($T.any()))
105
+ try {
106
+ return JSON.parse(await streamToString(body))
107
+ } catch (err: any) {
108
+ throw new RequestError({
109
+ status: 400,
110
+ payload: { body: err?.message ?? 'Parsing error' }
111
+ })
112
+ }
108
113
  } else {
109
114
  const str = await streamToString(body)
110
115
  let json
@@ -113,7 +118,7 @@ export const requestBodyParser = async (
113
118
  } catch (err: any) {
114
119
  throw new RequestError({
115
120
  status: 400,
116
- error: { body: err?.message ?? 'Parsing error' }
121
+ payload: { body: err?.message ?? 'Parsing error' }
117
122
  })
118
123
  }
119
124
  return validate(json, schema, true)
@@ -129,7 +134,7 @@ export const requestBodyParser = async (
129
134
  return await streamToUrlForm(body)
130
135
  } else {
131
136
  if (kind !== 'urlForm')
132
- throw new RequestError({ status: 400, error: { body: `Expected ${kind}, received urlForm` } })
137
+ throw new RequestError({ status: 400, payload: { body: `Expected ${kind}, received urlForm` } })
133
138
  if (isStream) return $streamToUrlForm(body, schema as STStream<STUrlForm>)
134
139
  else return await streamToUrlForm(body, schema as STUrlForm)
135
140
  }
@@ -139,7 +144,7 @@ export const requestBodyParser = async (
139
144
  return await streamToMultipartForm(body, boundary)
140
145
  } else {
141
146
  if (kind !== 'multipartForm')
142
- throw new RequestError({ status: 400, error: { body: `Expected ${kind}, received MultipartForm` } })
147
+ throw new RequestError({ status: 400, payload: { body: `Expected ${kind}, received MultipartForm` } })
143
148
  if (isStream) return $streamToMultipartForm(body, boundary, schema as STStream<STMultipartForm>)
144
149
  else {
145
150
  return streamToMultipartForm(body, boundary, schema as STMultipartForm)
@@ -151,7 +156,7 @@ export const requestBodyParser = async (
151
156
  }
152
157
  } catch (error) {
153
158
  if (error instanceof RequestError) throw error
154
- else throw new RequestError({ status: 400, error: { body: error } })
159
+ else throw new RequestError({ status: 400, payload: { body: error } })
155
160
  }
156
161
  }
157
162
  async function* $streamToString(body: ReadableStream) {
@@ -189,7 +194,7 @@ async function* $streamToUrlForm(
189
194
  let s = schema?.props?.[key]?.[Kind] === 'array' ? schema?.props?.[key].items : schema?.props?.[key]
190
195
  val = s ? paramParser(val, s) : val
191
196
  } catch (error) {
192
- throw new RequestError({ status: 400, error: { body: { [key]: error } } })
197
+ throw new RequestError({ status: 400, payload: { body: { [key]: error } } })
193
198
  }
194
199
  delete required[key]
195
200
  yield [key, val]
@@ -220,7 +225,7 @@ async function* $streamToUrlForm(
220
225
  let s = schema?.props?.[key]?.[Kind] === 'array' ? schema?.props?.[key].items : schema?.props?.[key]
221
226
  val = s ? paramParser(val, s) : val
222
227
  } catch (error) {
223
- throw new RequestError({ status: 400, error: { body: { [key]: error } } })
228
+ throw new RequestError({ status: 400, payload: { body: { [key]: error } } })
224
229
  }
225
230
  delete required[key]
226
231
  yield [key, val]
@@ -234,7 +239,7 @@ async function* $streamToUrlForm(
234
239
  if (reqKeys.length > 0)
235
240
  throw new RequestError({
236
241
  status: 400,
237
- error: { body: `Missing field${reqKeys.length > 1 ? 's' : ''}: ${reqKeys.join(', ')}` }
242
+ payload: { body: `Missing field${reqKeys.length > 1 ? 's' : ''}: ${reqKeys.join(', ')}` }
238
243
  })
239
244
  }
240
245
  const streamToUrlForm = async (body: ReadableStream<Uint8Array>, schema?: STUrlForm) => {
@@ -260,7 +265,7 @@ const streamToUrlForm = async (body: ReadableStream<Uint8Array>, schema?: STUrlF
260
265
  errors[k] = k in errors ? [...errors[k], error] : error
261
266
  }
262
267
  }
263
- if (Object.keys(errors).length) throw new RequestError({ status: 400, error: { body: errors } })
268
+ if (Object.keys(errors).length) throw new RequestError({ status: 400, payload: { body: errors } })
264
269
  for (const [k, s] of Object.entries(required)) {
265
270
  if (s[Kind] === 'array') {
266
271
  object[k] = []
@@ -271,7 +276,7 @@ const streamToUrlForm = async (body: ReadableStream<Uint8Array>, schema?: STUrlF
271
276
  if (reqKeys.length > 0)
272
277
  throw new RequestError({
273
278
  status: 400,
274
- error: { body: `Missing field${reqKeys.length > 1 ? 's' : ''}: ${reqKeys.join(', ')}` }
279
+ payload: { body: `Missing field${reqKeys.length > 1 ? 's' : ''}: ${reqKeys.join(', ')}` }
275
280
  })
276
281
  return object
277
282
  }
@@ -310,7 +315,7 @@ async function* $streamToMultipartForm(data: ReadableStream<Uint8Array>, boundar
310
315
  }
311
316
  } catch (err) {
312
317
  if (err instanceof RequestError) throw err
313
- throw new RequestError({ status: 400, error: { body: { [headers.name]: err } } })
318
+ throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
314
319
  }
315
320
  }
316
321
  bK = new Uint8Array()
@@ -343,7 +348,7 @@ async function* $streamToMultipartForm(data: ReadableStream<Uint8Array>, boundar
343
348
  content: parseMultipartContent(bV, headers, schema)
344
349
  }
345
350
  } catch (err) {
346
- throw new RequestError({ status: 400, error: { body: { [headers.name]: err } } })
351
+ throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
347
352
  }
348
353
  }
349
354
  for (const [k, s] of Object.entries(required)) {
@@ -356,7 +361,7 @@ async function* $streamToMultipartForm(data: ReadableStream<Uint8Array>, boundar
356
361
  if (reqKeys.length > 0)
357
362
  throw new RequestError({
358
363
  status: 400,
359
- error: { body: `Missing field${reqKeys.length > 1 ? 's' : ''}: ${reqKeys.join(', ')}` }
364
+ payload: { body: `Missing field${reqKeys.length > 1 ? 's' : ''}: ${reqKeys.join(', ')}` }
360
365
  })
361
366
  }
362
367
  const parseMultipartHeader = (header: string): { name: string; [key: string]: string } | null => {
@@ -394,19 +399,22 @@ const parseMultipartContent = (
394
399
  try {
395
400
  result = JSON.parse(textDecoder.decode(content).trim())
396
401
  } catch (err: any) {
397
- throw new RequestError({ status: 400, error: { body: { [headers.name]: err?.message || 'Parsing error' } } })
402
+ throw new RequestError({ status: 400, payload: { body: { [headers.name]: err?.message || 'Parsing error' } } })
398
403
  }
399
404
  } else if (schema?.props) {
400
405
  if (schema?.props[headers.name][Kind] === 'object') {
401
406
  try {
402
407
  result = JSON.parse(textDecoder.decode(content).trim())
403
408
  } catch (err: any) {
404
- throw new RequestError({ status: 400, error: { body: { [headers.name]: err?.message || 'Parsing error' } } })
409
+ throw new RequestError({
410
+ status: 400,
411
+ payload: { body: { [headers.name]: err?.message || 'Parsing error' } }
412
+ })
405
413
  }
406
414
  try {
407
415
  validate(result, schema?.props[headers.name])
408
416
  } catch (err) {
409
- throw new RequestError({ status: 400, error: { body: { [headers.name]: err } } })
417
+ throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
410
418
  }
411
419
  } else if (schema?.props[headers.name][Kind] === 'byteArray') {
412
420
  return content
@@ -415,7 +423,7 @@ const parseMultipartContent = (
415
423
  } else {
416
424
  throw new RequestError({
417
425
  status: 400,
418
- error: { body: { [headers.name]: `Expect ${schema?.props[headers.name][Kind]} found json` } }
426
+ payload: { body: { [headers.name]: `Expect ${schema?.props[headers.name][Kind]} found json` } }
419
427
  })
420
428
  }
421
429
  }
@@ -424,7 +432,7 @@ const parseMultipartContent = (
424
432
  let s = schema?.props[headers.name]
425
433
  validate(result, s?.[Kind] === 'array' ? s?.items : s)
426
434
  } catch (err) {
427
- throw new RequestError({ status: 400, error: { body: { [headers.name]: err } } })
435
+ throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
428
436
  }
429
437
  }
430
438
  return result
@@ -473,7 +481,7 @@ const streamToMultipartForm = async (data: ReadableStream<Uint8Array>, boundary:
473
481
  if (Object.keys(errors).length)
474
482
  throw new RequestError({
475
483
  status: 400,
476
- error: { body: errors }
484
+ payload: { body: errors }
477
485
  })
478
486
  for (const [k, s] of Object.entries(required)) {
479
487
  if (s[Kind] === 'array') {
@@ -485,7 +493,7 @@ const streamToMultipartForm = async (data: ReadableStream<Uint8Array>, boundary:
485
493
  if (reqKeys.length > 0)
486
494
  throw new RequestError({
487
495
  status: 400,
488
- error: { body: `Missing field${reqKeys.length > 1 ? 's' : ''}: ${reqKeys.join(', ')}` }
496
+ payload: { body: `Missing field${reqKeys.length > 1 ? 's' : ''}: ${reqKeys.join(', ')}` }
489
497
  })
490
498
  return res
491
499
  }
@@ -559,14 +567,28 @@ const paramParser = (
559
567
  }
560
568
 
561
569
  export const requestPathParser = (input: string, path: string) => {
562
- let pPath = path.replace(/^\/$(.*)\/?$/, '$1').split('/')
563
- let pInput = input.replace(/^\/$(.*)\/?$/, '$1').split('/')
570
+ let pInput = input.split('/')
564
571
  let params: Record<string, any> = {}
565
- pPath.shift()
566
- pInput.shift()
567
- for (const [i, p] of pPath.entries()) {
568
- const match = p.match(/^:(.*)/)
569
- if (match) params[match[1]] = pInput[i]
572
+ let idx = 0
573
+ for (let i = 0; i < path.length; i++) {
574
+ let c = path[i]
575
+ if (c === '/') {
576
+ idx++
577
+ continue
578
+ }
579
+ if (c === ':') {
580
+ let name = ''
581
+ while (true) {
582
+ i++
583
+ c = path[i]
584
+ if (c === '/' || i >= path.length) {
585
+ i--
586
+ break
587
+ }
588
+ name += c
589
+ }
590
+ params[name] = pInput[idx]
591
+ }
570
592
  }
571
593
  return params
572
594
  }
@@ -601,7 +623,7 @@ export const parseEntry = <T extends STProps>(
601
623
  })
602
624
 
603
625
  if (Object.keys(errors).length) {
604
- throw new RequestError({ status: 400, error: options?.name ? { [options.name]: errors } : errors })
626
+ throw new RequestError({ status: 400, payload: options?.name ? { [options.name]: errors } : errors })
605
627
  }
606
628
 
607
629
  return parsedParams as Static<STObject<T>>
@@ -631,7 +653,7 @@ export const responseParser = (response: any, ctx: Context) => {
631
653
  let data = `id:${id}\ndata:${r}\n\n`
632
654
  try {
633
655
  await controller.write(data)
634
- // await controller.flush()
656
+ await controller.flush()
635
657
  } catch (err) {
636
658
  console.error(err)
637
659
  }
@@ -648,7 +670,7 @@ export const responseParser = (response: any, ctx: Context) => {
648
670
  return new Response(JSON.stringify(response), details)
649
671
  } catch (error) {
650
672
  console.error(error)
651
- throw new RequestError({ status: 500, error: 'Internal Server Error' })
673
+ throw new InternalError()
652
674
  }
653
675
  }
654
676
  }
package/src/router.ts CHANGED
@@ -44,19 +44,21 @@ const walkRoutes = (path: string[], node: RouteNode, alts: RouteNode[] = []): Ro
44
44
  export class GalbeRouter {
45
45
  routes: RouteTree
46
46
  prefix: string
47
- staticRoutes: Map<string, Route>
48
- constructor(prefix?: string) {
47
+ cacheEnabled: boolean
48
+ cachedRoutes: Map<string, Route | null>
49
+ constructor(options?: { prefix?: string; cacheEnabled?: boolean }) {
49
50
  this.routes = { GET: {}, POST: {}, PUT: {}, PATCH: {}, DELETE: {}, OPTIONS: {} }
50
- prefix = prefix || ''
51
+ let prefix = options?.prefix || ''
51
52
  if (prefix && !prefix.match(/^\//)) prefix = `/${prefix}`
52
53
  this.prefix = prefix
53
- this.staticRoutes = new Map()
54
+ this.cachedRoutes = new Map()
55
+ this.cacheEnabled = options?.cacheEnabled ?? false
54
56
  }
55
57
  add(route: Route) {
56
58
  route.path = route?.path?.[0] === '/' ? route.path : `/${route.path}`
57
59
  if (!route.path.match(ROUTE_REGEX)) throw new SyntaxError(`${route.path} is not a valid route path.`)
58
60
  const isStatic = !route.path.match(/(:[\w\d-]+|\*)/)
59
- if (isStatic) this.staticRoutes.set(`[${route.method.toUpperCase()}]${route.path}`, route)
61
+ if (isStatic) this.cachedRoutes.set(`[${route.method.toUpperCase()}]${route.path}`, route)
60
62
  route.path = `${this.prefix || ''}${route.path}`
61
63
  let path = route.path.replace(/^\/$(.*)\/?$/, '$1').split('/')
62
64
  path.shift()
@@ -87,14 +89,16 @@ export class GalbeRouter {
87
89
  }
88
90
  }
89
91
  find(method: string, path: string) {
90
- const staticRoute = this.staticRoutes.get(`[${method}]${path}`)
92
+ const staticRoute = this.cachedRoutes.get(`[${method}]${path}`)
93
+ if (staticRoute === null) throw new NotFoundError()
91
94
  if (staticRoute !== undefined) return staticRoute
92
- let parts = path
93
- .replace(/\/+/g, '/')
94
- .replace(/^\/$(.*)\/?$/, '$1')
95
- .split('/')
95
+ let parts = path.split('/')
96
96
  const route = walkRoutes(parts, this.routes[method]).route
97
- if (!route) throw new NotFoundError()
97
+ if (!route) {
98
+ if (this.cacheEnabled) this.cachedRoutes.set(`[${method}]${path}`, null)
99
+ throw new NotFoundError()
100
+ }
101
+ if (this.cacheEnabled) this.cachedRoutes.set(`[${method}]${path}`, route)
98
102
  return route
99
103
  }
100
104
  }
package/src/server.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import type { Context, Route } from './types'
2
2
 
3
- import { NotFoundError, RequestError } from './types'
3
+ import { InternalError, RequestError } from './types'
4
4
  import { parseEntry, requestBodyParser, requestPathParser, responseParser } from './parser'
5
5
  import { Galbe } from './index'
6
6
 
7
7
  const handleInternalError = (error: any) => {
8
8
  console.error(error)
9
- return new RequestError({ status: 500, error: 'Internal Server Error' })
9
+ return new InternalError()
10
10
  }
11
11
 
12
12
  const setupPluginCallbacks = (galbe: Galbe) => ({
@@ -60,7 +60,6 @@ export default async (galbe: Galbe, port?: number) => {
60
60
  let response: any = ''
61
61
  try {
62
62
  // find route
63
- if (!url.pathname.match(new RegExp(`^${galbe.config?.basePath || ''}`))) throw new NotFoundError()
64
63
  try {
65
64
  route = router.find(req.method, url.pathname)
66
65
  } catch (error) {
@@ -75,8 +74,10 @@ export default async (galbe: Galbe, port?: number) => {
75
74
 
76
75
  // parse request
77
76
  const schema = route.schema
78
- let inHeaders = Object.fromEntries(req.headers.entries())
79
- let inQuery = Object.fromEntries(url.searchParams.entries())
77
+ const inHeaders: Record<string, any> = {}
78
+ for (let [k, v] of req.headers) inHeaders[k] = v
79
+ let inQuery: Record<string, any> = {}
80
+ for (let [k, v] of url.searchParams) inQuery[k] = v
80
81
  let inParams = requestPathParser(url.pathname, route.path)
81
82
 
82
83
  context.body = await requestBodyParser(req.body, inHeaders, schema.body)
@@ -109,7 +110,7 @@ export default async (galbe: Galbe, port?: number) => {
109
110
  else throw handleInternalError(error)
110
111
  }
111
112
  if (errors.length) {
112
- throw new RequestError({ status: 400, error: errors.reduce((acc, c) => ({ ...acc, ...c.error }), {}) })
113
+ throw new RequestError({ status: 400, payload: errors.reduce((acc, c) => ({ ...acc, ...c.payload }), {}) })
113
114
  }
114
115
 
115
116
  for (const cb of pluginsCb.beforeHandle) {
@@ -123,7 +124,7 @@ export default async (galbe: Galbe, port?: number) => {
123
124
  handlerCalled = true
124
125
  return route.handler(context)
125
126
  }
126
- const callChain = route.hooks.map((hook, idx) => ({
127
+ const callChain: { call: () => any }[] = route.hooks.map((hook, idx) => ({
127
128
  call: async () => {
128
129
  let nextCalled = false
129
130
  let next = async () => {
@@ -133,7 +134,8 @@ export default async (galbe: Galbe, port?: number) => {
133
134
  await callChain[idx + 1].call()
134
135
  }
135
136
  }
136
- await hook(context, next)
137
+ let r = await hook(context, next)
138
+ if (r) return r
137
139
  if (!nextCalled && !handlerCalled) await next()
138
140
  }
139
141
  }))
@@ -143,8 +145,11 @@ export default async (galbe: Galbe, port?: number) => {
143
145
  context.set.status = response instanceof Response ? response.status : 200
144
146
  }
145
147
  })
146
- if (callChain.length > 1) await callChain[0].call()
147
- else response = await handlerWrapper(context)
148
+ if (callChain.length > 1) {
149
+ let r = await callChain[0].call()
150
+ if (r) response = r
151
+ } else response = await handlerWrapper(context)
152
+
148
153
  const parsedResponse = responseParser(response, context)
149
154
 
150
155
  for (const cb of pluginsCb.afterHandle) {
@@ -155,22 +160,29 @@ export default async (galbe: Galbe, port?: number) => {
155
160
  return parsedResponse
156
161
  } catch (error) {
157
162
  context.set.status = error instanceof RequestError ? error.status : 500
158
- if (galbe.errorHandler) return galbe.errorHandler(error, context)
163
+ let customError
164
+ if (galbe.errorHandler) customError = responseParser(galbe.errorHandler(error, context), context)
165
+ if (customError) return customError
159
166
  if (error instanceof RequestError) {
160
- return new Response(JSON.stringify(error.error), {
167
+ return new Response(JSON.stringify(error.payload), {
161
168
  status: error.status,
162
169
  headers: { 'Content-Type': 'application/json' }
163
170
  })
164
171
  }
165
- return new Response('Internal Server Error', { status: 500 })
172
+ return new Response('"Internal Server Error"', {
173
+ status: 500,
174
+ headers: {
175
+ 'content-type': 'application/json'
176
+ }
177
+ })
166
178
  }
167
179
  },
168
180
  error(error) {
169
181
  console.error(error)
170
- return new Response('Internal Server Error', {
182
+ return new Response('"Internal Server Error"', {
171
183
  status: 500,
172
184
  headers: {
173
- 'Content-Type': 'text/plain'
185
+ 'content-type': 'application/json'
174
186
  }
175
187
  })
176
188
  }
package/src/types.ts CHANGED
@@ -72,6 +72,7 @@ export type GalbeConfig = {
72
72
  basePath?: string
73
73
  server?: Exclude<ServeOptions, 'port'> | TLSServeOptions
74
74
  routes?: boolean | string | string[]
75
+ router?: { cacheEnabled: boolean }
75
76
  plugin?: Record<string, any>
76
77
  }
77
78
  /**
@@ -129,7 +130,6 @@ export type Context<Path extends string = string, S extends RequestSchema = Requ
129
130
  [header: string]: string
130
131
  }
131
132
  status?: number
132
- redirect?: string
133
133
  }
134
134
  }
135
135
  export type Next = () => void | Promise<void>
@@ -146,7 +146,7 @@ export type Endpoint = {
146
146
  H extends STHeaders,
147
147
  P extends Partial<STParams<Path>>,
148
148
  Q extends STQuery,
149
- B extends STBody = STObject
149
+ B extends STBody = any
150
150
  >(
151
151
  path: Path,
152
152
  schema: RequestSchema<Path, H, P, Q, B>,
@@ -158,7 +158,7 @@ export type Endpoint = {
158
158
  H extends STHeaders,
159
159
  P extends Partial<STParams<Path>>,
160
160
  Q extends STQuery,
161
- B extends STBody = STObject
161
+ B extends STBody = any
162
162
  >(
163
163
  path: Path,
164
164
  schema: RequestSchema<Path, H, P, Q, B>,
@@ -169,7 +169,7 @@ export type Endpoint = {
169
169
  H extends STHeaders,
170
170
  P extends Partial<STParams<Path>>,
171
171
  Q extends STQuery,
172
- B extends STBody = STObject
172
+ B extends STBody = any
173
173
  >(
174
174
  path: Path,
175
175
  hooks: Hook<Path, RequestSchema<Path, H, P, Q, B>>[],
@@ -180,7 +180,7 @@ export type Endpoint = {
180
180
  H extends STHeaders,
181
181
  P extends Partial<STParams<Path>>,
182
182
  Q extends STQuery,
183
- B extends STBody = STObject
183
+ B extends STBody = any
184
184
  >(
185
185
  path: Path,
186
186
  handler: Handler<Path, RequestSchema<Path, H, P, Q, B>>
@@ -189,10 +189,10 @@ export type Endpoint = {
189
189
 
190
190
  export class RequestError {
191
191
  status: number
192
- error: any
193
- constructor(options: { status?: number; error?: any }) {
192
+ payload: any
193
+ constructor(options: { status?: number; payload?: any }) {
194
194
  this.status = options.status ?? 500
195
- this.error = options.error ?? 'Internal server error'
195
+ this.payload = options.payload ?? 'Internal server error'
196
196
  }
197
197
  }
198
198
 
@@ -225,7 +225,13 @@ export type RouteTree = {
225
225
 
226
226
  export class NotFoundError extends RequestError {
227
227
  constructor(message?: string) {
228
- super({ status: 404, error: message ?? 'Not found' })
228
+ super({ status: 404, payload: message ?? 'Not found' })
229
+ }
230
+ }
231
+
232
+ export class InternalError extends RequestError {
233
+ constructor(message?: string) {
234
+ super({ status: 500, payload: message ?? 'Internal Server Error' })
229
235
  }
230
236
  }
231
237
 
@@ -174,4 +174,27 @@ describe('hooks', async () => {
174
174
  expect(resp.status).toBe(200)
175
175
  expect(await resp?.text()).toBe('handled')
176
176
  })
177
+
178
+ test('hooks, early response', async () => {
179
+ let hook = 0
180
+ galbe.get(
181
+ '/hooks',
182
+ [
183
+ async _ => {
184
+ hook++
185
+ return 'hook'
186
+ }
187
+ ],
188
+ () => {
189
+ expect.unreachable()
190
+ }
191
+ )
192
+ expect(hook).toBe(0)
193
+ let resp = await fetch(`http://localhost:${port}/hooks`, {
194
+ method: 'GET'
195
+ })
196
+ expect(hook).toBe(1)
197
+ expect(resp.status).toBe(200)
198
+ expect(await resp?.text()).toBe('hook')
199
+ })
177
200
  })
@@ -141,7 +141,7 @@ describe('router', () => {
141
141
  } catch (err: any) {
142
142
  expect(err).toBeInstanceOf(NotFoundError)
143
143
  expect(err.status).toBe(404)
144
- expect(err.error).toBe('Not found')
144
+ expect(err.payload).toBe('Not found')
145
145
  }
146
146
 
147
147
  try {
@@ -150,7 +150,7 @@ describe('router', () => {
150
150
  } catch (err: any) {
151
151
  expect(err).toBeInstanceOf(NotFoundError)
152
152
  expect(err.status).toBe(404)
153
- expect(err.error).toBe('Not found')
153
+ expect(err.payload).toBe('Not found')
154
154
  }
155
155
  })
156
156
 
@@ -188,10 +188,6 @@ describe('router', () => {
188
188
  expect(r1.path).toBe('/test/foo/*')
189
189
  expect(r1.handler).toBe(h1)
190
190
 
191
- let r2 = router.find('GET', '/test//foo/bar')
192
- expect(r2.path).toBe('/test/foo/bar')
193
- expect(r2.handler).toBe(h2)
194
-
195
191
  let r3 = router.find('GET', '/test/foo/bar/bar')
196
192
  expect(r3.path).toBe('/test/foo/:p/bar')
197
193
  expect(r3.handler).toBe(h3)