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/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
- throw new NotFoundError()
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
- try {
31
- return walk(path, node.children[nextSegment]!, index + 1)
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
- try {
40
- return walk(path, node.param, index + 1)
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
- try {
49
- return walk(path, node.children['*'], index + 1)
50
- } catch (error) {
51
- if (error instanceof NotFoundError) {
52
- if (node.children['*'].routes && !!Object.keys(node.children['*'].routes).length) {
53
- return node.children['*']
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
- throw new NotFoundError()
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
- constructor(options?: { prefix?: string; cacheEnabled?: boolean; cacheLimit?: number }) {
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
- if (!path.length) {
104
- r.routes[route.method] = route
105
- } else {
106
- while (path.length) {
107
- let p = path.shift()
108
- if (p === undefined) break
109
- if (!path.length) {
110
- if (p.match(/^:/)) {
111
- if (!r.param) r.param = { routes: {} }
112
- r.param.routes[route.method] = route
113
- } else {
114
- if (!r.children) r.children = newChildren()
115
- if (!(p in r.children)) r.children[p] = { routes: {} }
116
- r.children[p]!.routes[route.method] = route
117
- }
118
- } else {
119
- if (p.match(/^:/)) {
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
- } else if (!(method in r.routes)) throw new MethodNotAllowedError()
142
- const route = r.routes[method] as Route
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, GalbePlugin, Method, Route } from './types'
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 { transformSync } from '@swc/core'
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, ignore?: boolean, hide?: boolean } & Record<string, boolean | string | 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
- export class GalbeProxy {
35
- #g: Galbe
36
- #plugins: GalbePlugin[] = []
37
- _cb?: RouteInstanciationCallback
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
- target: 'esnext'
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) ? { ignore: true } : { ...parseComment(com), ...(hide || hideLines.has(line) ? { hide: true } : {}) }
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
- export const defineRoutes = async (
240
- options: Pick<GalbeConfig, 'routes'>,
241
- proxy: GalbeProxy,
242
- ) => {
243
- const routes = options?.routes === undefined || options?.routes === true ? DEFAULT_ROUTE_PATTERN : options?.routes
244
- if (!routes) return
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
- if (typeof routes === 'string') {
247
- for await (const path of new Glob(routes).scan({ cwd: root, absolute: true, onlyFiles: false, dot: true })) {
248
- const isDir = (await lstat(path)).isDirectory()
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
- let files: string[] = []
251
- if (!isDir) files.push(path)
252
- else files = files.concat((await readdir(path)).map(f => `${path}/${f}`))
253
- for (const f of files) {
254
- try {
255
- const metadata = await metaAnalysis(f)
256
- if (metadata?.ignore) continue
257
- proxy._filepath = f
258
- proxy._metaTmp = metadata
259
- proxy.meta = [...proxy.meta, { file: path, ...metadata }]
260
- const imported = await import(f)
261
- if (!imported?.default) throw new Error('No default export function')
262
- if (typeof imported.default !== 'function') throw new Error('Default export must be a function')
263
- const routes = imported.default
264
- routes(proxy)
265
- } catch (err: any) {
266
- if (proxy._cb) await proxy._cb({ type: 'error', error: err, filepath: f, route: undefined, meta: undefined })
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
- } else if (Array.isArray(routes)) {
271
- for (const r of routes) {
272
- await defineRoutes({ routes: r }, proxy)
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
  }