galbe 0.1.8 → 0.2.0
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/.github/workflows/release.yml +1 -1
- package/docs/context.md +114 -0
- package/docs/error-handler.md +54 -0
- package/docs/getting-started.md +11 -11
- package/docs/handler.md +104 -0
- package/docs/hooks.md +10 -8
- package/docs/plugins.md +123 -0
- package/docs/router.md +9 -0
- package/docs/routes.md +1 -1
- package/package.json +1 -1
- package/src/index.ts +4 -2
- package/src/parser.ts +63 -41
- package/src/router.ts +15 -11
- package/src/server.ts +47 -44
- package/src/types.ts +19 -12
- package/test/hooks.test.ts +23 -0
- package/test/plugins.test.ts +3 -3
- package/test/router.test.ts +2 -6
package/docs/context.md
ADDED
|
@@ -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](https://galbe.dev/documentation/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
|
+
```
|
package/docs/getting-started.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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](https://galbe.dev/documentation/lifecycle) 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
|
|
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
|
|
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](https://galbe.dev/documentation/lifecycle) 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/plugins.md
CHANGED
|
@@ -1 +1,124 @@
|
|
|
1
1
|
# Plugins
|
|
2
|
+
|
|
3
|
+
Galbe provides a powerful plugin system that allows developers to extend and customize the behavior of the framework. The plugin capabilities are centered around the [Request Lifecycle](https://galbe.dev/documentation/lifecycle).
|
|
4
|
+
|
|
5
|
+
## Definition
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
type GalbePlugin = {
|
|
9
|
+
name: string
|
|
10
|
+
init?: (config: any, galbe: Galbe) => MaybePromise<void>
|
|
11
|
+
onFetch?: (context: Context) => MaybePromise<Response | void>
|
|
12
|
+
onRoute?: (context: Context) => MaybePromise<Response | void>
|
|
13
|
+
beforeHandle?: (context: Context) => MaybePromise<Response | void>
|
|
14
|
+
afterHandle?: (response: Response, context: Context) => MaybePromise<Response | void>
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**name**
|
|
19
|
+
|
|
20
|
+
The name should be a Unique Plugin Identifier. It should be chosen to be unique to avoid conflicts with other potential plugins. For example, Galbe's official plugins names will always start with `dev.galbe.*`.
|
|
21
|
+
|
|
22
|
+
**init**
|
|
23
|
+
|
|
24
|
+
This method is called right after the server starts. It takes two arguments: a `config` and a `galbe` instance. The `config` holds the configuration for the specific scope of the current plugin (See [Configuration](getting-started.md#properties) `plugin` property for more details). The `galbe` argument is the instance of the current server; you can for instance retrieve the current routes definitions with `galbe.router.routes`.
|
|
25
|
+
|
|
26
|
+
**onFetch**
|
|
27
|
+
|
|
28
|
+
This method is called at the beginning of an incoming request. It takes a single `context` argument representing the current request [Context](context.md).
|
|
29
|
+
|
|
30
|
+
It is preemptable, meaning that any returned value will be interpreted as a response to send back to the client. Therefore, the method should only return [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) instances or nothing.
|
|
31
|
+
|
|
32
|
+
**onRoute**
|
|
33
|
+
|
|
34
|
+
This method is called after the router has found a matching route for the current request. Its takes a single `context` argument representing the current request [Context](context.md).
|
|
35
|
+
|
|
36
|
+
It is preemptable, meaning that any returned value will be interpreted as a response to send back to the client. Therefore, the method should only return [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) instances or nothing.
|
|
37
|
+
|
|
38
|
+
**beforeHandle**
|
|
39
|
+
|
|
40
|
+
This method is called after the request has been validated and before the route hooks and the handler are called. It takes a single `context` argument representing the current request [Context](context.md).
|
|
41
|
+
|
|
42
|
+
It is preemptable, meaning that any returned value will be interpreted as a response to send back to the client. Therefore, the method should only return [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) instances or nothing.
|
|
43
|
+
|
|
44
|
+
**afterHandle**
|
|
45
|
+
|
|
46
|
+
This method is called after the route handler has been called and before the response is sent. Its takes two arguments: a `response` object containing the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) returned by the handler, and a `context` argument representing the current request [Context](context.md).
|
|
47
|
+
|
|
48
|
+
It is preemptable, meaning that any returned value will be interpreted as a response to send back to the client. Therefore, the method should only return [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) instances or nothing.
|
|
49
|
+
|
|
50
|
+
## Registration
|
|
51
|
+
|
|
52
|
+
To register a plugin with your Galbe server, you must use the `use` method from you galbe instance.
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
const galbe = new Galbe()
|
|
56
|
+
galbe.use(plugin)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Example
|
|
60
|
+
|
|
61
|
+
Here is an example of a plugin implementation that handles routes tagged with `@deprecated` metadata (See [Route files](routes.md#route-files) section about metadata).
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// myPlugin.ts
|
|
65
|
+
|
|
66
|
+
import { Galbe, type Context, type Route } from 'galbe'
|
|
67
|
+
|
|
68
|
+
class MyPlugin {
|
|
69
|
+
name = 'dev.galbe.example'
|
|
70
|
+
deprecated: Record<string, string[]> = {}
|
|
71
|
+
/**
|
|
72
|
+
* Retrieve and store all route with a @deprecated flag metadata
|
|
73
|
+
*/
|
|
74
|
+
init(config: any, galbe: Galbe) {
|
|
75
|
+
if (config?.enabled && galbe.meta) {
|
|
76
|
+
for (const f of galbe.meta) {
|
|
77
|
+
for (const [path, methods] of Object.entries(f.routes)) {
|
|
78
|
+
for (const [method, meta] of Object.entries(methods)) {
|
|
79
|
+
if (meta.deprecated) {
|
|
80
|
+
if (!this.deprecated?.[method]) this.deprecated[method] = []
|
|
81
|
+
this.deprecated[method].push(path)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Check if the current route is deprecated, flags it as is and logs it
|
|
90
|
+
*/
|
|
91
|
+
onRoute(context: Context) {
|
|
92
|
+
let route = context.route
|
|
93
|
+
if (this.deprecated?.[route.method]?.includes(route.path)) {
|
|
94
|
+
context.state[this.name] = { deprecated: true }
|
|
95
|
+
console.warn(`Call to deprecated route [${route.method}]${route.path}`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Adds a header if the request has previously been flagged as deprecated
|
|
100
|
+
*/
|
|
101
|
+
afterHandle(response: Response, context: Context) {
|
|
102
|
+
if (context.state?.[this.name]?.deprecated) {
|
|
103
|
+
response.headers.set('x-deprecated', 'true')
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export default new MyPlugin()
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
// index.ts
|
|
113
|
+
|
|
114
|
+
import { Galbe } from 'galbe'
|
|
115
|
+
import config from './galbe.config'
|
|
116
|
+
import myPlugin from './myPlugin'
|
|
117
|
+
|
|
118
|
+
const galbe = new Galbe(config)
|
|
119
|
+
galbe.use(myPlugin)
|
|
120
|
+
|
|
121
|
+
export default galbe
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
As you can see in this example, the [Context](context.md#definition) `state` property is used to persist information between plugins interceptor methods. It is a good practice to scope any information stored in the state with the plugin name, as it can also be used by other plugins and hooks to store data in the context.
|
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
|
|
5
|
+
## Route definition
|
|
6
6
|
|
|
7
7
|
Here is how to define routes in Galbe.
|
|
8
8
|
|
package/package.json
CHANGED
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(
|
|
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 {
|
|
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:
|
|
40
|
+
headers: Record<string, string>,
|
|
43
41
|
schema?: STBody
|
|
44
42
|
) => {
|
|
45
|
-
const
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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({
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
563
|
-
let pInput = input.replace(/^\/$(.*)\/?$/, '$1').split('/')
|
|
570
|
+
let pInput = input.split('/')
|
|
564
571
|
let params: Record<string, any> = {}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
|
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,35 +1,20 @@
|
|
|
1
1
|
import type { Context, Route } from './types'
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
|
9
|
+
return new InternalError()
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
const setupPluginCallbacks = (galbe: Galbe) => ({
|
|
13
|
-
init: galbe.plugins.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (p.onFetch) l.push(p.onFetch)
|
|
19
|
-
return l
|
|
20
|
-
}, []),
|
|
21
|
-
onRoute: galbe.plugins.reduce((l: Function[], p) => {
|
|
22
|
-
if (p.onRoute) l.push(p.onRoute)
|
|
23
|
-
return l
|
|
24
|
-
}, []),
|
|
25
|
-
beforeHandle: galbe.plugins.reduce((l: Function[], p) => {
|
|
26
|
-
if (p.beforeHandle) l.push(p.beforeHandle)
|
|
27
|
-
return l
|
|
28
|
-
}, []),
|
|
29
|
-
afterHandle: galbe.plugins.reduce((l: Function[], p) => {
|
|
30
|
-
if (p.afterHandle) l.push(p.afterHandle)
|
|
31
|
-
return l
|
|
32
|
-
}, [])
|
|
13
|
+
init: galbe.plugins.filter(p => p.init),
|
|
14
|
+
onFetch: galbe.plugins.filter(p => p.onFetch),
|
|
15
|
+
onRoute: galbe.plugins.filter(p => p.onRoute),
|
|
16
|
+
beforeHandle: galbe.plugins.filter(p => p.beforeHandle),
|
|
17
|
+
afterHandle: galbe.plugins.filter(p => p.afterHandle)
|
|
33
18
|
})
|
|
34
19
|
|
|
35
20
|
export default async (galbe: Galbe, port?: number) => {
|
|
@@ -37,7 +22,8 @@ export default async (galbe: Galbe, port?: number) => {
|
|
|
37
22
|
if (galbe?.config?.basePath && galbe?.config?.basePath[0] !== '/')
|
|
38
23
|
galbe.config.basePath = `/${galbe?.config?.basePath}`
|
|
39
24
|
let pluginsCb = setupPluginCallbacks(galbe)
|
|
40
|
-
|
|
25
|
+
//@ts-ignore
|
|
26
|
+
for (const p of pluginsCb.init) await p.init(galbe?.config?.plugin?.[p.name], galbe)
|
|
41
27
|
|
|
42
28
|
return Bun.serve({
|
|
43
29
|
port: port || galbe.config?.port || 3000,
|
|
@@ -51,8 +37,9 @@ export default async (galbe: Galbe, port?: number) => {
|
|
|
51
37
|
body: {},
|
|
52
38
|
state: {}
|
|
53
39
|
}
|
|
54
|
-
for (const
|
|
55
|
-
|
|
40
|
+
for (const p of pluginsCb.onFetch) {
|
|
41
|
+
//@ts-ignore
|
|
42
|
+
const r = await p.onFetch(context)
|
|
56
43
|
if (r) return r
|
|
57
44
|
}
|
|
58
45
|
const url = new URL(req.url)
|
|
@@ -60,23 +47,26 @@ export default async (galbe: Galbe, port?: number) => {
|
|
|
60
47
|
let response: any = ''
|
|
61
48
|
try {
|
|
62
49
|
// find route
|
|
63
|
-
if (!url.pathname.match(new RegExp(`^${galbe.config?.basePath || ''}`))) throw new NotFoundError()
|
|
64
50
|
try {
|
|
65
51
|
route = router.find(req.method, url.pathname)
|
|
66
52
|
} catch (error) {
|
|
67
53
|
if (error instanceof RequestError) throw error
|
|
68
54
|
else throw handleInternalError(error)
|
|
69
55
|
}
|
|
56
|
+
context.route = route
|
|
70
57
|
|
|
71
|
-
for (const
|
|
72
|
-
|
|
58
|
+
for (const p of pluginsCb.onRoute) {
|
|
59
|
+
//@ts-ignore
|
|
60
|
+
const r = await p.onRoute(context)
|
|
73
61
|
if (r) return r
|
|
74
62
|
}
|
|
75
63
|
|
|
76
64
|
// parse request
|
|
77
65
|
const schema = route.schema
|
|
78
|
-
|
|
79
|
-
let
|
|
66
|
+
const inHeaders: Record<string, any> = {}
|
|
67
|
+
for (let [k, v] of req.headers) inHeaders[k] = v
|
|
68
|
+
let inQuery: Record<string, any> = {}
|
|
69
|
+
for (let [k, v] of url.searchParams) inQuery[k] = v
|
|
80
70
|
let inParams = requestPathParser(url.pathname, route.path)
|
|
81
71
|
|
|
82
72
|
context.body = await requestBodyParser(req.body, inHeaders, schema.body)
|
|
@@ -109,11 +99,12 @@ export default async (galbe: Galbe, port?: number) => {
|
|
|
109
99
|
else throw handleInternalError(error)
|
|
110
100
|
}
|
|
111
101
|
if (errors.length) {
|
|
112
|
-
throw new RequestError({ status: 400,
|
|
102
|
+
throw new RequestError({ status: 400, payload: errors.reduce((acc, c) => ({ ...acc, ...c.payload }), {}) })
|
|
113
103
|
}
|
|
114
104
|
|
|
115
|
-
for (const
|
|
116
|
-
|
|
105
|
+
for (const p of pluginsCb.beforeHandle) {
|
|
106
|
+
//@ts-ignore
|
|
107
|
+
const r = await p.beforeHandle(context)
|
|
117
108
|
if (r) return r
|
|
118
109
|
}
|
|
119
110
|
|
|
@@ -123,7 +114,7 @@ export default async (galbe: Galbe, port?: number) => {
|
|
|
123
114
|
handlerCalled = true
|
|
124
115
|
return route.handler(context)
|
|
125
116
|
}
|
|
126
|
-
const callChain = route.hooks.map((hook, idx) => ({
|
|
117
|
+
const callChain: { call: () => any }[] = route.hooks.map((hook, idx) => ({
|
|
127
118
|
call: async () => {
|
|
128
119
|
let nextCalled = false
|
|
129
120
|
let next = async () => {
|
|
@@ -133,7 +124,8 @@ export default async (galbe: Galbe, port?: number) => {
|
|
|
133
124
|
await callChain[idx + 1].call()
|
|
134
125
|
}
|
|
135
126
|
}
|
|
136
|
-
await hook(context, next)
|
|
127
|
+
let r = await hook(context, next)
|
|
128
|
+
if (r) return r
|
|
137
129
|
if (!nextCalled && !handlerCalled) await next()
|
|
138
130
|
}
|
|
139
131
|
}))
|
|
@@ -143,34 +135,45 @@ export default async (galbe: Galbe, port?: number) => {
|
|
|
143
135
|
context.set.status = response instanceof Response ? response.status : 200
|
|
144
136
|
}
|
|
145
137
|
})
|
|
146
|
-
if (callChain.length > 1)
|
|
147
|
-
|
|
138
|
+
if (callChain.length > 1) {
|
|
139
|
+
let r = await callChain[0].call()
|
|
140
|
+
if (r) response = r
|
|
141
|
+
} else response = await handlerWrapper(context)
|
|
142
|
+
|
|
148
143
|
const parsedResponse = responseParser(response, context)
|
|
149
144
|
|
|
150
|
-
for (const
|
|
151
|
-
|
|
145
|
+
for (const p of pluginsCb.afterHandle) {
|
|
146
|
+
//@ts-ignore
|
|
147
|
+
const r = await p.afterHandle(parsedResponse, context)
|
|
152
148
|
if (r) return r
|
|
153
149
|
}
|
|
154
150
|
|
|
155
151
|
return parsedResponse
|
|
156
152
|
} catch (error) {
|
|
157
153
|
context.set.status = error instanceof RequestError ? error.status : 500
|
|
158
|
-
|
|
154
|
+
let customError
|
|
155
|
+
if (galbe.errorHandler) customError = responseParser(galbe.errorHandler(error, context), context)
|
|
156
|
+
if (customError) return customError
|
|
159
157
|
if (error instanceof RequestError) {
|
|
160
|
-
return new Response(JSON.stringify(error.
|
|
158
|
+
return new Response(JSON.stringify(error.payload), {
|
|
161
159
|
status: error.status,
|
|
162
160
|
headers: { 'Content-Type': 'application/json' }
|
|
163
161
|
})
|
|
164
162
|
}
|
|
165
|
-
return new Response('Internal Server Error', {
|
|
163
|
+
return new Response('"Internal Server Error"', {
|
|
164
|
+
status: 500,
|
|
165
|
+
headers: {
|
|
166
|
+
'content-type': 'application/json'
|
|
167
|
+
}
|
|
168
|
+
})
|
|
166
169
|
}
|
|
167
170
|
},
|
|
168
171
|
error(error) {
|
|
169
172
|
console.error(error)
|
|
170
|
-
return new Response('Internal Server Error', {
|
|
173
|
+
return new Response('"Internal Server Error"', {
|
|
171
174
|
status: 500,
|
|
172
175
|
headers: {
|
|
173
|
-
'
|
|
176
|
+
'content-type': 'application/json'
|
|
174
177
|
}
|
|
175
178
|
})
|
|
176
179
|
}
|
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
|
/**
|
|
@@ -123,13 +124,13 @@ export type Context<Path extends string = string, S extends RequestSchema = Requ
|
|
|
123
124
|
query: Static<STObject<Exclude<S['query'], undefined>>>
|
|
124
125
|
body: Static<Exclude<S['body'], undefined>>
|
|
125
126
|
request: Request
|
|
127
|
+
route?: Route
|
|
126
128
|
state: Record<string, any>
|
|
127
129
|
set: {
|
|
128
130
|
headers: {
|
|
129
131
|
[header: string]: string
|
|
130
132
|
}
|
|
131
133
|
status?: number
|
|
132
|
-
redirect?: string
|
|
133
134
|
}
|
|
134
135
|
}
|
|
135
136
|
export type Next = () => void | Promise<void>
|
|
@@ -146,7 +147,7 @@ export type Endpoint = {
|
|
|
146
147
|
H extends STHeaders,
|
|
147
148
|
P extends Partial<STParams<Path>>,
|
|
148
149
|
Q extends STQuery,
|
|
149
|
-
B extends STBody =
|
|
150
|
+
B extends STBody = any
|
|
150
151
|
>(
|
|
151
152
|
path: Path,
|
|
152
153
|
schema: RequestSchema<Path, H, P, Q, B>,
|
|
@@ -158,7 +159,7 @@ export type Endpoint = {
|
|
|
158
159
|
H extends STHeaders,
|
|
159
160
|
P extends Partial<STParams<Path>>,
|
|
160
161
|
Q extends STQuery,
|
|
161
|
-
B extends STBody =
|
|
162
|
+
B extends STBody = any
|
|
162
163
|
>(
|
|
163
164
|
path: Path,
|
|
164
165
|
schema: RequestSchema<Path, H, P, Q, B>,
|
|
@@ -169,7 +170,7 @@ export type Endpoint = {
|
|
|
169
170
|
H extends STHeaders,
|
|
170
171
|
P extends Partial<STParams<Path>>,
|
|
171
172
|
Q extends STQuery,
|
|
172
|
-
B extends STBody =
|
|
173
|
+
B extends STBody = any
|
|
173
174
|
>(
|
|
174
175
|
path: Path,
|
|
175
176
|
hooks: Hook<Path, RequestSchema<Path, H, P, Q, B>>[],
|
|
@@ -180,7 +181,7 @@ export type Endpoint = {
|
|
|
180
181
|
H extends STHeaders,
|
|
181
182
|
P extends Partial<STParams<Path>>,
|
|
182
183
|
Q extends STQuery,
|
|
183
|
-
B extends STBody =
|
|
184
|
+
B extends STBody = any
|
|
184
185
|
>(
|
|
185
186
|
path: Path,
|
|
186
187
|
handler: Handler<Path, RequestSchema<Path, H, P, Q, B>>
|
|
@@ -189,10 +190,10 @@ export type Endpoint = {
|
|
|
189
190
|
|
|
190
191
|
export class RequestError {
|
|
191
192
|
status: number
|
|
192
|
-
|
|
193
|
-
constructor(options: { status?: number;
|
|
193
|
+
payload: any
|
|
194
|
+
constructor(options: { status?: number; payload?: any }) {
|
|
194
195
|
this.status = options.status ?? 500
|
|
195
|
-
this.
|
|
196
|
+
this.payload = options.payload ?? 'Internal server error'
|
|
196
197
|
}
|
|
197
198
|
}
|
|
198
199
|
|
|
@@ -225,7 +226,13 @@ export type RouteTree = {
|
|
|
225
226
|
|
|
226
227
|
export class NotFoundError extends RequestError {
|
|
227
228
|
constructor(message?: string) {
|
|
228
|
-
super({ status: 404,
|
|
229
|
+
super({ status: 404, payload: message ?? 'Not found' })
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export class InternalError extends RequestError {
|
|
234
|
+
constructor(message?: string) {
|
|
235
|
+
super({ status: 500, payload: message ?? 'Internal Server Error' })
|
|
229
236
|
}
|
|
230
237
|
}
|
|
231
238
|
|
|
@@ -253,8 +260,8 @@ export class NotFoundError extends RequestError {
|
|
|
253
260
|
export type GalbePlugin = {
|
|
254
261
|
name: string
|
|
255
262
|
init?: (config: any, galbe: Galbe) => MaybePromise<void>
|
|
256
|
-
onFetch?: (
|
|
257
|
-
onRoute?: (
|
|
263
|
+
onFetch?: (context: Context) => MaybePromise<Response | void>
|
|
264
|
+
onRoute?: (context: Context) => MaybePromise<Response | void>
|
|
258
265
|
beforeHandle?: (context: Context) => MaybePromise<Response | void>
|
|
259
|
-
afterHandle?: (response: Response) => MaybePromise<Response | void>
|
|
266
|
+
afterHandle?: (response: Response, context: Context) => MaybePromise<Response | void>
|
|
260
267
|
}
|
package/test/hooks.test.ts
CHANGED
|
@@ -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
|
})
|
package/test/plugins.test.ts
CHANGED
|
@@ -39,8 +39,8 @@ describe('plugins', async () => {
|
|
|
39
39
|
let request: any = null
|
|
40
40
|
const plugin: GalbePlugin = {
|
|
41
41
|
name: 'dev.galbe.test.init',
|
|
42
|
-
onFetch: mock(
|
|
43
|
-
request =
|
|
42
|
+
onFetch: mock(ctx => {
|
|
43
|
+
request = ctx.request
|
|
44
44
|
})
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -86,7 +86,7 @@ describe('plugins', async () => {
|
|
|
86
86
|
const plugin: GalbePlugin = {
|
|
87
87
|
name: 'dev.galbe.test.init',
|
|
88
88
|
onRoute: mock(r => {
|
|
89
|
-
route = r
|
|
89
|
+
route = r.route
|
|
90
90
|
})
|
|
91
91
|
}
|
|
92
92
|
|
package/test/router.test.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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)
|