galbe 0.1.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/build_test.yml +19 -0
- package/.github/workflows/deploy_website.yml +20 -0
- package/.prettierrc +10 -0
- package/README.md +39 -0
- package/bin/cli.ts +114 -0
- package/bun.lockb +0 -0
- package/docs/Getting Started/basic.md +1 -0
- package/docs/Guides/routes.md +1 -0
- package/docs/getting-started.md +5 -0
- package/package.json +49 -0
- package/scripts/build.ts +14 -0
- package/scripts/hooks/prepare-commit-msg +10 -0
- package/scripts/postinstall.ts +9 -0
- package/src/index.ts +353 -0
- package/src/parser.ts +656 -0
- package/src/router.ts +100 -0
- package/src/routes.ts +157 -0
- package/src/server.ts +174 -0
- package/src/types.ts +279 -0
- package/src/util.ts +12 -0
- package/src/validator.ts +117 -0
- package/test/hooks.test.ts +177 -0
- package/test/parser.test.ts +1198 -0
- package/test/plugins.test.ts +239 -0
- package/test/requests.test.ts +909 -0
- package/test/resources/image.png +0 -0
- package/test/resources/object.badSyntax.json +8 -0
- package/test/resources/object.json +8 -0
- package/test/resources/object.missing.json +6 -0
- package/test/resources/test.route.comment.ts +38 -0
- package/test/resources/test.route.empty.ts +9 -0
- package/test/responses.test.ts +109 -0
- package/test/routeFiles.test.ts +209 -0
- package/test/router.test.ts +207 -0
- package/test/test.utils.ts +102 -0
- package/tsconfig.json +23 -0
package/src/router.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Route, RouteNode, RouteTree } from './types'
|
|
2
|
+
import { NotFoundError } from './types'
|
|
3
|
+
|
|
4
|
+
const ROUTE_REGEX = /^(\/(\*|:?\d+|:?\w+|:?[\w\d][\w-]+[\w\d]))*\/?$/
|
|
5
|
+
|
|
6
|
+
const walkRoutes = (path: string[], node: RouteNode, alts: RouteNode[] = []): RouteNode => {
|
|
7
|
+
if (path.length < 1) throw new NotFoundError()
|
|
8
|
+
if (path.length === 1 && node.route) return node
|
|
9
|
+
|
|
10
|
+
if (node.children?.['*']) alts.push(node.children['*'])
|
|
11
|
+
if (node.param) alts.push(node.param)
|
|
12
|
+
|
|
13
|
+
if (node.children && path[1] in node.children) {
|
|
14
|
+
path.shift()
|
|
15
|
+
return walkRoutes(path, node.children[path[0]], alts)
|
|
16
|
+
}
|
|
17
|
+
if (node.param) {
|
|
18
|
+
path.shift()
|
|
19
|
+
alts.pop()
|
|
20
|
+
try {
|
|
21
|
+
return walkRoutes(path, node.param, alts)
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error instanceof NotFoundError) console.log(error)
|
|
24
|
+
else throw error
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (alts.length > 1) {
|
|
28
|
+
return walkRoutes(path, alts.pop() as RouteNode, alts)
|
|
29
|
+
}
|
|
30
|
+
if (alts.length === 1) {
|
|
31
|
+
let lastAlt = alts.pop() as RouteNode
|
|
32
|
+
try {
|
|
33
|
+
return walkRoutes(path, lastAlt, alts)
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error instanceof NotFoundError) {
|
|
36
|
+
if (lastAlt?.route) return lastAlt
|
|
37
|
+
} else throw error
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
throw new NotFoundError()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class GalbeRouter {
|
|
45
|
+
routes: RouteTree
|
|
46
|
+
prefix: string
|
|
47
|
+
staticRoutes: Map<string, Route>
|
|
48
|
+
constructor(prefix?: string) {
|
|
49
|
+
this.routes = { GET: {}, POST: {}, PUT: {}, PATCH: {}, DELETE: {}, OPTIONS: {} }
|
|
50
|
+
prefix = prefix || ''
|
|
51
|
+
if (prefix && !prefix.match(/^\//)) prefix = `/${prefix}`
|
|
52
|
+
this.prefix = prefix
|
|
53
|
+
this.staticRoutes = new Map()
|
|
54
|
+
}
|
|
55
|
+
add(route: Route) {
|
|
56
|
+
route.path = route?.path?.[0] === '/' ? route.path : `/${route.path}`
|
|
57
|
+
if (!route.path.match(ROUTE_REGEX)) throw new SyntaxError(`${route.path} is not a valid route path.`)
|
|
58
|
+
const isStatic = !route.path.match(/(:[\w\d-]+|\*)/)
|
|
59
|
+
if (isStatic) this.staticRoutes.set(`[${route.method.toUpperCase()}]${route.path}`, route)
|
|
60
|
+
route.path = `${this.prefix || ''}${route.path}`
|
|
61
|
+
let path = route.path.replace(/^\/$(.*)\/?$/, '$1').split('/')
|
|
62
|
+
path.shift()
|
|
63
|
+
let r = this.routes[route.method.toUpperCase()]
|
|
64
|
+
if (!path.length) {
|
|
65
|
+
r.route = route
|
|
66
|
+
} else {
|
|
67
|
+
while (path.length) {
|
|
68
|
+
let p = path.shift()
|
|
69
|
+
if (p === undefined) break
|
|
70
|
+
if (!path.length) {
|
|
71
|
+
if (p.match(/^:/)) r.param = { route }
|
|
72
|
+
else {
|
|
73
|
+
if (!r.children) r.children = {}
|
|
74
|
+
r.children[p] = { ...r.children[p], route }
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
if (p.match(/^:/)) {
|
|
78
|
+
if (!r.param) r.param = {}
|
|
79
|
+
r = r.param
|
|
80
|
+
} else {
|
|
81
|
+
if (!r.children) r.children = {}
|
|
82
|
+
if (!(p in r.children)) r.children[p] = {}
|
|
83
|
+
r = r.children[p]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
find(method: string, path: string) {
|
|
90
|
+
const staticRoute = this.staticRoutes.get(`[${method}]${path}`)
|
|
91
|
+
if (staticRoute !== undefined) return staticRoute
|
|
92
|
+
let parts = path
|
|
93
|
+
.replace(/\/+/g, '/')
|
|
94
|
+
.replace(/^\/$(.*)\/?$/, '$1')
|
|
95
|
+
.split('/')
|
|
96
|
+
const route = walkRoutes(parts, this.routes[method]).route
|
|
97
|
+
if (!route) throw new NotFoundError()
|
|
98
|
+
return route
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { GalbeConfig } from './types'
|
|
2
|
+
|
|
3
|
+
import { readdir, lstat } from 'fs/promises'
|
|
4
|
+
import { extname } from 'path'
|
|
5
|
+
import { parse } from 'acorn'
|
|
6
|
+
import { simple } from 'acorn-walk'
|
|
7
|
+
import { Galbe } from './index'
|
|
8
|
+
import { transformSync } from '@swc/core'
|
|
9
|
+
import { Glob } from 'bun'
|
|
10
|
+
|
|
11
|
+
export type RouteMeta = {
|
|
12
|
+
header: Record<string, boolean | string | string[]>
|
|
13
|
+
routes: Record<string, Record<string, Record<string, boolean | string | string[]>>>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type RouteFileMeta = {
|
|
17
|
+
file: string
|
|
18
|
+
} & RouteMeta
|
|
19
|
+
|
|
20
|
+
const parseComment = (comment: string): Record<string, string | string[]> => {
|
|
21
|
+
const head =
|
|
22
|
+
comment
|
|
23
|
+
.match(/^([^@]*)/)?.[1]
|
|
24
|
+
.replace(/^ *\* */gm, '')
|
|
25
|
+
.trim() || ''
|
|
26
|
+
const refs = {
|
|
27
|
+
...(head ? { head } : {}),
|
|
28
|
+
...[...comment.matchAll(new RegExp(`^\\s*\\*\\s*@([a-zA-Z_][0-9a-zA-Z_]*)(?:$|\\s+([^\\n]*)\\s*$)`, 'gm'))].reduce(
|
|
29
|
+
(acc, n) => {
|
|
30
|
+
return {
|
|
31
|
+
...acc,
|
|
32
|
+
[n[1]]:
|
|
33
|
+
n[1] in acc ? [...(typeof acc[n[1]] === 'string' ? [acc[n[1]]] : acc[n[1]]), n[2] ?? true] : n[2] ?? true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{} as Record<string, any>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
return refs
|
|
40
|
+
}
|
|
41
|
+
export const metaAnalysis = async (filePath: string): Promise<RouteMeta> => {
|
|
42
|
+
const file = Bun.file(filePath)
|
|
43
|
+
const fileExt = extname(filePath)
|
|
44
|
+
let content = await file.text()
|
|
45
|
+
let meta: RouteMeta = { header: {}, routes: {} }
|
|
46
|
+
|
|
47
|
+
if (fileExt === '.ts') {
|
|
48
|
+
//// Much faster but doesn't include comments. See https://github.com/oven-sh/bun/pull/7055
|
|
49
|
+
// content = new Bun.Transpiler({
|
|
50
|
+
// loader: 'ts',
|
|
51
|
+
// target: 'bun',
|
|
52
|
+
// tsconfig: {
|
|
53
|
+
// compilerOptions: {
|
|
54
|
+
// // @ts-ignore https://github.com/oven-sh/bun/pull/7055
|
|
55
|
+
// removeComments: false
|
|
56
|
+
// }
|
|
57
|
+
// }
|
|
58
|
+
// }).transformSync(content)
|
|
59
|
+
content = transformSync(content, {
|
|
60
|
+
jsc: {
|
|
61
|
+
parser: {
|
|
62
|
+
syntax: 'typescript'
|
|
63
|
+
},
|
|
64
|
+
preserveAllComments: true,
|
|
65
|
+
target: 'esnext'
|
|
66
|
+
}
|
|
67
|
+
}).code
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const comments: Record<number, string> = {}
|
|
71
|
+
|
|
72
|
+
const ast = parse(content, {
|
|
73
|
+
ecmaVersion: 'latest',
|
|
74
|
+
sourceType: 'module',
|
|
75
|
+
locations: true,
|
|
76
|
+
onComment: (isBlock, text, _start, _end, _locStart, locEnd) => {
|
|
77
|
+
if (isBlock && locEnd?.line) comments[locEnd?.line] = text
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
simple(ast, {
|
|
81
|
+
ExportDefaultDeclaration(node) {
|
|
82
|
+
// @ts-ignore
|
|
83
|
+
let galbeIdentifier = node.declaration.params[0].name
|
|
84
|
+
|
|
85
|
+
const headerLine = node.loc?.start.line
|
|
86
|
+
const headerCom = headerLine !== undefined && comments?.[headerLine] ? comments[headerLine] : ''
|
|
87
|
+
const headerRef = parseComment(headerCom)
|
|
88
|
+
meta.header = headerRef
|
|
89
|
+
// @ts-ignore
|
|
90
|
+
simple(node.declaration.body, {
|
|
91
|
+
CallExpression(node) {
|
|
92
|
+
// @ts-ignore
|
|
93
|
+
if (node?.callee?.object?.name === galbeIdentifier) {
|
|
94
|
+
// @ts-ignore
|
|
95
|
+
const path = node.arguments[0].value
|
|
96
|
+
// @ts-ignore
|
|
97
|
+
const method = node.callee.property.name
|
|
98
|
+
const line = node.loc?.start.line
|
|
99
|
+
const com = line !== undefined && comments?.[line] ? comments[line] : ''
|
|
100
|
+
|
|
101
|
+
const routeRefs = parseComment(com)
|
|
102
|
+
|
|
103
|
+
if (!(path in meta.routes)) meta.routes[path] = {}
|
|
104
|
+
if (!(method in meta.routes[path])) meta.routes[path][method] = routeRefs
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
return meta
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const importRoutes = async (filePath: string, galbe: Galbe) => {
|
|
114
|
+
const routes = (await import(filePath)).default
|
|
115
|
+
routes(galbe)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const defineRoutes = async (options: GalbeConfig, galbe: Galbe) => {
|
|
119
|
+
const routes = options?.routes
|
|
120
|
+
if (!routes) {
|
|
121
|
+
console.log(`\x1b\[38;5;245m No route file defined\x1b[0m`)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
const root = process.cwd()
|
|
125
|
+
if (typeof routes === 'string') {
|
|
126
|
+
let noRouteFound = true
|
|
127
|
+
for await (const path of new Glob(routes).scan({ cwd: root, absolute: true, onlyFiles: false })) {
|
|
128
|
+
noRouteFound = false
|
|
129
|
+
const isDir = (await lstat(path)).isDirectory()
|
|
130
|
+
|
|
131
|
+
let files: string[] = []
|
|
132
|
+
if (!isDir) files.push(path)
|
|
133
|
+
else files = files.concat((await readdir(path)).map(f => `${path}/${f}`))
|
|
134
|
+
if (files.length === 0) console.log(`\x1b\[38;5;245m No route found\x1b[0m`)
|
|
135
|
+
for (const f of files) {
|
|
136
|
+
try {
|
|
137
|
+
const metadata = await metaAnalysis(f)
|
|
138
|
+
galbe.meta?.push({ file: path, ...metadata })
|
|
139
|
+
console.log(`\n\x1b\[0;36m ${f}\x1b[0m`)
|
|
140
|
+
await importRoutes(f, galbe)
|
|
141
|
+
} catch (err) {
|
|
142
|
+
// console.log(`\x1b\[0;31m ${f}\x1b[0m`)
|
|
143
|
+
throw err
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (noRouteFound) {
|
|
148
|
+
process.stdout.write('\r\x1b[K')
|
|
149
|
+
console.log(`\x1b\[38;5;245m No route found\x1b[0m`)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
} else if (Array.isArray(routes)) {
|
|
153
|
+
for (const r of routes) {
|
|
154
|
+
await defineRoutes({ routes: r }, galbe)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { Context, Route } from './types'
|
|
2
|
+
|
|
3
|
+
import { NotFoundError, RequestError } from './types'
|
|
4
|
+
import { parseEntry, requestBodyParser, requestPathParser, responseParser } from './parser'
|
|
5
|
+
import { Galbe } from './index'
|
|
6
|
+
|
|
7
|
+
const handleInternalError = (error: any) => {
|
|
8
|
+
console.error(error)
|
|
9
|
+
return new RequestError({ status: 500, error: 'Internal Server Error' })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const setupPluginCallbacks = (galbe: Galbe) => ({
|
|
13
|
+
init: galbe.plugins.reduce((l: { name: string; cb: Function }[], p) => {
|
|
14
|
+
if (p.init) l.push({ name: p.name, cb: p.init })
|
|
15
|
+
return l
|
|
16
|
+
}, []),
|
|
17
|
+
onFetch: galbe.plugins.reduce((l: Function[], p) => {
|
|
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
|
+
}, [])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export default async (galbe: Galbe, port?: number) => {
|
|
36
|
+
const router = galbe.router
|
|
37
|
+
if (galbe?.config?.basePath && galbe?.config?.basePath[0] !== '/')
|
|
38
|
+
galbe.config.basePath = `/${galbe?.config?.basePath}`
|
|
39
|
+
let pluginsCb = setupPluginCallbacks(galbe)
|
|
40
|
+
for (const { name, cb } of pluginsCb.init) await cb(galbe?.config?.plugin?.[name], galbe)
|
|
41
|
+
|
|
42
|
+
return Bun.serve({
|
|
43
|
+
port: port || galbe.config?.port || 3000,
|
|
44
|
+
async fetch(req) {
|
|
45
|
+
const context: Context = {
|
|
46
|
+
request: req,
|
|
47
|
+
set: { headers: {} },
|
|
48
|
+
headers: {},
|
|
49
|
+
params: {},
|
|
50
|
+
query: {},
|
|
51
|
+
body: {},
|
|
52
|
+
state: {}
|
|
53
|
+
}
|
|
54
|
+
for (const cb of pluginsCb.onFetch) {
|
|
55
|
+
const r = await cb(req)
|
|
56
|
+
if (r) return r
|
|
57
|
+
}
|
|
58
|
+
const url = new URL(req.url)
|
|
59
|
+
let route: Route
|
|
60
|
+
let response: any = ''
|
|
61
|
+
try {
|
|
62
|
+
// find route
|
|
63
|
+
if (!url.pathname.match(new RegExp(`^${galbe.config?.basePath || ''}`))) throw new NotFoundError()
|
|
64
|
+
try {
|
|
65
|
+
route = router.find(req.method, url.pathname)
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error instanceof RequestError) throw error
|
|
68
|
+
else throw handleInternalError(error)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const cb of pluginsCb.onRoute) {
|
|
72
|
+
const r = await cb(route)
|
|
73
|
+
if (r) return r
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// parse request
|
|
77
|
+
const schema = route.schema
|
|
78
|
+
let inHeaders = Object.fromEntries(req.headers.entries())
|
|
79
|
+
let inQuery = Object.fromEntries(url.searchParams.entries())
|
|
80
|
+
let inParams = requestPathParser(url.pathname, route.path)
|
|
81
|
+
|
|
82
|
+
context.body = await requestBodyParser(req.body, inHeaders, schema.body)
|
|
83
|
+
context.headers = { ...context.headers, ...inHeaders }
|
|
84
|
+
context.query = inQuery
|
|
85
|
+
context.params = inParams
|
|
86
|
+
|
|
87
|
+
// request validation
|
|
88
|
+
let errors: RequestError[] = []
|
|
89
|
+
try {
|
|
90
|
+
if (schema?.headers)
|
|
91
|
+
context.headers = {
|
|
92
|
+
...context.headers,
|
|
93
|
+
...parseEntry(context.headers, schema.headers, { name: 'headers', i: true })
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (error instanceof RequestError) errors.push(error)
|
|
97
|
+
else throw handleInternalError(error)
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
if (schema?.query) context.query = parseEntry(context.query, schema.query, { name: 'query' })
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error instanceof RequestError) errors.push(error)
|
|
103
|
+
else throw handleInternalError(error)
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
if (schema?.params) context.params = parseEntry(context.params, schema.params, { name: 'params' })
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error instanceof RequestError) errors.push(error)
|
|
109
|
+
else throw handleInternalError(error)
|
|
110
|
+
}
|
|
111
|
+
if (errors.length) {
|
|
112
|
+
throw new RequestError({ status: 400, error: errors.reduce((acc, c) => ({ ...acc, ...c.error }), {}) })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const cb of pluginsCb.beforeHandle) {
|
|
116
|
+
const r = await cb(context)
|
|
117
|
+
if (r) return r
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// call chain
|
|
121
|
+
let handlerCalled = false
|
|
122
|
+
const handlerWrapper = async (context: Context) => {
|
|
123
|
+
handlerCalled = true
|
|
124
|
+
return route.handler(context)
|
|
125
|
+
}
|
|
126
|
+
const callChain = route.hooks.map((hook, idx) => ({
|
|
127
|
+
call: async () => {
|
|
128
|
+
let nextCalled = false
|
|
129
|
+
let next = async () => {
|
|
130
|
+
await callChain[idx + 1].call()
|
|
131
|
+
}
|
|
132
|
+
await hook(context, next)
|
|
133
|
+
if (!nextCalled && !handlerCalled) await next()
|
|
134
|
+
}
|
|
135
|
+
}))
|
|
136
|
+
callChain.push({
|
|
137
|
+
call: async () => {
|
|
138
|
+
response = await handlerWrapper(context)
|
|
139
|
+
context.set.status = response instanceof Response ? response.status : 200
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
if (callChain.length > 1) await callChain[0].call()
|
|
143
|
+
else response = await handlerWrapper(context)
|
|
144
|
+
const parsedResponse = responseParser(response, context)
|
|
145
|
+
|
|
146
|
+
for (const cb of pluginsCb.afterHandle) {
|
|
147
|
+
const r = await cb(parsedResponse)
|
|
148
|
+
if (r) return r
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return parsedResponse
|
|
152
|
+
} catch (error) {
|
|
153
|
+
context.set.status = error instanceof RequestError ? error.status : 500
|
|
154
|
+
if (galbe.errorHandler) return galbe.errorHandler(error, context)
|
|
155
|
+
if (error instanceof RequestError) {
|
|
156
|
+
return new Response(JSON.stringify(error.error), {
|
|
157
|
+
status: error.status,
|
|
158
|
+
headers: { 'Content-Type': 'application/json' }
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
return new Response('Internal Server Error', { status: 500 })
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
error(error) {
|
|
165
|
+
console.error(error)
|
|
166
|
+
return new Response('Internal Server Error', {
|
|
167
|
+
status: 500,
|
|
168
|
+
headers: {
|
|
169
|
+
'Content-Type': 'text/plain'
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TSchema,
|
|
3
|
+
TBoolean,
|
|
4
|
+
TNumber,
|
|
5
|
+
TInteger,
|
|
6
|
+
TString,
|
|
7
|
+
TLiteral,
|
|
8
|
+
TArray,
|
|
9
|
+
TObject,
|
|
10
|
+
TProperties,
|
|
11
|
+
TUnion,
|
|
12
|
+
Static,
|
|
13
|
+
OptionalPropertyKeys,
|
|
14
|
+
ReadonlyOptionalPropertyKeys,
|
|
15
|
+
ReadonlyPropertyKeys,
|
|
16
|
+
RequiredPropertyKeys,
|
|
17
|
+
TAny
|
|
18
|
+
} from '@sinclair/typebox'
|
|
19
|
+
|
|
20
|
+
import { Kind } from '@sinclair/typebox'
|
|
21
|
+
import { Galbe } from './index'
|
|
22
|
+
|
|
23
|
+
export const Stream = Symbol.for('Galbe.Stream')
|
|
24
|
+
export type TStream<T extends TSchema = TSchema> = T & {
|
|
25
|
+
[Stream]: 'Stream'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type TBody =
|
|
29
|
+
| TByteArray
|
|
30
|
+
| TString
|
|
31
|
+
| TBoolean
|
|
32
|
+
| TNumber
|
|
33
|
+
| TInteger
|
|
34
|
+
| TObject
|
|
35
|
+
| TArray
|
|
36
|
+
| TUrlForm
|
|
37
|
+
| TMultipartForm
|
|
38
|
+
| TUnion
|
|
39
|
+
| TStream
|
|
40
|
+
export type TStreamable = TByteArray | TString | TUrlForm | TMultipartForm
|
|
41
|
+
export type TUrlFormParam = TString | TByteArray | TBoolean | TNumber | TInteger | TLiteral | TArray | TAny | TUnion
|
|
42
|
+
export type TMultipartFormParam = TByteArray | TUrlFormParam | TObject | TArray
|
|
43
|
+
export type MaybeArray<T> = T | T[]
|
|
44
|
+
|
|
45
|
+
export interface MultipartFormData<
|
|
46
|
+
K extends string | number | symbol = string,
|
|
47
|
+
V extends Static<TMultipartFormParam> = any
|
|
48
|
+
> {
|
|
49
|
+
headers: { type?: string; name: K; filename?: string }
|
|
50
|
+
content: V
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TByteArray extends TSchema {
|
|
54
|
+
[Kind]: 'ByteArray'
|
|
55
|
+
static: Uint8Array
|
|
56
|
+
type: 'byteArray'
|
|
57
|
+
}
|
|
58
|
+
export interface TMultipartForm<T extends TMultipartProperties = TMultipartProperties> extends TSchema {
|
|
59
|
+
[Kind]: 'MultipartForm'
|
|
60
|
+
static: MultipartPropertiesReduce<T, this['params']>
|
|
61
|
+
type: 'multipartForm'
|
|
62
|
+
}
|
|
63
|
+
export interface TUrlForm<T extends TUrlFormProperties = TUrlFormProperties> extends TSchema {
|
|
64
|
+
[Kind]: 'UrlForm'
|
|
65
|
+
static: UrlFormPropertiesReduce<T, this['params']>
|
|
66
|
+
type: 'urlForm'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type TMultipartProperties<V = TMultipartFormParam> = Record<string, V>
|
|
70
|
+
export type MultipartPropertiesReduce<T extends TMultipartProperties, P extends unknown[]> = MultipartPropertiesReducer<
|
|
71
|
+
T,
|
|
72
|
+
{
|
|
73
|
+
[K in keyof T]: Static<T[K], P>
|
|
74
|
+
}
|
|
75
|
+
>
|
|
76
|
+
export type MultipartPropertiesReducer<
|
|
77
|
+
T extends TMultipartProperties,
|
|
78
|
+
R extends Record<keyof any, unknown>
|
|
79
|
+
> = MultipartEvaluate<
|
|
80
|
+
Readonly<Partial<Pick<R, ReadonlyOptionalPropertyKeys<T>>>> &
|
|
81
|
+
Readonly<Pick<R, ReadonlyPropertyKeys<T>>> &
|
|
82
|
+
Partial<Pick<R, OptionalPropertyKeys<T>>> &
|
|
83
|
+
Required<Pick<R, RequiredPropertyKeys<T>>>
|
|
84
|
+
>
|
|
85
|
+
export type MultipartEvaluate<T> = T extends infer O
|
|
86
|
+
? {
|
|
87
|
+
[K in keyof O]: O[K] extends Static<TMultipartFormParam> ? MultipartFormData<K, O[K]> : never
|
|
88
|
+
}
|
|
89
|
+
: never
|
|
90
|
+
|
|
91
|
+
export type TUrlFormProperties<V = TUrlFormParam> = Record<string, V>
|
|
92
|
+
export type UrlFormPropertiesReduce<T extends TUrlFormProperties, P extends unknown[]> = UrlFormPropertiesReducer<
|
|
93
|
+
T,
|
|
94
|
+
{
|
|
95
|
+
[K in keyof T]: Static<T[K], P>
|
|
96
|
+
}
|
|
97
|
+
>
|
|
98
|
+
export type UrlFormPropertiesReducer<
|
|
99
|
+
T extends TUrlFormProperties,
|
|
100
|
+
R extends Record<keyof any, unknown>
|
|
101
|
+
> = UrlFormEvaluate<
|
|
102
|
+
Readonly<Partial<Pick<R, ReadonlyOptionalPropertyKeys<T>>>> &
|
|
103
|
+
Readonly<Pick<R, ReadonlyPropertyKeys<T>>> &
|
|
104
|
+
Partial<Pick<R, OptionalPropertyKeys<T>>> &
|
|
105
|
+
Required<Pick<R, RequiredPropertyKeys<T>>>
|
|
106
|
+
>
|
|
107
|
+
export type UrlFormEvaluate<T> = T extends infer O
|
|
108
|
+
? {
|
|
109
|
+
[K in keyof O]: O[K] extends Static<TUrlFormParam> ? O[K] : never
|
|
110
|
+
}
|
|
111
|
+
: never
|
|
112
|
+
|
|
113
|
+
export type Method = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options'
|
|
114
|
+
type MaybePromise<T> = T | Promise<T>
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* #### GalbeConfig
|
|
118
|
+
* Instanciate a Galbe web server
|
|
119
|
+
*
|
|
120
|
+
* ---
|
|
121
|
+
* @example
|
|
122
|
+
* ```typescript
|
|
123
|
+
* import { Galbe } from 'galbe'
|
|
124
|
+
* const config : GalbeConfig = {
|
|
125
|
+
* port: 8080,
|
|
126
|
+
* basePath: "/v1",
|
|
127
|
+
* routes: "src/**/*.route.ts"
|
|
128
|
+
* }
|
|
129
|
+
*
|
|
130
|
+
* export default new Galbe(config)
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
export type GalbeConfig = {
|
|
134
|
+
port?: number
|
|
135
|
+
basePath?: string
|
|
136
|
+
routes?: string | string[]
|
|
137
|
+
plugin?: Record<string, any>
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* #### Schema
|
|
141
|
+
* Define a request Schema with constraint upon
|
|
142
|
+
*
|
|
143
|
+
* ---
|
|
144
|
+
* @example
|
|
145
|
+
* ```typescript
|
|
146
|
+
* import { T, Schema } from 'galbe'
|
|
147
|
+
* const MyRequestSchema = {
|
|
148
|
+
* params: {
|
|
149
|
+
* id: T.Number(),
|
|
150
|
+
* },
|
|
151
|
+
* body: T.Object({
|
|
152
|
+
* name: T.String()
|
|
153
|
+
* age: T.Optional(T.Number({minimum: 0})),
|
|
154
|
+
* })
|
|
155
|
+
* }
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
export type Schema<
|
|
159
|
+
H extends TProperties = TProperties,
|
|
160
|
+
P extends TProperties = TProperties,
|
|
161
|
+
Q extends TProperties = TProperties,
|
|
162
|
+
B extends TBody = TBody
|
|
163
|
+
> = {
|
|
164
|
+
headers?: H
|
|
165
|
+
params?: P
|
|
166
|
+
query?: Q
|
|
167
|
+
body?: B
|
|
168
|
+
}
|
|
169
|
+
export type Context<S extends Schema = any> = {
|
|
170
|
+
headers: Static<TObject<Exclude<S['headers'], undefined>>>
|
|
171
|
+
params: Static<TObject<Exclude<S['params'], undefined>>>
|
|
172
|
+
query: Static<TObject<Exclude<S['query'], undefined>>>
|
|
173
|
+
body: Static<Exclude<S['body'], undefined>>
|
|
174
|
+
request: Request
|
|
175
|
+
state: Record<string, any>
|
|
176
|
+
set: {
|
|
177
|
+
headers: {
|
|
178
|
+
[header: string]: string
|
|
179
|
+
}
|
|
180
|
+
status?: number
|
|
181
|
+
redirect?: string
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export type Next = () => void | Promise<void>
|
|
185
|
+
export type Hook<S extends Schema = Schema> = (ctx: Context<S>, next: Next) => any | Promise<any>
|
|
186
|
+
export type Handler<S extends Schema = Schema> = (ctx: Context<S>) => any
|
|
187
|
+
export type Endpoint = {
|
|
188
|
+
<H extends TProperties, P extends TProperties, Q extends TProperties, B extends TBody = TObject>(
|
|
189
|
+
path: string,
|
|
190
|
+
schema: Schema<H, P, Q, B>,
|
|
191
|
+
hooks: Hook<Schema<H, P, Q, B>>[],
|
|
192
|
+
handler: Handler<Schema<H, P, Q, B>>
|
|
193
|
+
): void
|
|
194
|
+
<H extends TProperties, P extends TProperties, Q extends TProperties, B extends TBody = TObject>(
|
|
195
|
+
path: string,
|
|
196
|
+
schema: Schema<H, P, Q, B>,
|
|
197
|
+
handler: Handler<Schema<H, P, Q, B>>
|
|
198
|
+
): void
|
|
199
|
+
<H extends TProperties, P extends TProperties, Q extends TProperties, B extends TBody = TObject>(
|
|
200
|
+
path: string,
|
|
201
|
+
hooks: Hook<Schema<H, P, Q, B>>[],
|
|
202
|
+
handler: Handler<Schema<H, P, Q, B>>
|
|
203
|
+
): void
|
|
204
|
+
<H extends TProperties, P extends TProperties, Q extends TProperties, B extends TBody = TObject>(
|
|
205
|
+
path: string,
|
|
206
|
+
handler: Handler<Schema<H, P, Q, B>>
|
|
207
|
+
): void
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export class RequestError {
|
|
211
|
+
status: number
|
|
212
|
+
error: any
|
|
213
|
+
constructor(options: { status?: number; error?: any }) {
|
|
214
|
+
this.status = options.status ?? 500
|
|
215
|
+
this.error = options.error ?? 'Internal server error'
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export type ErrorHandler = (error: any, context: Context) => any
|
|
220
|
+
|
|
221
|
+
export type RouteNode = {
|
|
222
|
+
route?: Route
|
|
223
|
+
param?: RouteNode
|
|
224
|
+
children?: Record<string, RouteNode>
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export type Route<
|
|
228
|
+
H extends TProperties = TProperties,
|
|
229
|
+
P extends TProperties = TProperties,
|
|
230
|
+
Q extends TProperties = TProperties,
|
|
231
|
+
B extends TBody = TBody
|
|
232
|
+
> = {
|
|
233
|
+
method: Method
|
|
234
|
+
path: string
|
|
235
|
+
schema: Schema<H, P, Q, B>
|
|
236
|
+
context: Context<Schema<H, P, Q, B>>
|
|
237
|
+
hooks: Hook[]
|
|
238
|
+
handler: Handler<Schema<H, P, Q, B>>
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export type RouteTree = {
|
|
242
|
+
[key: string]: RouteNode
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export class NotFoundError extends RequestError {
|
|
246
|
+
constructor(message?: string) {
|
|
247
|
+
super({ status: 404, error: message ?? 'Not found' })
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* #### GalbePlugin
|
|
253
|
+
* Define a plugin for a Galbe server
|
|
254
|
+
*
|
|
255
|
+
* ---
|
|
256
|
+
* @example
|
|
257
|
+
* ```typescript
|
|
258
|
+
* import { GalbePlugin } from 'galbe'
|
|
259
|
+
* const MyPlugin : GalbePlugin = {
|
|
260
|
+
* name: 'com.example.plugin.name',
|
|
261
|
+
* init: (config, galbe) => {
|
|
262
|
+
* console.log('Plugin initialization')
|
|
263
|
+
* },
|
|
264
|
+
* onRoute: (route) => {
|
|
265
|
+
* if(route.path === '/myPlugin') {
|
|
266
|
+
* return new Response('Hello Mom!')
|
|
267
|
+
* }
|
|
268
|
+
* }
|
|
269
|
+
* }
|
|
270
|
+
* ```
|
|
271
|
+
*/
|
|
272
|
+
export type GalbePlugin = {
|
|
273
|
+
name: string
|
|
274
|
+
init?: (config: any, galbe: Galbe) => MaybePromise<void>
|
|
275
|
+
onFetch?: (request: Request) => MaybePromise<Response | void>
|
|
276
|
+
onRoute?: (route: Route) => MaybePromise<Response | void>
|
|
277
|
+
beforeHandle?: (context: Context) => MaybePromise<Response | void>
|
|
278
|
+
afterHandle?: (response: Response) => MaybePromise<Response | void>
|
|
279
|
+
}
|