dynara 0.0.1
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/README.md +150 -0
- package/dist/index.d.ts +119 -0
- package/dist/index.js +426 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Marci
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@den59k/marci)
|
|
4
|
+
|
|
5
|
+
An extremely simple HTTP framework for Bun — practically a typed wrapper around `Bun.serve`, with Fastify-style routing and fast schema validation. Made for people switching over from Fastify.
|
|
6
|
+
|
|
7
|
+
- **Bun only** — Node.js and Deno are not supported.
|
|
8
|
+
- **Minimal overhead** — routing is delegated to Bun's native router.
|
|
9
|
+
- **Typed validation** — powered by [TypeBox](https://www.npmjs.com/package/@sinclair/typebox), written with the compact [compact-json-schema](https://www.npmjs.com/package/compact-json-schema) syntax.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
bun add @den59k/marci
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { MarciApp } from '@den59k/marci'
|
|
21
|
+
|
|
22
|
+
const app = new MarciApp()
|
|
23
|
+
|
|
24
|
+
app.get('/', () => {
|
|
25
|
+
return { hello: 'world' }
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
app.listen(3000)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
A handler may return a plain value (sent as JSON), a `Response` (sent as-is), or `undefined` (an empty `200`).
|
|
32
|
+
|
|
33
|
+
## Routes & validation
|
|
34
|
+
|
|
35
|
+
Routes use Bun's native patterns — `:param` for a single segment, `*` for a wildcard. The methods are `get`, `post`, `put`, `patch`, and `delete`.
|
|
36
|
+
|
|
37
|
+
Schemas are written with [compact-json-schema](https://www.npmjs.com/package/compact-json-schema) and validated with TypeBox. Pass them as a route-option object, or as a positional array — `[params]` / `[params, query]` for `GET`, `[params, body, query]` for the others. Validated `req.params`, `req.query`, and `req.body` are fully typed.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { schema } from 'compact-json-schema'
|
|
41
|
+
|
|
42
|
+
const params = schema({ id: 'number' })
|
|
43
|
+
const body = schema({ name: 'string', age: 'number?' }) // ? optional, ?? nullable
|
|
44
|
+
|
|
45
|
+
// Option object
|
|
46
|
+
app.post('/users/:id', { params, body }, (req) => {
|
|
47
|
+
req.params.id // number
|
|
48
|
+
req.body.name // string
|
|
49
|
+
return { ok: true }
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// Positional array
|
|
53
|
+
app.get('/users/:id', [params], (req) => {
|
|
54
|
+
return { id: req.params.id } // number
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A few conveniences:
|
|
59
|
+
|
|
60
|
+
- **Array params** are comma-split: `GET /items/1,2,3` with `{ itemIds: { type: 'array', items: 'number' } }` yields `[1, 2, 3]`.
|
|
61
|
+
- **Query booleans** accept `?flag=true` or a bare `?flag`.
|
|
62
|
+
- `req.raw` exposes the underlying `BunRequest`, and `req.server` the Bun `Server`.
|
|
63
|
+
|
|
64
|
+
## Hooks
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
app.addHook('onRequest', (req) => {
|
|
68
|
+
// runs before every handler; throw to short-circuit the request
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
app.addHook('onListen', (server) => {
|
|
72
|
+
console.log(`Listening on ${server.url}`)
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Plugins & context
|
|
77
|
+
|
|
78
|
+
`register` mounts a group of routes under a prefix. Type the app with a context type to share data attached by hooks:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
type Ctx = { user: { id: number } }
|
|
82
|
+
|
|
83
|
+
app.register((users: MarciApp<Ctx>) => {
|
|
84
|
+
users.addHook('onRequest', (req) => { req.user = { id: 1 } })
|
|
85
|
+
users.get('/me', (req) => req.user)
|
|
86
|
+
}, { prefix: '/users' })
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
For composable, reusable plugins there is the `marci()` builder. `use` adds plugins, `routes` adds handlers, and the result can be passed to `register`:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { marci, MarciApp } from '@den59k/marci'
|
|
93
|
+
|
|
94
|
+
const useAuth = (app: MarciApp<Ctx>) => {
|
|
95
|
+
app.addHook('onRequest', (req) => { req.user = { id: 1 } })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const users = marci<Ctx>()
|
|
99
|
+
.use(useAuth)
|
|
100
|
+
.routes((app) => {
|
|
101
|
+
app.get('/me', (req) => req.user)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
app.register(users, { prefix: '/users' })
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Errors
|
|
108
|
+
|
|
109
|
+
Throw `HTTPError` to send an explicit status code; validation failures are turned into `400` responses automatically.
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { HTTPError } from '@den59k/marci'
|
|
113
|
+
|
|
114
|
+
app.get('/secret', () => {
|
|
115
|
+
throw new HTTPError('Forbidden', 403) // text body
|
|
116
|
+
// throw new HTTPError({ reason: 'forbidden' }, 403) // JSON body
|
|
117
|
+
})
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Testing
|
|
121
|
+
|
|
122
|
+
`inject()` dispatches a request through your routes in-process — no server, no socket — and returns a real `Response`. It reuses the same routing, validation, and error handling as a live server.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { test, expect } from 'bun:test'
|
|
126
|
+
|
|
127
|
+
test('returns a user', async () => {
|
|
128
|
+
const res = await app.inject('/users/1')
|
|
129
|
+
expect(res.status).toBe(200)
|
|
130
|
+
expect(await res.json()).toEqual({ id: 1 })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// With a body:
|
|
134
|
+
await app.inject({ method: 'POST', url: '/users', body: { name: 'Alice' } })
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
> Under `inject()` there is no Bun `Server`, so `req.server` is `undefined` and WebSocket upgrades are not exercised.
|
|
138
|
+
|
|
139
|
+
## WebSockets
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
app.registerWsHandler('/ws', {
|
|
143
|
+
open(ws) { ws.send('hello') },
|
|
144
|
+
message(ws, msg) { ws.send(msg) },
|
|
145
|
+
})
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
declare class HTTPError extends Error {
|
|
2
|
+
statusCode: number;
|
|
3
|
+
data?: any;
|
|
4
|
+
message: string;
|
|
5
|
+
constructor(message: string | object, statusCode?: number);
|
|
6
|
+
}
|
|
7
|
+
import { BunRequest, Server, HeadersInit, BodyInit } from "bun";
|
|
8
|
+
import { SchemaItem, SchemaType } from "compact-json-schema";
|
|
9
|
+
type RouteOptions = {
|
|
10
|
+
params?: SchemaItem
|
|
11
|
+
body?: SchemaItem
|
|
12
|
+
query?: SchemaItem
|
|
13
|
+
};
|
|
14
|
+
type GetRouteOptions = {
|
|
15
|
+
params?: SchemaItem
|
|
16
|
+
query?: SchemaItem
|
|
17
|
+
};
|
|
18
|
+
interface MarciContext {}
|
|
19
|
+
type MarciRequest<
|
|
20
|
+
R extends object = {},
|
|
21
|
+
T extends RouteOptions = {}
|
|
22
|
+
> = MarciContext & R & {
|
|
23
|
+
params: T["params"] extends object ? SchemaType<T["params"]> : unknown
|
|
24
|
+
query: T["query"] extends object ? SchemaType<T["query"]> : unknown
|
|
25
|
+
body: T["body"] extends object ? SchemaType<T["body"]> : unknown
|
|
26
|
+
raw: BunRequest
|
|
27
|
+
server: Server
|
|
28
|
+
};
|
|
29
|
+
type GetMarciRequest<
|
|
30
|
+
R extends object = {},
|
|
31
|
+
T extends GetRouteOptions = {}
|
|
32
|
+
> = MarciContext & R & {
|
|
33
|
+
params: T["params"] extends object ? SchemaType<T["params"]> : unknown
|
|
34
|
+
query: T["query"] extends object ? SchemaType<T["query"]> : unknown
|
|
35
|
+
raw: BunRequest
|
|
36
|
+
server: Server
|
|
37
|
+
};
|
|
38
|
+
type RouteAction<
|
|
39
|
+
T extends RouteOptions,
|
|
40
|
+
R extends object = {}
|
|
41
|
+
> = (req: MarciRequest<R, T>) => (any | Promise<any>);
|
|
42
|
+
type GetRouteAction<
|
|
43
|
+
T extends GetRouteOptions,
|
|
44
|
+
R extends object = {}
|
|
45
|
+
> = (req: GetMarciRequest<R, T>) => (any | Promise<any>);
|
|
46
|
+
type RegisterPluginOptions = {
|
|
47
|
+
prefix?: string
|
|
48
|
+
};
|
|
49
|
+
type InjectOptions = {
|
|
50
|
+
method?: string
|
|
51
|
+
url: string
|
|
52
|
+
headers?: HeadersInit
|
|
53
|
+
body?: BodyInit | Record<string, any> | null
|
|
54
|
+
};
|
|
55
|
+
import { BunRequest as BunRequest3, Server as Server2, WebSocketHandler } from "bun";
|
|
56
|
+
import { SchemaItem as SchemaItem3 } from "compact-json-schema";
|
|
57
|
+
import { SchemaItem as SchemaItem2 } from "compact-json-schema";
|
|
58
|
+
type GetOptionsFromSchemaList<T extends readonly SchemaItem2[]> = T extends [SchemaItem2] ? {
|
|
59
|
+
params: T[0]
|
|
60
|
+
} : T extends [SchemaItem2, SchemaItem2] ? {
|
|
61
|
+
params: T[0]
|
|
62
|
+
query: T[1]
|
|
63
|
+
} : {};
|
|
64
|
+
type PostOptionsFromSchemaList<T extends readonly SchemaItem2[]> = T extends [SchemaItem2] ? {
|
|
65
|
+
params: T[0]
|
|
66
|
+
} : T extends [SchemaItem2, SchemaItem2] ? {
|
|
67
|
+
params: T[0]
|
|
68
|
+
body: T[1]
|
|
69
|
+
} : {};
|
|
70
|
+
declare class MarciApp<R extends object = {}> {
|
|
71
|
+
private routes;
|
|
72
|
+
private promises;
|
|
73
|
+
private prefix;
|
|
74
|
+
private root;
|
|
75
|
+
private server;
|
|
76
|
+
private onListenHooks;
|
|
77
|
+
private onRequestHooks;
|
|
78
|
+
private add;
|
|
79
|
+
addHook(where: "onListen", callback: (server: Bun.Server2) => void): void;
|
|
80
|
+
addHook(where: "onRequest", callback: (ctx: MarciRequest<R>) => void): void;
|
|
81
|
+
get(path: string, callback: GetRouteAction<{}, R>): void;
|
|
82
|
+
get<T extends GetRouteOptions>(path: string, options: T, callback: GetRouteAction<T, R>): void;
|
|
83
|
+
get<T extends readonly SchemaItem3[]>(path: string, schemas: [...T], callback: GetRouteAction<GetOptionsFromSchemaList<T>, R>): void;
|
|
84
|
+
post(path: string, callback: RouteAction<{}, R>): void;
|
|
85
|
+
post<T extends RouteOptions>(path: string, options: T, callback: RouteAction<T, R>): void;
|
|
86
|
+
post<T extends readonly SchemaItem3[]>(path: string, schemas: [...T], callback: RouteAction<PostOptionsFromSchemaList<T>, R>): void;
|
|
87
|
+
put(path: string, callback: RouteAction<{}, R>): void;
|
|
88
|
+
put<T extends RouteOptions>(path: string, options: T, callback: RouteAction<T, R>): void;
|
|
89
|
+
put<T extends readonly SchemaItem3[]>(path: string, schemas: [...T], callback: RouteAction<PostOptionsFromSchemaList<T>, R>): void;
|
|
90
|
+
patch(path: string, callback: RouteAction<{}, R>): void;
|
|
91
|
+
patch<T extends RouteOptions>(path: string, options: T, callback: RouteAction<T, R>): void;
|
|
92
|
+
patch<T extends readonly SchemaItem3[]>(path: string, schemas: [...T], callback: RouteAction<PostOptionsFromSchemaList<T>, R>): void;
|
|
93
|
+
delete(path: string, callback: RouteAction<{}, R>): void;
|
|
94
|
+
delete<T extends RouteOptions>(path: string, options: T, callback: RouteAction<T, R>): void;
|
|
95
|
+
delete<T extends readonly SchemaItem3[]>(path: string, schemas: [...T], callback: RouteAction<PostOptionsFromSchemaList<T>, R>): void;
|
|
96
|
+
register(plugin: (app: MarciApp<any>) => void | Promise<void>, options?: RegisterPluginOptions): void;
|
|
97
|
+
private websocket?;
|
|
98
|
+
private websocketPath?;
|
|
99
|
+
private websocketFetch?;
|
|
100
|
+
registerWsHandler<T>(ws: WebSocketHandler<T>): void;
|
|
101
|
+
registerWsHandler<T>(path: string, ws: WebSocketHandler<T>): void;
|
|
102
|
+
registerWsHandler<T>(onFetch: (req: BunRequest3) => any, ws: WebSocketHandler<T>): void;
|
|
103
|
+
private fetch;
|
|
104
|
+
registerNotFoundHandler(handler: (path: string, req: Request, server: Server2) => Response | Promise<Response | undefined>): void;
|
|
105
|
+
listen(port?: number, hostname?: string): Promise<Bun.Server2>;
|
|
106
|
+
/** Converts a thrown error into the same Response Bun's `error` handler produces. */
|
|
107
|
+
private handleError;
|
|
108
|
+
/**\\n\\t\\n\\t* Dispatches a request through the app's routes in-process — no server, no\\n\\t\\n\\t* socket. Matches the route the same way Bun would, then runs the onRequest\\n\\t\\n\\t* hooks, validation, handler, and error mapping, returning the Response.\\n\\t\\n\\t* Intended for integration testing.\\n\\t\\n\\t*\\n\\t\\n\\t* @example\\n\\t\\n\\t* const res = await app.inject("/users/1")\\n\\t\\n\\t* const res = await app.inject({ method: "POST", url: "/users", body: { name: "Alice" } })\\n\\t\\n\\t* expect(res.status).toBe(200)\\n\\t\\n\\t* expect(await res.json()).toEqual({ ... })\\n\\t\\n\\t*/
|
|
109
|
+
inject(options: string | InjectOptions): Promise<Response>;
|
|
110
|
+
}
|
|
111
|
+
type MarciSyntax<R extends object = {}> = ((app: MarciApp<any>) => Promise<void>) & {
|
|
112
|
+
use<
|
|
113
|
+
S extends object = {},
|
|
114
|
+
A extends any[] = []
|
|
115
|
+
>(plugin: (app: MarciApp<R & S>, ...args: A) => Promise<void> | void, ...args: A): MarciSyntax<R & S>
|
|
116
|
+
routes(app: (app: MarciApp<R>) => Promise<void> | void): MarciSyntax<R>
|
|
117
|
+
};
|
|
118
|
+
declare const marci: <R extends object = {}>() => MarciSyntax<R>;
|
|
119
|
+
export { marci, MarciSyntax, MarciRequest, MarciApp, InjectOptions, HTTPError };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
// src/error.ts
|
|
2
|
+
class HTTPError extends Error {
|
|
3
|
+
statusCode;
|
|
4
|
+
data;
|
|
5
|
+
message;
|
|
6
|
+
constructor(message, statusCode) {
|
|
7
|
+
super();
|
|
8
|
+
if (typeof message === "string") {
|
|
9
|
+
this.message = message;
|
|
10
|
+
} else if (typeof message === "object" && message !== null && !("error" in message)) {
|
|
11
|
+
this.data = { error: message };
|
|
12
|
+
this.message = "HTTP Error";
|
|
13
|
+
} else {
|
|
14
|
+
this.data = message;
|
|
15
|
+
this.message = "HTTP Error";
|
|
16
|
+
}
|
|
17
|
+
this.statusCode = statusCode ?? 400;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class ValidationError extends Error {
|
|
22
|
+
error;
|
|
23
|
+
where;
|
|
24
|
+
constructor(err, where) {
|
|
25
|
+
super(err.message);
|
|
26
|
+
this.error = err;
|
|
27
|
+
this.where = where;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/marci-syntax.ts
|
|
32
|
+
var marci = () => {
|
|
33
|
+
const plugins = [];
|
|
34
|
+
const handlers = [];
|
|
35
|
+
const app = Object.assign(async (app2) => {
|
|
36
|
+
for (let plugin of plugins) {
|
|
37
|
+
await plugin[0](app2, ...plugin.slice(1));
|
|
38
|
+
}
|
|
39
|
+
for (let handler of handlers) {
|
|
40
|
+
await handler(app2);
|
|
41
|
+
}
|
|
42
|
+
}, {
|
|
43
|
+
use(plugin, ...args) {
|
|
44
|
+
plugins.push([plugin, ...args]);
|
|
45
|
+
return this;
|
|
46
|
+
},
|
|
47
|
+
routes(handler) {
|
|
48
|
+
handlers.push(handler);
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
return app;
|
|
53
|
+
};
|
|
54
|
+
// src/MarciApp.ts
|
|
55
|
+
import { unfoldTypeBoxSchema } from "compact-json-schema";
|
|
56
|
+
import { TypeBoxError } from "@sinclair/typebox";
|
|
57
|
+
import { TypeCompiler } from "@sinclair/typebox/compiler";
|
|
58
|
+
|
|
59
|
+
// src/request.ts
|
|
60
|
+
import { Value } from "@sinclair/typebox/value";
|
|
61
|
+
|
|
62
|
+
class MarciRequestInternal {
|
|
63
|
+
server;
|
|
64
|
+
raw;
|
|
65
|
+
params;
|
|
66
|
+
query;
|
|
67
|
+
body = null;
|
|
68
|
+
constructor(req, server, paramsSchema, querySchema) {
|
|
69
|
+
if (paramsSchema && paramsSchema.arrayKeys) {
|
|
70
|
+
const params = req.params;
|
|
71
|
+
for (let key of paramsSchema.arrayKeys) {
|
|
72
|
+
if (params[key] && !params[key].startsWith("[")) {
|
|
73
|
+
params[key] = params[key].split(",");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
this.params = Value.Parse(paramsSchema, params);
|
|
77
|
+
} else {
|
|
78
|
+
this.params = paramsSchema === null ? req.params : Value.Parse(paramsSchema, req.params);
|
|
79
|
+
}
|
|
80
|
+
if (req.url.includes("?")) {
|
|
81
|
+
const queryParams = new URL(req.url).searchParams;
|
|
82
|
+
if (querySchema && querySchema.type === "object") {
|
|
83
|
+
const query = {};
|
|
84
|
+
for (let [key, value] of queryParams.entries()) {
|
|
85
|
+
const schema = querySchema.properties[key];
|
|
86
|
+
if (!schema)
|
|
87
|
+
continue;
|
|
88
|
+
if (schema.type === "boolean" && value === "") {
|
|
89
|
+
query[key] = true;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
query[key] = Value.Parse(schema, value);
|
|
93
|
+
}
|
|
94
|
+
this.query = query;
|
|
95
|
+
} else {
|
|
96
|
+
this.query = Object.fromEntries(queryParams.entries());
|
|
97
|
+
}
|
|
98
|
+
} else if (querySchema) {
|
|
99
|
+
this.query = Value.Parse(querySchema, {});
|
|
100
|
+
} else {
|
|
101
|
+
this.query = null;
|
|
102
|
+
}
|
|
103
|
+
this.raw = req;
|
|
104
|
+
this.server = server;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// src/utils.ts
|
|
109
|
+
import { Type } from "@sinclair/typebox";
|
|
110
|
+
import { provideTypeBoxMap } from "compact-json-schema";
|
|
111
|
+
var getRouteOptions = (method, schemas) => {
|
|
112
|
+
if (schemas.length === 0)
|
|
113
|
+
return {};
|
|
114
|
+
if (schemas.length === 1) {
|
|
115
|
+
return { params: schemas[0] };
|
|
116
|
+
}
|
|
117
|
+
if (method === "GET") {
|
|
118
|
+
return { params: schemas[0], query: schemas[1] };
|
|
119
|
+
} else {
|
|
120
|
+
if (schemas.length === 2) {
|
|
121
|
+
return { params: schemas[0], body: schemas[1] };
|
|
122
|
+
} else {
|
|
123
|
+
return { params: schemas[0], body: schemas[1], query: schemas[2] };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
var getValidationError = (obj, step) => {
|
|
128
|
+
if (step) {
|
|
129
|
+
return `{"cause":"Validation error","where":"${step}","fields":{"${obj.path.slice(1)}":{"message":"${obj.message}"}}}`;
|
|
130
|
+
} else {
|
|
131
|
+
return `{"cause":"Validation error","fields":{"${obj.path.slice(1)}":{"message":"${obj.message}"}}}`;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
provideTypeBoxMap({
|
|
135
|
+
string: Type.String,
|
|
136
|
+
boolean: Type.Boolean,
|
|
137
|
+
number: Type.Number,
|
|
138
|
+
integer: Type.Integer,
|
|
139
|
+
object: Type.Object,
|
|
140
|
+
array: Type.Array,
|
|
141
|
+
bigint: Type.BigInt,
|
|
142
|
+
union: Type.Union,
|
|
143
|
+
null: Type.Null,
|
|
144
|
+
literal: Type.Literal,
|
|
145
|
+
optional: Type.Optional,
|
|
146
|
+
any: Type.Any
|
|
147
|
+
});
|
|
148
|
+
var isDefault = (schema) => {
|
|
149
|
+
if (typeof schema !== "object" || schema === null)
|
|
150
|
+
return true;
|
|
151
|
+
for (let value of Object.values(schema)) {
|
|
152
|
+
if (value !== "string" && value !== "string?" && value !== "string??") {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
};
|
|
158
|
+
var parseBody = (schema, req) => {
|
|
159
|
+
return new Promise((res, rej) => {
|
|
160
|
+
req.json().catch((e) => {
|
|
161
|
+
rej(new HTTPError(`Error on parsing body (${e.message})`, 400));
|
|
162
|
+
}).then((resp) => {
|
|
163
|
+
const check = schema.Check(resp);
|
|
164
|
+
if (!check) {
|
|
165
|
+
const error = schema.Errors(resp).First();
|
|
166
|
+
const err = new ValidationError(error, "body");
|
|
167
|
+
rej(err);
|
|
168
|
+
}
|
|
169
|
+
res(resp);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
var matchSegments = (pattern, segments) => {
|
|
174
|
+
const patternSegments = pattern.split("/");
|
|
175
|
+
const params = {};
|
|
176
|
+
for (let i = 0;i < patternSegments.length; i++) {
|
|
177
|
+
const part = patternSegments[i];
|
|
178
|
+
if (i >= segments.length)
|
|
179
|
+
return null;
|
|
180
|
+
if (part === "*") {
|
|
181
|
+
return params;
|
|
182
|
+
}
|
|
183
|
+
if (part.startsWith(":")) {
|
|
184
|
+
params[part.slice(1)] = decodeURIComponent(segments[i]);
|
|
185
|
+
} else if (part !== segments[i]) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return segments.length === patternSegments.length ? params : null;
|
|
190
|
+
};
|
|
191
|
+
var scorePattern = (pattern) => {
|
|
192
|
+
let score = 0;
|
|
193
|
+
for (const part of pattern.split("/")) {
|
|
194
|
+
if (part === "*")
|
|
195
|
+
score -= 1000;
|
|
196
|
+
else if (part.startsWith(":"))
|
|
197
|
+
score += 1;
|
|
198
|
+
else
|
|
199
|
+
score += 10;
|
|
200
|
+
}
|
|
201
|
+
return score;
|
|
202
|
+
};
|
|
203
|
+
var matchRoute = (routes, method, pathname) => {
|
|
204
|
+
const segments = pathname.split("/");
|
|
205
|
+
let best = null;
|
|
206
|
+
let bestScore = -Infinity;
|
|
207
|
+
for (const pattern in routes) {
|
|
208
|
+
const methods = routes[pattern];
|
|
209
|
+
if (!methods || typeof methods[method] !== "function")
|
|
210
|
+
continue;
|
|
211
|
+
const params = matchSegments(pattern, segments);
|
|
212
|
+
if (params === null)
|
|
213
|
+
continue;
|
|
214
|
+
const score = scorePattern(pattern);
|
|
215
|
+
if (score > bestScore) {
|
|
216
|
+
bestScore = score;
|
|
217
|
+
best = { handler: methods[method], params };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return best;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// src/MarciApp.ts
|
|
224
|
+
class MarciApp {
|
|
225
|
+
routes = {};
|
|
226
|
+
promises = [];
|
|
227
|
+
prefix = "";
|
|
228
|
+
root = null;
|
|
229
|
+
server;
|
|
230
|
+
onListenHooks = [];
|
|
231
|
+
onRequestHooks = [];
|
|
232
|
+
add(path, method, _options, callback) {
|
|
233
|
+
let fullPath = this.prefix + (path.endsWith("/") ? path.slice(0, -1) : path) || "/";
|
|
234
|
+
if (!(fullPath in this.routes)) {
|
|
235
|
+
this.routes[fullPath] = {};
|
|
236
|
+
}
|
|
237
|
+
const options = Array.isArray(_options) ? getRouteOptions(method, _options) : _options;
|
|
238
|
+
const paramsSchema = options.params && !isDefault(options.params) ? unfoldTypeBoxSchema(options.params) : null;
|
|
239
|
+
const querySchema = options.query ? unfoldTypeBoxSchema(options.query) : null;
|
|
240
|
+
const bodyValidation = options.body ? TypeCompiler.Compile(unfoldTypeBoxSchema(options.body)) : null;
|
|
241
|
+
if (paramsSchema && paramsSchema.properties) {
|
|
242
|
+
const arrayKeys = Object.entries(paramsSchema.properties).filter((i) => i[1].type === "array").map((i) => i[0]);
|
|
243
|
+
if (arrayKeys.length > 0) {
|
|
244
|
+
paramsSchema.arrayKeys = arrayKeys;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
this.routes[fullPath][method] = async (req) => {
|
|
248
|
+
const request = new MarciRequestInternal(req, this.root?.server ?? this.server, paramsSchema, querySchema);
|
|
249
|
+
for (const callback2 of this.onRequestHooks) {
|
|
250
|
+
await callback2(request);
|
|
251
|
+
}
|
|
252
|
+
const body = bodyValidation === null ? undefined : await parseBody(bodyValidation, req);
|
|
253
|
+
request.body = body;
|
|
254
|
+
const resp = await callback(request);
|
|
255
|
+
if (resp === undefined) {
|
|
256
|
+
return new Response;
|
|
257
|
+
} else if (resp instanceof Response) {
|
|
258
|
+
return resp;
|
|
259
|
+
} else {
|
|
260
|
+
return Response.json(resp);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
addHook(where, callback) {
|
|
265
|
+
if (where === "onRequest") {
|
|
266
|
+
this.onRequestHooks.push(callback);
|
|
267
|
+
} else if (where === "onListen") {
|
|
268
|
+
this.onListenHooks.push(callback);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
get(path, ...args) {
|
|
272
|
+
if (args.length === 1) {
|
|
273
|
+
this.add(path, "GET", {}, args[0]);
|
|
274
|
+
} else {
|
|
275
|
+
this.add(path, "GET", args[0], args[1]);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
post(path, ...args) {
|
|
279
|
+
if (args.length === 1) {
|
|
280
|
+
this.add(path, "POST", {}, args[0]);
|
|
281
|
+
} else {
|
|
282
|
+
this.add(path, "POST", args[0], args[1]);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
put(path, ...args) {
|
|
286
|
+
if (args.length === 1) {
|
|
287
|
+
this.add(path, "PUT", {}, args[0]);
|
|
288
|
+
} else {
|
|
289
|
+
this.add(path, "PUT", args[0], args[1]);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
patch(path, ...args) {
|
|
293
|
+
if (args.length === 1) {
|
|
294
|
+
this.add(path, "PATCH", {}, args[0]);
|
|
295
|
+
} else {
|
|
296
|
+
this.add(path, "PATCH", args[0], args[1]);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
delete(path, ...args) {
|
|
300
|
+
if (args.length === 1) {
|
|
301
|
+
this.add(path, "DELETE", {}, args[0]);
|
|
302
|
+
} else {
|
|
303
|
+
this.add(path, "DELETE", args[0], args[1]);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
register(plugin, options = {}) {
|
|
307
|
+
const app = new MarciApp;
|
|
308
|
+
app.root = this.root ?? this;
|
|
309
|
+
app.routes = this.routes;
|
|
310
|
+
app.onListenHooks = this.onListenHooks;
|
|
311
|
+
app.prefix = this.prefix + (options.prefix ?? "");
|
|
312
|
+
const resp = plugin(app);
|
|
313
|
+
if (typeof resp === "object") {
|
|
314
|
+
this.promises.push(resp);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
websocket;
|
|
318
|
+
websocketPath;
|
|
319
|
+
websocketFetch;
|
|
320
|
+
registerWsHandler(path, ws) {
|
|
321
|
+
if (typeof path === "string") {
|
|
322
|
+
this.websocket = ws;
|
|
323
|
+
this.websocketPath = path;
|
|
324
|
+
} else if (typeof path === "function") {
|
|
325
|
+
this.websocket = ws;
|
|
326
|
+
this.websocketFetch = path;
|
|
327
|
+
} else {
|
|
328
|
+
this.websocket = path;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
fetch = async (req, server) => {
|
|
332
|
+
const path = new URL(req.url).pathname;
|
|
333
|
+
if ((!this.websocketPath || this.websocketPath.startsWith(path)) && this.websocket) {
|
|
334
|
+
const data = this.websocketFetch ? await this.websocketFetch(req) : {};
|
|
335
|
+
if (data instanceof Response) {
|
|
336
|
+
return data;
|
|
337
|
+
}
|
|
338
|
+
if (server.upgrade(req, { data })) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return new Response(`Route ${req.method}:${path} not found`, { status: 404 });
|
|
343
|
+
};
|
|
344
|
+
registerNotFoundHandler(handler) {
|
|
345
|
+
this.fetch = async (req, server) => {
|
|
346
|
+
const path = new URL(req.url).pathname;
|
|
347
|
+
if ((!this.websocketPath || this.websocketPath.startsWith(path)) && this.websocket) {
|
|
348
|
+
const data = this.websocketFetch ? await this.websocketFetch(req) : {};
|
|
349
|
+
if (data instanceof Response) {
|
|
350
|
+
return data;
|
|
351
|
+
}
|
|
352
|
+
if (server.upgrade(req, { data })) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const resp = await handler(path, req, server);
|
|
357
|
+
return resp;
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
async listen(port, hostname) {
|
|
361
|
+
if (this.promises.length > 0) {
|
|
362
|
+
await Promise.all(this.promises);
|
|
363
|
+
}
|
|
364
|
+
const server = Bun.serve({
|
|
365
|
+
routes: this.routes,
|
|
366
|
+
port,
|
|
367
|
+
hostname,
|
|
368
|
+
fetch: this.fetch,
|
|
369
|
+
websocket: this.websocket,
|
|
370
|
+
error: (err) => this.handleError(err)
|
|
371
|
+
});
|
|
372
|
+
this.server = server;
|
|
373
|
+
for (const callback of this.onListenHooks) {
|
|
374
|
+
await callback(server);
|
|
375
|
+
}
|
|
376
|
+
return server;
|
|
377
|
+
}
|
|
378
|
+
handleError(err) {
|
|
379
|
+
if (err instanceof HTTPError) {
|
|
380
|
+
if (err.data) {
|
|
381
|
+
return Response.json(err.data, { status: err.statusCode });
|
|
382
|
+
}
|
|
383
|
+
return new Response(err.message, { status: err.statusCode });
|
|
384
|
+
} else if (err instanceof TypeBoxError || err instanceof ValidationError) {
|
|
385
|
+
return new Response(getValidationError(err.error, err.where), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
386
|
+
} else {
|
|
387
|
+
console.error(err);
|
|
388
|
+
return new Response(err.message, { status: 500 });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async inject(options) {
|
|
392
|
+
if (this.root)
|
|
393
|
+
return this.root.inject(options);
|
|
394
|
+
const opts = typeof options === "string" ? { url: options } : options;
|
|
395
|
+
if (this.promises.length > 0) {
|
|
396
|
+
await Promise.all(this.promises);
|
|
397
|
+
}
|
|
398
|
+
const method = (opts.method ?? "GET").toUpperCase();
|
|
399
|
+
const rawUrl = opts.url.includes("://") ? opts.url : "http://localhost" + (opts.url.startsWith("/") ? opts.url : "/" + opts.url);
|
|
400
|
+
const url = new URL(rawUrl);
|
|
401
|
+
const pathname = url.pathname;
|
|
402
|
+
const headers = new Headers(opts.headers);
|
|
403
|
+
let body = opts.body;
|
|
404
|
+
if (body !== undefined && body !== null && typeof body === "object" && !(body instanceof ReadableStream) && !(body instanceof Blob) && !(body instanceof FormData) && !(body instanceof URLSearchParams) && !(body instanceof ArrayBuffer) && !ArrayBuffer.isView(body)) {
|
|
405
|
+
body = JSON.stringify(body);
|
|
406
|
+
if (!headers.has("content-type"))
|
|
407
|
+
headers.set("content-type", "application/json");
|
|
408
|
+
}
|
|
409
|
+
const req = new Request(url.href, { method, headers, body: body ?? undefined });
|
|
410
|
+
const match = matchRoute(this.routes, method, pathname);
|
|
411
|
+
if (!match) {
|
|
412
|
+
return this.fetch(req, { upgrade: () => false });
|
|
413
|
+
}
|
|
414
|
+
req.params = match.params;
|
|
415
|
+
try {
|
|
416
|
+
return await match.handler(req);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
return this.handleError(err);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
export {
|
|
423
|
+
marci,
|
|
424
|
+
MarciApp,
|
|
425
|
+
HTTPError
|
|
426
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dynara",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"development": "./src/index.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"version": "0.0.1",
|
|
15
|
+
"description": "Simple HTTP framework powered by Bun",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/den59k/marci.git",
|
|
19
|
+
"directory": "packages/marci"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "bunx bunup src/index.ts --format esm",
|
|
23
|
+
"test": "bun test",
|
|
24
|
+
"release": "bun pm version patch"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"bun-plugin-dts": "^0.3.0",
|
|
28
|
+
"bunup": "^0.8.58"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@types/bun": "*",
|
|
32
|
+
"typescript": "^5"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@sinclair/typebox": "^0.34.37",
|
|
36
|
+
"compact-json-schema": "^0.1.4"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist"
|
|
40
|
+
]
|
|
41
|
+
}
|