galbe 0.15.6 → 0.16.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/README.md +3 -0
- package/bin/commands/build.ts +30 -19
- package/bin/commands/dev.ts +53 -5
- package/bin/commands/generate/cli/index.ts +4 -1
- package/bin/commands/generate/client.ts +61 -30
- package/bin/commands/generate/code/openapi.parser.ts +440 -163
- package/bin/commands/generate/code/route-merge.ts +26 -21
- package/bin/commands/generate/code.ts +15 -1
- package/bin/commands/generate/model.ts +4 -1
- package/bin/commands/generate/spec.ts +3 -1
- package/bin/res/client.runtime.ts +5 -0
- package/bin/util.ts +36 -90
- package/package.json +34 -9
- package/src/cookies.ts +29 -8
- package/src/extras/spec/openapi.serializer.ts +287 -99
- package/src/extras.ts +1 -1
- package/src/index.ts +377 -71
- package/src/middlewares/_auth.ts +178 -0
- package/src/middlewares/apiKey.ts +139 -0
- package/src/middlewares/basicAuth.ts +151 -0
- package/src/middlewares/bearer.ts +136 -0
- package/src/middlewares/jwt.ts +455 -0
- package/src/middlewares/logger.ts +120 -0
- package/src/middlewares/rateLimit.ts +153 -0
- package/src/middlewares/requestId.ts +94 -0
- package/src/middlewares/timing.ts +86 -0
- package/src/middlewares.ts +53 -0
- package/src/parser.ts +279 -133
- package/src/router.ts +74 -51
- package/src/routes.ts +220 -136
- package/src/schema.ts +123 -31
- package/src/server.ts +130 -70
- package/src/types.ts +366 -90
- package/src/util.ts +271 -5
- package/src/validator.compile.ts +343 -0
- package/src/validator.ts +64 -18
- package/bin/res/client.template.ts +0 -200
- package/scripts/release.ts +0 -196
package/src/router.ts
CHANGED
|
@@ -13,51 +13,41 @@ const normalizePath = (path: string) => (path.length > 1 ? path.replace(/\/+$/g,
|
|
|
13
13
|
|
|
14
14
|
const DEFAULT_CACHE_LIMIT = 1024
|
|
15
15
|
|
|
16
|
-
const walk = (path: string[], node: RouteNode, index: number = 0): RouteNode => {
|
|
16
|
+
const walk = (path: string[], node: RouteNode, index: number = 0): RouteNode | null => {
|
|
17
17
|
if (index === path.length - 1) {
|
|
18
18
|
if (node.routes && !!Object.keys(node.routes).length) return node
|
|
19
19
|
// /a/* should match /a — fall back to a wildcard child if the node has no
|
|
20
20
|
// routes of its own.
|
|
21
21
|
const wc = node.children?.['*']
|
|
22
22
|
if (wc?.routes && !!Object.keys(wc.routes).length) return wc
|
|
23
|
-
|
|
23
|
+
return null
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
const nextSegment = path[index + 1]
|
|
27
27
|
|
|
28
28
|
// 1. Exact Match
|
|
29
29
|
if (node.children && nextSegment !== undefined && nextSegment in node.children) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
} catch (error) {
|
|
33
|
-
if (!(error instanceof NotFoundError)) throw error
|
|
34
|
-
}
|
|
30
|
+
const r = walk(path, node.children[nextSegment]!, index + 1)
|
|
31
|
+
if (r) return r
|
|
35
32
|
}
|
|
36
33
|
|
|
37
34
|
// 2. Param Match
|
|
38
35
|
if (node.param) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
} catch (error) {
|
|
42
|
-
if (!(error instanceof NotFoundError)) throw error
|
|
43
|
-
}
|
|
36
|
+
const r = walk(path, node.param, index + 1)
|
|
37
|
+
if (r) return r
|
|
44
38
|
}
|
|
45
39
|
|
|
46
40
|
// 3. Wildcard Match
|
|
47
41
|
if (node.children && '*' in node.children) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
if (!(error instanceof NotFoundError)) throw error
|
|
57
|
-
}
|
|
42
|
+
const wc = node.children['*']
|
|
43
|
+
const r = walk(path, wc, index + 1)
|
|
44
|
+
if (r) return r
|
|
45
|
+
// a wildcard with routes of its own swallows the remaining segments:
|
|
46
|
+
// /a/* answers /a/b/c once the deeper walk found nothing
|
|
47
|
+
if (wc.routes && !!Object.keys(wc.routes).length) return wc
|
|
58
48
|
}
|
|
59
49
|
|
|
60
|
-
|
|
50
|
+
return null
|
|
61
51
|
}
|
|
62
52
|
|
|
63
53
|
export class GalbeRouter {
|
|
@@ -66,7 +56,15 @@ export class GalbeRouter {
|
|
|
66
56
|
cacheEnabled: boolean
|
|
67
57
|
cacheLimit: number
|
|
68
58
|
cachedRoutes: Map<string, Route | null>
|
|
69
|
-
|
|
59
|
+
// registration-conflict warnings; injectable so tests can observe them. The
|
|
60
|
+
// console.warn default is silenced in production, an injected hook is not.
|
|
61
|
+
warn: (message: string) => void
|
|
62
|
+
constructor(options?: {
|
|
63
|
+
prefix?: string
|
|
64
|
+
cacheEnabled?: boolean
|
|
65
|
+
cacheLimit?: number
|
|
66
|
+
warn?: (message: string) => void
|
|
67
|
+
}) {
|
|
70
68
|
this.routes = { routes: {} }
|
|
71
69
|
let prefix = options?.prefix || ''
|
|
72
70
|
if (prefix && !prefix.match(/^\//)) prefix = `/${prefix}`
|
|
@@ -74,6 +72,7 @@ export class GalbeRouter {
|
|
|
74
72
|
this.cachedRoutes = new Map()
|
|
75
73
|
this.cacheEnabled = options?.cacheEnabled ?? false
|
|
76
74
|
this.cacheLimit = options?.cacheLimit ?? DEFAULT_CACHE_LIMIT
|
|
75
|
+
this.warn = options?.warn ?? (message => Bun.env.BUN_ENV !== 'production' && console.warn(message))
|
|
77
76
|
}
|
|
78
77
|
// bounded LRU: gets refresh recency, sets evict the oldest entry once past
|
|
79
78
|
// cacheLimit, so a flood of distinct lookups (including cached misses) can't
|
|
@@ -100,33 +99,48 @@ export class GalbeRouter {
|
|
|
100
99
|
let path = route.path.replace(/^\/+|\/+$/g, '').split('/')
|
|
101
100
|
if (path[0] === '') path.shift()
|
|
102
101
|
let r = this.routes
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (!r.param) r.param = { routes: {} }
|
|
121
|
-
r = r.param
|
|
122
|
-
} else {
|
|
123
|
-
if (!r.children) r.children = newChildren()
|
|
124
|
-
if (!(p in r.children)) r.children[p] = { routes: {} }
|
|
125
|
-
r = r.children[p]!
|
|
126
|
-
}
|
|
127
|
-
}
|
|
102
|
+
let walked = ''
|
|
103
|
+
while (path.length) {
|
|
104
|
+
let p = path.shift()
|
|
105
|
+
if (p === undefined) break
|
|
106
|
+
if (p.match(/^:/)) {
|
|
107
|
+
// params sharing a trie node keep the first registered name: a later
|
|
108
|
+
// registration with a different name lands on the same node
|
|
109
|
+
if (!r.param) r.param = { routes: {}, paramName: p.slice(1) }
|
|
110
|
+
else if (r.param.paramName !== p.slice(1))
|
|
111
|
+
this.warn(
|
|
112
|
+
`${walked}/${p} collides with ${walked}/:${r.param.paramName} — param routes share one trie node regardless of name`
|
|
113
|
+
)
|
|
114
|
+
r = r.param
|
|
115
|
+
} else {
|
|
116
|
+
if (!r.children) r.children = newChildren()
|
|
117
|
+
if (!(p in r.children)) r.children[p] = { routes: {} }
|
|
118
|
+
r = r.children[p]!
|
|
128
119
|
}
|
|
120
|
+
walked += `/${p}`
|
|
121
|
+
}
|
|
122
|
+
if (r.routes[route.method])
|
|
123
|
+
this.warn(`route ${route.method.toUpperCase()} ${route.path} redefined — previous registration overwritten`)
|
|
124
|
+
r.routes[route.method] = route
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Unregister a route. Identity-checked: the node's entry is only deleted when
|
|
128
|
+
* it still holds this exact route object, so removing a route that was since
|
|
129
|
+
* redefined leaves the newer registration in place.
|
|
130
|
+
*/
|
|
131
|
+
remove(route: Route): boolean {
|
|
132
|
+
let path = route.path.replace(/^\/+|\/+$/g, '').split('/')
|
|
133
|
+
if (path[0] === '') path.shift()
|
|
134
|
+
let r: RouteNode | undefined = this.routes
|
|
135
|
+
for (const p of path) {
|
|
136
|
+
if (p === '') break
|
|
137
|
+
r = p.match(/^:/) ? r.param : r.children?.[p]
|
|
138
|
+
if (!r) return false
|
|
129
139
|
}
|
|
140
|
+
if (r.routes[route.method] !== route) return false
|
|
141
|
+
delete r.routes[route.method]
|
|
142
|
+
for (const [key, cached] of this.cachedRoutes) if (cached === route) this.cachedRoutes.delete(key)
|
|
143
|
+
return true
|
|
130
144
|
}
|
|
131
145
|
find(method: Method, path: string): Route {
|
|
132
146
|
path = normalizePath(path)
|
|
@@ -138,8 +152,17 @@ export class GalbeRouter {
|
|
|
138
152
|
if (!r || !Object.keys(r.routes).length) {
|
|
139
153
|
if (this.cacheEnabled) this.cacheSet(`[${method}]${path}`, null)
|
|
140
154
|
throw new NotFoundError()
|
|
141
|
-
}
|
|
142
|
-
|
|
155
|
+
}
|
|
156
|
+
// RFC 9110 §9.3.3: HEAD is GET without a body. An explicit head route wins;
|
|
157
|
+
// otherwise fall back to the get route (the server strips the response body).
|
|
158
|
+
const route = (r.routes[method] ?? (method === 'head' ? r.routes.get : undefined)) as Route | undefined
|
|
159
|
+
if (!route) {
|
|
160
|
+
// RFC 9110 §15.5.6: a 405 must carry an Allow header listing the methods
|
|
161
|
+
// the resource supports; HEAD is implicitly allowed whenever GET is.
|
|
162
|
+
const allow = Object.keys(r.routes).map(m => m.toUpperCase())
|
|
163
|
+
if ('get' in r.routes && !('head' in r.routes)) allow.push('HEAD')
|
|
164
|
+
throw new MethodNotAllowedError(undefined, { allow: allow.join(', ') })
|
|
165
|
+
}
|
|
143
166
|
if (this.cacheEnabled) this.cacheSet(`[${method}]${path}`, route)
|
|
144
167
|
return route
|
|
145
168
|
}
|
package/src/routes.ts
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
|
-
import type { GalbeConfig,
|
|
1
|
+
import type { GalbeConfig, Method, MiddlewareDef, Route } from './types'
|
|
2
2
|
|
|
3
3
|
import { readdir, lstat } from 'fs/promises'
|
|
4
|
-
import { extname } from 'path'
|
|
4
|
+
import { extname, dirname, relative, resolve, sep } from 'path'
|
|
5
5
|
import { parse } from 'acorn'
|
|
6
6
|
import { simple } from 'acorn-walk'
|
|
7
|
-
import { Galbe } from './index'
|
|
8
|
-
import {
|
|
7
|
+
import { Galbe, GalbeGroup } from './index'
|
|
8
|
+
import { joinPath } from './util'
|
|
9
|
+
import { transformSync } from '@swc/wasm'
|
|
9
10
|
import { Glob } from 'bun'
|
|
10
11
|
|
|
11
12
|
export const DEFAULT_ROUTE_PATTERN = 'src/**/*.route.{js,ts}'
|
|
13
|
+
export const DEFAULT_MIDDLEWARE_PATTERN = 'src/**/*.middleware.{js,ts}'
|
|
12
14
|
|
|
13
15
|
const IGNORE_COMMENT_RGX = /^\s*\@galbe-ignore\s*$/
|
|
14
16
|
const HIDE_COMMENT_RGX = /^\s*\@galbe-hide\s*$/
|
|
17
|
+
const ROUTE_PATH_RGX = /^(\/(\*|:?\d+|:?\w+|:?[\w\d.][\w-.]+[\w\d]))*\/?$/
|
|
18
|
+
// literal route segment: the router's segment rules minus ':param' and '*'
|
|
19
|
+
const LITERAL_SEGMENT_RGX = /^(\d+|\w+|[\w\d.][\w-.]+[\w\d])$/
|
|
20
|
+
const ROUTE_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'static'])
|
|
21
|
+
/** directories that never hold app source — neither scanned nor watched */
|
|
22
|
+
export const NEVER_SCANNED_DIRS = ['node_modules', '.git']
|
|
15
23
|
|
|
16
|
-
export type RouteMeta = { head?: string
|
|
24
|
+
export type RouteMeta = { head?: string; ignore?: boolean; hide?: boolean } & Record<
|
|
25
|
+
string,
|
|
26
|
+
boolean | string | string[]
|
|
27
|
+
>
|
|
17
28
|
export type RoutesMeta = {
|
|
18
29
|
header: Record<string, boolean | string | string[]>
|
|
19
30
|
routes: Record<string, Partial<Record<Method | 'static', RouteMeta>>>
|
|
@@ -30,93 +41,14 @@ export type RouteInstanciationCallback = <T extends 'add' | 'error'>(event: {
|
|
|
30
41
|
export type RouteFileMeta = {
|
|
31
42
|
file: string
|
|
32
43
|
} & RoutesMeta
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
_metaTmp?: RoutesMeta
|
|
39
|
-
_filepath?: string
|
|
40
|
-
_meta: Array<RouteFileMeta> = []
|
|
41
|
-
_staticTargets: Array<{ path: string; target: string }> = []
|
|
42
|
-
constructor(g: Galbe, cb?: RouteInstanciationCallback) {
|
|
43
|
-
this.#g = g
|
|
44
|
-
this._cb = cb
|
|
45
|
-
this.#plugins = g.plugins
|
|
46
|
-
}
|
|
47
|
-
get server() {
|
|
48
|
-
return this.#g.server
|
|
49
|
-
}
|
|
50
|
-
get router() {
|
|
51
|
-
return this.#g.router
|
|
52
|
-
}
|
|
53
|
-
get config() {
|
|
54
|
-
return this.#g.config
|
|
55
|
-
}
|
|
56
|
-
get meta() {
|
|
57
|
-
return this._meta
|
|
58
|
-
}
|
|
59
|
-
set meta(meta: Array<RouteFileMeta>) {
|
|
60
|
-
this._meta = meta
|
|
61
|
-
this.#g.meta = meta
|
|
62
|
-
}
|
|
63
|
-
async init() {
|
|
64
|
-
for (const p of this.#plugins) {
|
|
65
|
-
// @ts-ignore
|
|
66
|
-
if (p.init) await p.init(this.#g.config?.plugin?.[p.name] || {}, this)
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
private async handleRoute(method: Method | 'static', ...args: any[]): Promise<Route | undefined> {
|
|
70
|
-
let path = args[0]
|
|
71
|
-
let meta = {
|
|
72
|
-
...this._metaTmp?.routes?.[path]?.[method],
|
|
73
|
-
...(this._metaTmp?.hide ? { hide: true } : {}),
|
|
74
|
-
...(this._metaTmp?.ignore ? { ignore: true } : {})
|
|
75
|
-
}
|
|
76
|
-
if (meta?.ignore) return
|
|
77
|
-
|
|
78
|
-
//@ts-ignore
|
|
79
|
-
const route = this.#g[method](...args) as Route
|
|
80
|
-
|
|
81
|
-
if (this._cb && !meta?.ignore) {
|
|
82
|
-
await this._cb({
|
|
83
|
-
type: 'add',
|
|
84
|
-
route,
|
|
85
|
-
filepath: this._filepath || '',
|
|
86
|
-
meta
|
|
87
|
-
})
|
|
88
|
-
}
|
|
89
|
-
return route
|
|
90
|
-
}
|
|
91
|
-
async get(...args: any[]) {
|
|
92
|
-
return this.handleRoute('get', ...args)
|
|
93
|
-
}
|
|
94
|
-
async post(...args: any[]) {
|
|
95
|
-
return this.handleRoute('post', ...args)
|
|
96
|
-
}
|
|
97
|
-
async put(...args: any[]) {
|
|
98
|
-
return this.handleRoute('put', ...args)
|
|
99
|
-
}
|
|
100
|
-
async patch(...args: any[]) {
|
|
101
|
-
return this.handleRoute('patch', ...args)
|
|
102
|
-
}
|
|
103
|
-
async delete(...args: any[]) {
|
|
104
|
-
return this.handleRoute('delete', ...args)
|
|
105
|
-
}
|
|
106
|
-
async options(...args: any[]) {
|
|
107
|
-
return this.handleRoute('options', ...args)
|
|
108
|
-
}
|
|
109
|
-
async head(...args: any[]) {
|
|
110
|
-
return this.handleRoute('head', ...args)
|
|
111
|
-
}
|
|
112
|
-
async static(...args: any[]) {
|
|
113
|
-
const [path, target] = args
|
|
114
|
-
if (typeof path === 'string' && typeof target === 'string') {
|
|
115
|
-
this._staticTargets.push({ path, target })
|
|
116
|
-
}
|
|
117
|
-
return this.handleRoute('static', ...args)
|
|
118
|
-
}
|
|
44
|
+
export type MiddlewareFileMeta = {
|
|
45
|
+
file: string
|
|
46
|
+
/** effective middleware pattern, e.g. `/api/*` or `*` */
|
|
47
|
+
scope: string
|
|
48
|
+
header: Record<string, boolean | string | string[]>
|
|
119
49
|
}
|
|
50
|
+
export type RouteFileRegistration = { file: string; prefix: string }
|
|
51
|
+
export type MiddlewareFileRegistration = { file: string; scope: string }
|
|
120
52
|
|
|
121
53
|
const parseComment = (comment: string): Record<string, string | string[]> => {
|
|
122
54
|
// Find the first JSDoc-tag line (a line whose first non-whitespace/star
|
|
@@ -136,8 +68,7 @@ const parseComment = (comment: string): Record<string, string | string[]> => {
|
|
|
136
68
|
const val = n[2] ?? true
|
|
137
69
|
return {
|
|
138
70
|
...acc,
|
|
139
|
-
[tag]:
|
|
140
|
-
tag in acc ? [...(typeof acc[tag] === 'string' ? [acc[tag]] : acc[tag]), val] : val
|
|
71
|
+
[tag]: tag in acc ? [...(typeof acc[tag] === 'string' ? [acc[tag]] : acc[tag]), val] : val,
|
|
141
72
|
}
|
|
142
73
|
},
|
|
143
74
|
{} as Record<string, any>
|
|
@@ -166,11 +97,12 @@ export const metaAnalysis = async (filePath: string): Promise<RoutesMeta> => {
|
|
|
166
97
|
content = transformSync(content, {
|
|
167
98
|
jsc: {
|
|
168
99
|
parser: {
|
|
169
|
-
syntax: 'typescript'
|
|
100
|
+
syntax: 'typescript',
|
|
170
101
|
},
|
|
171
102
|
preserveAllComments: true,
|
|
172
|
-
|
|
173
|
-
|
|
103
|
+
// @swc/wasm's JscTarget typing lags @swc/core's; 'esnext' is supported at runtime
|
|
104
|
+
target: 'esnext' as any,
|
|
105
|
+
},
|
|
174
106
|
}).code
|
|
175
107
|
}
|
|
176
108
|
|
|
@@ -186,13 +118,11 @@ export const metaAnalysis = async (filePath: string): Promise<RoutesMeta> => {
|
|
|
186
118
|
if (!comments?.[locEnd.line]) comments[locEnd.line] = []
|
|
187
119
|
if (IGNORE_COMMENT_RGX.test(text)) {
|
|
188
120
|
ignoredLines.add(locEnd.line + 1)
|
|
189
|
-
}
|
|
190
|
-
else if (HIDE_COMMENT_RGX.test(text)) {
|
|
121
|
+
} else if (HIDE_COMMENT_RGX.test(text)) {
|
|
191
122
|
hideLines.add(locEnd.line + 1)
|
|
192
|
-
}
|
|
193
|
-
else comments[locEnd.line]![locEnd.column] = text
|
|
123
|
+
} else comments[locEnd.line]![locEnd.column] = text
|
|
194
124
|
}
|
|
195
|
-
}
|
|
125
|
+
},
|
|
196
126
|
})
|
|
197
127
|
simple(ast, {
|
|
198
128
|
ExportDefaultDeclaration(node) {
|
|
@@ -217,59 +147,213 @@ export const metaAnalysis = async (filePath: string): Promise<RoutesMeta> => {
|
|
|
217
147
|
CallExpression(node) {
|
|
218
148
|
// @ts-ignore
|
|
219
149
|
if (node?.callee?.object?.name === galbeIdentifier) {
|
|
220
|
-
// @ts-ignore
|
|
221
|
-
let path = node.arguments[0].value
|
|
222
|
-
if (!path?.startsWith("/")) path = `/${path}`
|
|
223
150
|
// @ts-ignore
|
|
224
151
|
const method = node.callee.property.name as Method
|
|
152
|
+
// 'group'/'middleware' calls are not routes; skip non-literal paths
|
|
153
|
+
// @ts-ignore
|
|
154
|
+
let path = node.arguments?.[0]?.value
|
|
155
|
+
if (!ROUTE_METHODS.has(method) || typeof path !== 'string') return
|
|
156
|
+
if (!path.startsWith('/')) path = `/${path}`
|
|
225
157
|
const line = node.loc?.start.line || -1
|
|
226
158
|
const col = node.loc?.start.column || -1
|
|
227
159
|
const com = comments?.[line]?.[col - 1] ?? ''
|
|
228
|
-
const routeRefs = ignoredLines.has(line)
|
|
160
|
+
const routeRefs = ignoredLines.has(line)
|
|
161
|
+
? { ignore: true }
|
|
162
|
+
: { ...parseComment(com), ...(hide || hideLines.has(line) ? { hide: true } : {}) }
|
|
229
163
|
if (!(path in meta.routes)) meta.routes[path] = {}
|
|
230
164
|
if (!(method in meta.routes[path]!)) meta.routes[path]![method] = routeRefs as RouteMeta
|
|
231
165
|
}
|
|
232
|
-
}
|
|
166
|
+
},
|
|
233
167
|
})
|
|
234
|
-
}
|
|
168
|
+
},
|
|
235
169
|
})
|
|
236
170
|
return meta
|
|
237
171
|
}
|
|
238
172
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
) => {
|
|
243
|
-
const
|
|
244
|
-
|
|
173
|
+
// static base of a glob pattern: the segments before the first one containing a
|
|
174
|
+
// glob metachar. Directory prefixes are derived relative to this anchor. A
|
|
175
|
+
// pattern with no metachar names a file: its own directory is the base.
|
|
176
|
+
export const globBase = (pattern: string): string => {
|
|
177
|
+
const segments = pattern.split('/')
|
|
178
|
+
const idx = segments.findIndex(s => /[*?[{]/.test(s))
|
|
179
|
+
return (idx === -1 ? segments.slice(0, -1) : segments.slice(0, idx)).join('/')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const dirPrefixOf = (file: string, base: string): string => {
|
|
183
|
+
const rel = relative(base, dirname(file))
|
|
184
|
+
if (!rel || rel === '.') return ''
|
|
185
|
+
const segments = rel.split(sep)
|
|
186
|
+
for (const s of segments) {
|
|
187
|
+
if (!LITERAL_SEGMENT_RGX.test(s))
|
|
188
|
+
throw new Error(
|
|
189
|
+
`invalid directory name '${s}' for ${file} — directory names must be valid literal route segments`
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
return `/${segments.join('/')}`
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// '@prefix /v2' declares the file's route prefix, overriding the dir-derived
|
|
196
|
+
// one entirely; '@prefix /' opts a file out of dirPrefix
|
|
197
|
+
const headerPrefixOf = (meta: RoutesMeta, file: string): string | undefined => {
|
|
198
|
+
let p = meta.header?.prefix
|
|
199
|
+
if (p === undefined) return undefined
|
|
200
|
+
if (Array.isArray(p)) {
|
|
201
|
+
console.warn(`duplicate @prefix in ${file} — using the first one`)
|
|
202
|
+
p = p[0]!
|
|
203
|
+
}
|
|
204
|
+
if (typeof p !== 'string' || !ROUTE_PATH_RGX.test(p) || p.includes('*'))
|
|
205
|
+
throw new Error(`invalid @prefix ${String(p)} in ${file} — must be a valid route path`)
|
|
206
|
+
return p.replace(/\/+$/, '')
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// rewrite meta keys to the final path relative to basePath: group/@prefix
|
|
210
|
+
// included, basePath excluded — the shape every consumer looks up by
|
|
211
|
+
const prefixMeta = (meta: RoutesMeta, prefix: string): RoutesMeta =>
|
|
212
|
+
!prefix
|
|
213
|
+
? meta
|
|
214
|
+
: { ...meta, routes: Object.fromEntries(Object.entries(meta.routes).map(([p, m]) => [joinPath(prefix, p), m])) }
|
|
215
|
+
|
|
216
|
+
// a directory scan returns entries in the filesystem's order, which differs from
|
|
217
|
+
// machine to machine. Route registration order decides the emitted spec's `paths`
|
|
218
|
+
// order, the printed route table and which param name a shared trie node keeps,
|
|
219
|
+
// so discovery has to be sorted rather than trusted.
|
|
220
|
+
const byDepthThenPath = (a: { file: string }, b: { file: string }) =>
|
|
221
|
+
a.file.split('/').length - b.file.split('/').length || a.file.localeCompare(b.file)
|
|
222
|
+
|
|
223
|
+
const collectFiles = async (pattern: string): Promise<Array<{ file: string; base: string }>> => {
|
|
245
224
|
const root = process.cwd()
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
225
|
+
const base = resolve(root, globBase(pattern))
|
|
226
|
+
// these hold other people's files, never the app's: an app-wide pattern like
|
|
227
|
+
// '**/*.route.ts' must not import (and prefix routes by) whatever a dependency
|
|
228
|
+
// happens to ship. A pattern naming one of them explicitly opts back in.
|
|
229
|
+
const skipped = NEVER_SCANNED_DIRS.filter(d => !pattern.split('/').includes(d))
|
|
230
|
+
const isSkipped = (path: string) =>
|
|
231
|
+
relative(root, path)
|
|
232
|
+
.split(sep)
|
|
233
|
+
.some(s => skipped.includes(s))
|
|
234
|
+
const out: Array<{ file: string; base: string }> = []
|
|
235
|
+
for await (const path of new Glob(pattern).scan({ cwd: root, absolute: true, onlyFiles: false, dot: true })) {
|
|
236
|
+
if (isSkipped(path)) continue
|
|
237
|
+
if ((await lstat(path)).isDirectory()) {
|
|
238
|
+
// a pattern naming a directory registers its direct children, unprefixed
|
|
239
|
+
for (const f of await readdir(path)) out.push({ file: `${path}/${f}`, base: path })
|
|
240
|
+
} else out.push({ file: path, base })
|
|
241
|
+
}
|
|
242
|
+
return out.sort(byDepthThenPath)
|
|
243
|
+
}
|
|
249
244
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
245
|
+
/**
|
|
246
|
+
* Automatic Route Analyzer: discovers middleware files (registered first,
|
|
247
|
+
* shallowest directory wins the outer position), then imports route files,
|
|
248
|
+
* handing each a registrar bound to its prefix (directory-derived or
|
|
249
|
+
* `@prefix`). Route-to-file correlation relies on `galbe.onRouteAdded` and
|
|
250
|
+
* assumes registration is synchronous within a file's default export.
|
|
251
|
+
*/
|
|
252
|
+
export const defineRoutes = async (
|
|
253
|
+
options: Pick<GalbeConfig, 'routes' | 'middleware'>,
|
|
254
|
+
galbe: Galbe,
|
|
255
|
+
cb?: RouteInstanciationCallback
|
|
256
|
+
): Promise<{ routeFiles: RouteFileRegistration[]; middlewareFiles: MiddlewareFileRegistration[] }> => {
|
|
257
|
+
const result = { routeFiles: [] as RouteFileRegistration[], middlewareFiles: [] as MiddlewareFileRegistration[] }
|
|
258
|
+
const routesConf = options?.routes
|
|
259
|
+
if (routesConf === false) return result
|
|
260
|
+
const conf =
|
|
261
|
+
typeof routesConf === 'object' && !Array.isArray(routesConf)
|
|
262
|
+
? routesConf
|
|
263
|
+
: { pattern: routesConf === undefined || routesConf === true ? undefined : routesConf }
|
|
264
|
+
const dirPrefix = conf.dirPrefix !== false
|
|
265
|
+
const patterns = conf.pattern === undefined ? [DEFAULT_ROUTE_PATTERN] : [conf.pattern].flat()
|
|
266
|
+
|
|
267
|
+
const relPath = (path: string) =>
|
|
268
|
+
galbe.router.prefix && path.startsWith(galbe.router.prefix) ? path.slice(galbe.router.prefix.length) || '/' : path
|
|
269
|
+
|
|
270
|
+
// middleware files first: outer scopes wrap route files' in-file registrations
|
|
271
|
+
const mwConf = options?.middleware
|
|
272
|
+
if (mwConf !== false) {
|
|
273
|
+
const mwPatterns = mwConf === undefined || mwConf === true ? [DEFAULT_MIDDLEWARE_PATTERN] : [mwConf].flat()
|
|
274
|
+
const files = (await Promise.all(mwPatterns.map(collectFiles))).flat()
|
|
275
|
+
// each pattern came back sorted already: this re-sorts across patterns
|
|
276
|
+
files.sort(byDepthThenPath)
|
|
277
|
+
for (const { file, base } of files) {
|
|
278
|
+
const dirScope = dirPrefixOf(file, base)
|
|
279
|
+
try {
|
|
280
|
+
const metadata = await metaAnalysis(file)
|
|
281
|
+
if (metadata?.ignore) continue
|
|
282
|
+
const imported = await import(file)
|
|
283
|
+
const exported = imported?.default
|
|
284
|
+
const scopeExport = imported.scope
|
|
285
|
+
if (scopeExport !== undefined && typeof scopeExport !== 'string')
|
|
286
|
+
throw new Error(`invalid scope export in ${file} — must be a middleware pattern string`)
|
|
287
|
+
const fileScope = (pattern?: string) =>
|
|
288
|
+
pattern !== undefined ? joinPath(dirScope, pattern) : dirScope ? `${dirScope}/*` : '*'
|
|
289
|
+
const before = galbe.middlewares.length
|
|
290
|
+
if (typeof exported === 'function') {
|
|
291
|
+
// registration function: a scoped registrar, like a route file gets
|
|
292
|
+
if (scopeExport !== undefined)
|
|
293
|
+
throw new Error(`unsupported scope export in ${file} — pass a pattern to g.middleware() instead`)
|
|
294
|
+
exported(new GalbeGroup(galbe, dirScope))
|
|
295
|
+
} else if (exported && !Array.isArray(exported) && typeof exported === 'object') {
|
|
296
|
+
galbe.middleware(fileScope(scopeExport), exported as MiddlewareDef)
|
|
297
|
+
} else {
|
|
298
|
+
throw new Error(
|
|
299
|
+
'Middleware file must default-export a middleware definition (see `middleware()`) or a registration function'
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
for (const entry of galbe.middlewares.slice(before)) {
|
|
303
|
+
// '/*' and '*' are the same pattern; record the canonical global form
|
|
304
|
+
const scope = entry.pattern === '/*' ? '*' : entry.pattern
|
|
305
|
+
galbe.metaMiddleware.push({ file, scope, header: metadata.header })
|
|
306
|
+
result.middlewareFiles.push({ file, scope })
|
|
267
307
|
}
|
|
308
|
+
} catch (err: any) {
|
|
309
|
+
if (cb) await cb({ type: 'error', error: err, filepath: file, route: undefined, meta: undefined })
|
|
268
310
|
}
|
|
269
311
|
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (const pattern of patterns) {
|
|
315
|
+
for (const { file, base } of await collectFiles(pattern)) {
|
|
316
|
+
let metadata: RoutesMeta
|
|
317
|
+
try {
|
|
318
|
+
metadata = await metaAnalysis(file)
|
|
319
|
+
} catch (err: any) {
|
|
320
|
+
if (cb) await cb({ type: 'error', error: err, filepath: file, route: undefined, meta: undefined })
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
if (metadata?.ignore) continue
|
|
324
|
+
// invalid dir segments and invalid @prefix are boot errors, not per-file ones
|
|
325
|
+
const prefix = headerPrefixOf(metadata, file) ?? (dirPrefix ? dirPrefixOf(file, base) : '')
|
|
326
|
+
const meta = prefixMeta(metadata, prefix)
|
|
327
|
+
galbe.meta = [...(galbe.meta ?? []), { file, ...meta }]
|
|
328
|
+
const added: Route[] = []
|
|
329
|
+
const unsub = galbe.onRouteAdded(({ route }) => added.push(route))
|
|
330
|
+
try {
|
|
331
|
+
const imported = await import(file)
|
|
332
|
+
if (!imported?.default) throw new Error('No default export function')
|
|
333
|
+
if (typeof imported.default !== 'function') throw new Error('Default export must be a function')
|
|
334
|
+
imported.default(prefix ? new GalbeGroup(galbe, prefix) : galbe)
|
|
335
|
+
result.routeFiles.push({ file, prefix })
|
|
336
|
+
} catch (err: any) {
|
|
337
|
+
if (cb) await cb({ type: 'error', error: err, filepath: file, route: undefined, meta: undefined })
|
|
338
|
+
} finally {
|
|
339
|
+
unsub()
|
|
340
|
+
}
|
|
341
|
+
for (const route of added) {
|
|
342
|
+
const root = route.static?.root
|
|
343
|
+
const key = root ? (root[0] === '/' ? root : `/${root}`) : relPath(route.path)
|
|
344
|
+
const routeMeta: RouteMeta = {
|
|
345
|
+
...meta.routes?.[key]?.[root ? 'static' : route.method],
|
|
346
|
+
...(meta.hide ? { hide: true } : {}),
|
|
347
|
+
...(meta.ignore ? { ignore: true } : {}),
|
|
348
|
+
}
|
|
349
|
+
// '@galbe-ignore'd routes must not be served: unregister them
|
|
350
|
+
if (routeMeta.ignore) {
|
|
351
|
+
galbe.router.remove(route)
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
354
|
+
if (cb) await cb({ type: 'add', route, filepath: file, meta: routeMeta })
|
|
355
|
+
}
|
|
273
356
|
}
|
|
274
357
|
}
|
|
358
|
+
return result
|
|
275
359
|
}
|