galbe 0.15.5 → 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/index.ts CHANGED
@@ -1,5 +1,4 @@
1
- import type { Server } from 'bun'
2
- import type { RouteFileMeta } from './routes'
1
+ import type { MiddlewareFileMeta, RouteFileMeta } from './routes'
3
2
  import type {
4
3
  GalbeConfig,
5
4
  Method,
@@ -8,7 +7,6 @@ import type {
8
7
  Handler,
9
8
  Endpoint,
10
9
  Context,
11
- ContextSet,
12
10
  ErrorHandler,
13
11
  GalbePlugin,
14
12
  STBody,
@@ -16,17 +14,25 @@ import type {
16
14
  STParams,
17
15
  STHeaders,
18
16
  STQuery,
17
+ STCookies,
19
18
  StaticEndpoint,
20
19
  Route,
21
20
  StaticEndpointOptions,
22
- STBodyValue,
21
+ GalbeMiddleware,
22
+ MaybeArray,
23
+ MiddlewareDef,
24
+ MiddlewareSchema,
25
+ PreParseHook,
26
+ ResponseHook,
23
27
  } from './types'
24
28
 
25
- import { readdirSync, statSync } from 'fs'
29
+ import { existsSync, readdirSync, statSync } from 'fs'
26
30
  import { resolve as resolvePath } from 'path'
27
31
  import server from './server'
32
+ import { joinPath, matchMiddleware, mergeMiddlewareSchema, parseMiddlewarePattern, walkRoutes } from './util'
28
33
  import { GalbeRouter } from './router'
29
- import { SchemaType, type STObject, type Static } from './schema'
34
+ import { SchemaType } from './schema'
35
+ import { compileRoute } from './validator.compile'
30
36
 
31
37
  const overloadDiscriminer = <
32
38
  M extends Method,
@@ -36,19 +42,20 @@ const overloadDiscriminer = <
36
42
  Q extends STQuery,
37
43
  B extends STBody,
38
44
  R extends STResponse,
45
+ C extends STCookies,
39
46
  >(
40
47
  galbe: Galbe,
41
48
  method: M,
42
49
  path: Path,
43
50
  arg2:
44
- | RequestSchema<M, Path, H, P, Q, B, R>
45
- | Hook<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>[]
46
- | Handler<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>,
51
+ | RequestSchema<M, Path, H, P, Q, B, R, C>
52
+ | Hook<M, Path, RequestSchema<M, Path, H, P, Q, B, R, C>>[]
53
+ | Handler<M, Path, RequestSchema<M, Path, H, P, Q, B, R, C>>,
47
54
  arg3?:
48
- | Hook<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>[]
49
- | Handler<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>,
50
- arg4?: Handler<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>
51
- ): Route<M, Path, P, H, Q, B, R> => {
55
+ | Hook<M, Path, RequestSchema<M, Path, H, P, Q, B, R, C>>[]
56
+ | Handler<M, Path, RequestSchema<M, Path, H, P, Q, B, R, C>>,
57
+ arg4?: Handler<M, Path, RequestSchema<M, Path, H, P, Q, B, R, C>>
58
+ ): Route<M, Path, P, H, Q, B, R, C> => {
52
59
  const defaultSchema = {}
53
60
  if (typeof arg2 === 'function') {
54
61
  return galbeMethod(galbe, method, path, defaultSchema, undefined, arg2)
@@ -62,6 +69,99 @@ const overloadDiscriminer = <
62
69
  }
63
70
  throw new Error('Undefined route signature')
64
71
  }
72
+ type HookChainState = { handlerCalled: boolean; response: any }
73
+
74
+ // Compose the hook/handler chain once, at registration: a route's chain is
75
+ // immutable after `add()`, so rebuilding it per request is pure allocation
76
+ // churn. Per-request semantics are unchanged: fresh `handlerCalled`/`nextCalled`
77
+ // state per invocation, `Hook already called - ignored` on a double-next(), a
78
+ // truthy hook return short-circuits and becomes the response, and a hook that
79
+ // neither called next() nor returned a value triggers an implicit next().
80
+ // The composed function resolves to the handler's response (or the
81
+ // short-circuit value), starting from '' exactly like the historical chain.
82
+ const composeHooks = <M extends Method, Path extends string, S extends RequestSchema>(
83
+ hooks: Hook<M, Path, S>[],
84
+ handler: Handler<M, Path, S>
85
+ ): ((context: Context<M, Path, S>) => Promise<any>) => {
86
+ // terminal entry: run the handler and settle the response status
87
+ let downstream: (context: Context<M, Path, S>, state: HookChainState) => Promise<any> = async (context, state) => {
88
+ state.handlerCalled = true
89
+ state.response = await handler(context)
90
+ context.set.status = state.response instanceof Response ? state.response.status : context.set.status || 200
91
+ }
92
+ for (let i = hooks.length - 1; i >= 0; i--) {
93
+ const hook = hooks[i]!
94
+ const next = downstream
95
+ downstream = async (context, state) => {
96
+ let nextCalled = false
97
+ const nextFn = async () => {
98
+ if (nextCalled) console.error('Hook already called - ignored')
99
+ else {
100
+ nextCalled = true
101
+ return await next(context, state)
102
+ }
103
+ }
104
+ const r = await hook(context, nextFn)
105
+ if (r) return r
106
+ if (!nextCalled && !state.handlerCalled) return await nextFn()
107
+ }
108
+ }
109
+ const chain = downstream
110
+ return async context => {
111
+ const state: HookChainState = { handlerCalled: false, response: '' }
112
+ const r = await chain(context, state)
113
+ if (r) state.response = r
114
+ return state.response
115
+ }
116
+ }
117
+
118
+ // The pre-parse slot is a sequence, not an onion: no next(), each hook either
119
+ // returns a Response — which short-circuits the request before a byte of body
120
+ // is read — or falls through to the next one. Composed once at registration,
121
+ // alongside the hook chain; undefined when the slot is empty so the request
122
+ // path can skip it outright.
123
+ const composePreParse = (hooks: PreParseHook[]): Route['composedPre'] => {
124
+ if (!hooks.length) return undefined
125
+ return async context => {
126
+ for (const hook of hooks) {
127
+ const r = await hook(context)
128
+ if (r) return r
129
+ }
130
+ }
131
+ }
132
+
133
+ // The post-parse slot is a transform, not an onion: by the time a Response
134
+ // exists the hook chain has unwound, so a hook either returns a replacement
135
+ // Response or keeps the one it was handed. Composed once at registration, and
136
+ // undefined when the slot is empty so the request path can skip it outright.
137
+ const composePost = (hooks: ResponseHook[]): Route['composedPost'] => {
138
+ if (!hooks.length) return undefined
139
+ return async (response, context, error) => {
140
+ for (const hook of hooks) {
141
+ const r = await hook(response, context, error)
142
+ if (r) response = r
143
+ }
144
+ return response
145
+ }
146
+ }
147
+
148
+ // group prefixes may contain ':params', middleware patterns may not: a param
149
+ // segment matches like '*'
150
+ const patternFromPath = (path: string) => path.replace(/:[^/]+/g, '*')
151
+
152
+ // a bare hook (or hook array) is sugar for { hooks }
153
+ const toMiddlewareDef = (arg?: MaybeArray<Hook> | MiddlewareDef): Omit<GalbeMiddleware, 'pattern' | 'segments'> => {
154
+ const def: MiddlewareDef = typeof arg === 'function' || Array.isArray(arg) ? { hooks: arg } : (arg ?? {})
155
+ return {
156
+ beforeParse: def.beforeParse ? [def.beforeParse].flat() : [],
157
+ hooks: def.hooks ? [def.hooks].flat() : [],
158
+ afterHandle: def.afterHandle ? [def.afterHandle].flat() : [],
159
+ schema: def.schema,
160
+ security: def.security,
161
+ securitySchemes: def.securitySchemes,
162
+ }
163
+ }
164
+
65
165
  const galbeMethod = <
66
166
  M extends Method,
67
167
  Path extends string,
@@ -70,34 +170,24 @@ const galbeMethod = <
70
170
  Q extends STQuery,
71
171
  B extends STBody,
72
172
  R extends STResponse,
173
+ C extends STCookies,
73
174
  >(
74
175
  _galbe: Galbe,
75
176
  method: M,
76
177
  path: Path,
77
- schema: RequestSchema<M, Path, H, P, Q, B, R> | undefined,
78
- hooks: Hook<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>[] | undefined,
79
- handler: Handler<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>
80
- ): Route<M, Path, P, H, Q, B, R> => {
178
+ schema: RequestSchema<M, Path, H, P, Q, B, R, C> | undefined,
179
+ hooks: Hook<M, Path, RequestSchema<M, Path, H, P, Q, B, R, C>>[] | undefined,
180
+ handler: Handler<M, Path, RequestSchema<M, Path, H, P, Q, B, R, C>>
181
+ ): Route<M, Path, P, H, Q, B, R, C> => {
81
182
  schema = schema ?? {}
82
183
  hooks = hooks || []
83
- //@ts-ignore
84
- const context: Context<M, Path, typeof schema> = {
85
- headers: {} as Static<STObject<Exclude<(typeof schema)['headers'], undefined>>>,
86
- params: {} as any,
87
- query: {} as Static<STObject<Exclude<(typeof schema)['query'], undefined>>>,
88
- body: ['get', 'options', 'head'].includes(method) ? null : ({} as unknown as STBodyValue),
89
- request: {} as Request,
90
- cookies: {} as Record<string, string>,
91
- state: {},
92
- set: {} as ContextSet,
93
- }
94
184
  return {
95
185
  method,
96
186
  path,
97
187
  schema,
98
- context,
99
188
  hooks,
100
189
  handler,
190
+ composed: composeHooks(hooks, handler),
101
191
  }
102
192
  }
103
193
 
@@ -109,6 +199,27 @@ export type { STResponseContent, STResponseBodyKey, STResponseEntry } from './ty
109
199
 
110
200
  export const config = (config: GalbeConfig) => config
111
201
 
202
+ /**
203
+ * #### Middleware
204
+ * Define a middleware as a value: hooks, the request contract they impose, and
205
+ * the security scheme they enforce, in one exportable thing. Identity at
206
+ * runtime — it exists so the `schema` fragment types the def's own handlers,
207
+ * which a bare object literal cannot do.
208
+ *
209
+ * ---
210
+ * @example
211
+ * ```typescript
212
+ * // src/api/tenant.middleware.ts — the directory is the scope
213
+ * export default middleware({
214
+ * schema: { headers: { 'x-tenant-id': $T.string() } },
215
+ * hooks: ctx => {
216
+ * ctx.state.tenant = ctx.headers['x-tenant-id'] // string
217
+ * }
218
+ * })
219
+ * ```
220
+ */
221
+ export const middleware = <F extends MiddlewareSchema>(def: MiddlewareDef<F>): MiddlewareDef<F> => def
222
+
112
223
  /**
113
224
  * #### Galbe Server
114
225
  * Instanciate a Galbe web server
@@ -125,28 +236,133 @@ export const config = (config: GalbeConfig) => config
125
236
  export class Galbe {
126
237
  config: GalbeConfig
127
238
  meta?: Array<RouteFileMeta> = []
239
+ /** Header metadata of middleware files discovered by the Automatic Route Analyzer. */
240
+ metaMiddleware: Array<MiddlewareFileMeta> = []
128
241
  router: GalbeRouter
129
242
  startCb: (() => void)[] = []
130
243
  stopCb: (() => void)[] = []
131
244
  errorCb: ErrorHandler[] = []
245
+ routeAddedCb: ((event: { route: Route }) => void)[] = []
132
246
  listening: boolean = false
133
- server?: Server<any>
247
+ server?: Awaited<ReturnType<typeof server>>
134
248
  plugins: GalbePlugin[] = []
249
+ middlewares: GalbeMiddleware[] = []
250
+ /** User-supplied `static(path, target)` pairs, recorded so `galbe build` can copy the assets next to the bundle. */
251
+ staticTargets: Array<{ path: string; target: string }> = []
135
252
  constructor(config?: GalbeConfig) {
136
253
  this.config = config ?? {}
137
254
  this.router = new GalbeRouter({
138
255
  prefix: this.config?.basePath || '',
139
256
  cacheEnabled: this.config?.router?.cacheEnabled,
140
257
  cacheLimit: this.config?.router?.cacheLimit,
258
+ warn: this.config?.router?.warn,
141
259
  })
142
260
  }
143
261
  private add(route: any) {
262
+ // schemas are immutable once the route is added: compile their validators now
263
+ if (route?.schema) compileRoute(route.schema)
144
264
  this.router.add(route)
265
+ const segments = this.routeSegments(route)
266
+ if (this.middlewares.some(m => matchMiddleware(m.segments, segments))) this.composeMiddleware(route)
267
+ for (const cb of this.routeAddedCb) cb({ route })
145
268
  return route
146
269
  }
270
+ // route.path carries the basePath prefix once registered: strip it, patterns
271
+ // are written relative to basePath like route paths
272
+ private routeSegments(route: Route): string[] {
273
+ const path = this.router.prefix ? route.path.slice(this.router.prefix.length) : route.path
274
+ return path.split('/').filter(s => s !== '')
275
+ }
276
+ private composeMiddleware(route: Route) {
277
+ const segments = this.routeSegments(route)
278
+ const matched = this.middlewares.filter(m => matchMiddleware(m.segments, segments))
279
+ // merged fragments bring in schemas that were never compiled; compile()
280
+ // caches per schema object, so only the new ones are built
281
+ if (mergeMiddlewareSchema(route, matched)) compileRoute(route.schema)
282
+ route.composed = composeHooks([...matched.flatMap(m => m.hooks), ...route.hooks], route.handler)
283
+ route.composedPre = composePreParse(matched.flatMap(m => m.beforeParse))
284
+ route.composedPost = composePost(matched.flatMap(m => m.afterHandle))
285
+ }
147
286
  async use(plugin: GalbePlugin) {
148
287
  this.plugins.push(plugin)
149
288
  }
289
+ /**
290
+ * #### Middleware
291
+ * Register hooks — or a {@link MiddlewareDef} — that apply to every route
292
+ * whose path matches the given pattern, ahead of the route's own hooks.
293
+ * Pattern segments are literals or `*` (any single segment); a trailing `*`
294
+ * matches the whole subtree, including the prefix itself. Patterns match
295
+ * registered route paths (not request URLs) and are resolved at registration
296
+ * time: matched hooks are composed into the route chain and the def's schema
297
+ * fragment is merged into the route schema, adding no per-request cost.
298
+ *
299
+ * ---
300
+ * @example
301
+ * ```typescript
302
+ * galbe.middleware(logger) // every route
303
+ * galbe.middleware('/api/*', authHook) // the /api subtree
304
+ * galbe.middleware('/api/*', middleware({ schema: { headers: { authorization: $T.string() } }, hooks: authHook }))
305
+ * ```
306
+ */
307
+ middleware(hooks: MaybeArray<Hook>): void
308
+ middleware<F extends MiddlewareSchema>(def: MiddlewareDef<F>): void
309
+ middleware(pattern: string, hooks: MaybeArray<Hook>): void
310
+ middleware<F extends MiddlewareSchema>(pattern: string, def: MiddlewareDef<F>): void
311
+ middleware(arg1: string | MaybeArray<Hook> | MiddlewareDef, arg2?: MaybeArray<Hook> | MiddlewareDef): void {
312
+ const pattern = typeof arg1 === 'string' ? arg1 : '*'
313
+ const def = toMiddlewareDef(typeof arg1 === 'string' ? arg2 : arg1)
314
+ if (
315
+ !def.hooks.length &&
316
+ !def.beforeParse.length &&
317
+ !def.afterHandle.length &&
318
+ !def.schema &&
319
+ !def.security &&
320
+ !def.securitySchemes
321
+ )
322
+ return
323
+ const entry = { pattern, segments: parseMiddlewarePattern(pattern), ...def }
324
+ this.middlewares.push(entry)
325
+ // routes registered before this call: recompose the ones the new entry matches
326
+ walkRoutes(this.router.routes, route => {
327
+ if (matchMiddleware(entry.segments, this.routeSegments(route))) this.composeMiddleware(route)
328
+ })
329
+ }
330
+ /**
331
+ * #### Route group
332
+ * Register routes under a shared path prefix. Optional hooks — or a
333
+ * middleware def — apply to the whole `<prefix>/*` subtree: they are prefix
334
+ * middleware, so they also cover matching routes registered outside the
335
+ * group. A def's schema fragment types the routes registered through the
336
+ * group registrar, on top of merging into their schemas.
337
+ *
338
+ * ---
339
+ * @example
340
+ * ```typescript
341
+ * galbe.group('/v1', [authHook], g => {
342
+ * g.get('/users', listUsers) // GET /v1/users
343
+ * g.group('/admin', a => { ... }) // /v1/admin/...
344
+ * })
345
+ *
346
+ * galbe.group('/v1', middleware({ schema: { headers: { authorization: $T.string() } } }), g => {
347
+ * g.get('/users', ctx => ctx.headers.authorization) // string
348
+ * })
349
+ * ```
350
+ */
351
+ group<P extends string>(prefix: P, cb: (group: GalbeGroup<P>) => void): GalbeGroup<P>
352
+ group<P extends string>(prefix: P, hooks: Hook[], cb: (group: GalbeGroup<P>) => void): GalbeGroup<P>
353
+ group<P extends string, F extends MiddlewareSchema>(
354
+ prefix: P,
355
+ def: MiddlewareDef<F>,
356
+ cb: (group: GalbeGroup<P, F>) => void
357
+ ): GalbeGroup<P, F>
358
+ group(prefix: string, arg2: any, arg3?: any): any {
359
+ const cb = typeof arg2 === 'function' ? arg2 : arg3
360
+ const scoped = Array.isArray(arg2) ? arg2.length > 0 : !!arg2 && typeof arg2 === 'object'
361
+ if (scoped) this.middleware(patternFromPath(joinPath(prefix, '/*')), arg2)
362
+ const group = new GalbeGroup(this, prefix)
363
+ cb?.(group)
364
+ return group
365
+ }
150
366
  async init() {
151
367
  for (const p of this.plugins) {
152
368
  if (p.init) await p.init(this.config?.plugin?.[p.name] || {}, this)
@@ -181,6 +397,20 @@ export class Galbe {
181
397
  onError(handler: ErrorHandler) {
182
398
  this.errorCb.push(handler)
183
399
  }
400
+ /**
401
+ * #### Route registration event
402
+ * Subscribe to route registrations: the callback fires synchronously for
403
+ * every route added to the router, right after its hook chain is composed,
404
+ * with the final (prefixed) path. Listener errors propagate to the
405
+ * registration site. Returns an unsubscribe function.
406
+ */
407
+ onRouteAdded(callback: (event: { route: Route }) => void): () => void {
408
+ this.routeAddedCb.push(callback)
409
+ return () => {
410
+ const idx = this.routeAddedCb.indexOf(callback)
411
+ if (idx >= 0) this.routeAddedCb.splice(idx, 1)
412
+ }
413
+ }
184
414
  get: Endpoint<'get'> = <
185
415
  Path extends string,
186
416
  P extends Partial<STParams<Path>>,
@@ -188,17 +418,17 @@ export class Galbe {
188
418
  Q extends STQuery,
189
419
  B extends STBody,
190
420
  R extends STResponse,
421
+ C extends STCookies,
191
422
  >(
192
423
  path: Path,
193
424
  arg2:
194
- | RequestSchema<'get', Path, H, P, Q, B, R>
195
- | Hook<'get', Path, RequestSchema<'get', Path, H, P, Q, B, R>>[]
196
- | Handler<'get', Path, RequestSchema<'get', Path, H, P, Q, B, R>>,
425
+ | RequestSchema<'get', Path, H, P, Q, B, R, C>
426
+ | Hook<'get', Path, RequestSchema<'get', Path, H, P, Q, B, R, C>>[]
427
+ | Handler<'get', Path, RequestSchema<'get', Path, H, P, Q, B, R, C>>,
197
428
  arg3?:
198
- | Hook<'get', Path, RequestSchema<'get', Path, H, P, Q, B, R>>[]
199
- | Handler<'get', Path, RequestSchema<'get', Path, H, P, Q, B, R>>,
200
- arg4?: Handler<'get', Path, RequestSchema<'get', Path, H, P, Q, B, R>>
201
- //@ts-ignore
429
+ | Hook<'get', Path, RequestSchema<'get', Path, H, P, Q, B, R, C>>[]
430
+ | Handler<'get', Path, RequestSchema<'get', Path, H, P, Q, B, R, C>>,
431
+ arg4?: Handler<'get', Path, RequestSchema<'get', Path, H, P, Q, B, R, C>>
202
432
  ) => this.add(overloadDiscriminer(this, 'get', path, arg2, arg3, arg4))
203
433
  post: Endpoint<'post'> = <
204
434
  Path extends string,
@@ -207,16 +437,17 @@ export class Galbe {
207
437
  Q extends STQuery,
208
438
  B extends STBody,
209
439
  R extends STResponse,
440
+ C extends STCookies,
210
441
  >(
211
442
  path: Path,
212
443
  arg2:
213
- | RequestSchema<'post', Path, H, P, Q, B, R>
214
- | Hook<'post', Path, RequestSchema<'post', Path, H, P, Q, B, R>>[]
215
- | Handler<'post', Path, RequestSchema<'post', Path, H, P, Q, B, R>>,
444
+ | RequestSchema<'post', Path, H, P, Q, B, R, C>
445
+ | Hook<'post', Path, RequestSchema<'post', Path, H, P, Q, B, R, C>>[]
446
+ | Handler<'post', Path, RequestSchema<'post', Path, H, P, Q, B, R, C>>,
216
447
  arg3?:
217
- | Hook<'post', Path, RequestSchema<'post', Path, H, P, Q, B, R>>[]
218
- | Handler<'post', Path, RequestSchema<'post', Path, H, P, Q, B, R>>,
219
- arg4?: Handler<'post', Path, RequestSchema<'post', Path, H, P, Q, B, R>>
448
+ | Hook<'post', Path, RequestSchema<'post', Path, H, P, Q, B, R, C>>[]
449
+ | Handler<'post', Path, RequestSchema<'post', Path, H, P, Q, B, R, C>>,
450
+ arg4?: Handler<'post', Path, RequestSchema<'post', Path, H, P, Q, B, R, C>>
220
451
  ) => this.add(overloadDiscriminer(this, 'post', path, arg2, arg3, arg4))
221
452
  put: Endpoint<'put'> = <
222
453
  Path extends string,
@@ -225,16 +456,17 @@ export class Galbe {
225
456
  Q extends STQuery,
226
457
  B extends STBody,
227
458
  R extends STResponse,
459
+ C extends STCookies,
228
460
  >(
229
461
  path: Path,
230
462
  arg2:
231
- | RequestSchema<'put', Path, H, P, Q, B, R>
232
- | Hook<'put', Path, RequestSchema<'put', Path, H, P, Q, B, R>>[]
233
- | Handler<'put', Path, RequestSchema<'put', Path, H, P, Q, B, R>>,
463
+ | RequestSchema<'put', Path, H, P, Q, B, R, C>
464
+ | Hook<'put', Path, RequestSchema<'put', Path, H, P, Q, B, R, C>>[]
465
+ | Handler<'put', Path, RequestSchema<'put', Path, H, P, Q, B, R, C>>,
234
466
  arg3?:
235
- | Hook<'put', Path, RequestSchema<'put', Path, H, P, Q, B, R>>[]
236
- | Handler<'put', Path, RequestSchema<'put', Path, H, P, Q, B, R>>,
237
- arg4?: Handler<'put', Path, RequestSchema<'put', Path, H, P, Q, B, R>>
467
+ | Hook<'put', Path, RequestSchema<'put', Path, H, P, Q, B, R, C>>[]
468
+ | Handler<'put', Path, RequestSchema<'put', Path, H, P, Q, B, R, C>>,
469
+ arg4?: Handler<'put', Path, RequestSchema<'put', Path, H, P, Q, B, R, C>>
238
470
  ) => this.add(overloadDiscriminer(this, 'put', path, arg2, arg3, arg4))
239
471
  patch: Endpoint<'patch'> = <
240
472
  Path extends string,
@@ -243,16 +475,17 @@ export class Galbe {
243
475
  Q extends STQuery,
244
476
  B extends STBody,
245
477
  R extends STResponse,
478
+ C extends STCookies,
246
479
  >(
247
480
  path: Path,
248
481
  arg2:
249
- | RequestSchema<'patch', Path, H, P, Q, B, R>
250
- | Hook<'patch', Path, RequestSchema<'patch', Path, H, P, Q, B, R>>[]
251
- | Handler<'patch', Path, RequestSchema<'patch', Path, H, P, Q, B, R>>,
482
+ | RequestSchema<'patch', Path, H, P, Q, B, R, C>
483
+ | Hook<'patch', Path, RequestSchema<'patch', Path, H, P, Q, B, R, C>>[]
484
+ | Handler<'patch', Path, RequestSchema<'patch', Path, H, P, Q, B, R, C>>,
252
485
  arg3?:
253
- | Hook<'patch', Path, RequestSchema<'patch', Path, H, P, Q, B, R>>[]
254
- | Handler<'patch', Path, RequestSchema<'patch', Path, H, P, Q, B, R>>,
255
- arg4?: Handler<'patch', Path, RequestSchema<'patch', Path, H, P, Q, B, R>>
486
+ | Hook<'patch', Path, RequestSchema<'patch', Path, H, P, Q, B, R, C>>[]
487
+ | Handler<'patch', Path, RequestSchema<'patch', Path, H, P, Q, B, R, C>>,
488
+ arg4?: Handler<'patch', Path, RequestSchema<'patch', Path, H, P, Q, B, R, C>>
256
489
  ) => this.add(overloadDiscriminer(this, 'patch', path, arg2, arg3, arg4))
257
490
  delete: Endpoint<'delete'> = <
258
491
  Path extends string,
@@ -261,16 +494,17 @@ export class Galbe {
261
494
  Q extends STQuery,
262
495
  B extends STBody,
263
496
  R extends STResponse,
497
+ C extends STCookies,
264
498
  >(
265
499
  path: Path,
266
500
  arg2:
267
- | RequestSchema<'delete', Path, H, P, Q, B, R>
268
- | Hook<'delete', Path, RequestSchema<'delete', Path, H, P, Q, B, R>>[]
269
- | Handler<'delete', Path, RequestSchema<'delete', Path, H, P, Q, B, R>>,
501
+ | RequestSchema<'delete', Path, H, P, Q, B, R, C>
502
+ | Hook<'delete', Path, RequestSchema<'delete', Path, H, P, Q, B, R, C>>[]
503
+ | Handler<'delete', Path, RequestSchema<'delete', Path, H, P, Q, B, R, C>>,
270
504
  arg3?:
271
- | Hook<'delete', Path, RequestSchema<'delete', Path, H, P, Q, B, R>>[]
272
- | Handler<'delete', Path, RequestSchema<'delete', Path, H, P, Q, B, R>>,
273
- arg4?: Handler<'delete', Path, RequestSchema<'delete', Path, H, P, Q, B, R>>
505
+ | Hook<'delete', Path, RequestSchema<'delete', Path, H, P, Q, B, R, C>>[]
506
+ | Handler<'delete', Path, RequestSchema<'delete', Path, H, P, Q, B, R, C>>,
507
+ arg4?: Handler<'delete', Path, RequestSchema<'delete', Path, H, P, Q, B, R, C>>
274
508
  ) => this.add(overloadDiscriminer(this, 'delete', path, arg2, arg3, arg4))
275
509
  options: Endpoint<'options'> = <
276
510
  Path extends string,
@@ -279,16 +513,17 @@ export class Galbe {
279
513
  Q extends STQuery,
280
514
  B extends STBody,
281
515
  R extends STResponse,
516
+ C extends STCookies,
282
517
  >(
283
518
  path: Path,
284
519
  arg2:
285
- | RequestSchema<'options', Path, H, P, Q, B, R>
286
- | Hook<'options', Path, RequestSchema<'options', Path, H, P, Q, B, R>>[]
287
- | Handler<'options', Path, RequestSchema<'options', Path, H, P, Q, B, R>>,
520
+ | RequestSchema<'options', Path, H, P, Q, B, R, C>
521
+ | Hook<'options', Path, RequestSchema<'options', Path, H, P, Q, B, R, C>>[]
522
+ | Handler<'options', Path, RequestSchema<'options', Path, H, P, Q, B, R, C>>,
288
523
  arg3?:
289
- | Hook<'options', Path, RequestSchema<'options', Path, H, P, Q, B, R>>[]
290
- | Handler<'options', Path, RequestSchema<'options', Path, H, P, Q, B, R>>,
291
- arg4?: Handler<'options', Path, RequestSchema<'options', Path, H, P, Q, B, R>>
524
+ | Hook<'options', Path, RequestSchema<'options', Path, H, P, Q, B, R, C>>[]
525
+ | Handler<'options', Path, RequestSchema<'options', Path, H, P, Q, B, R, C>>,
526
+ arg4?: Handler<'options', Path, RequestSchema<'options', Path, H, P, Q, B, R, C>>
292
527
  ) => this.add(overloadDiscriminer(this, 'options', path, arg2, arg3, arg4))
293
528
  head: Endpoint<'head'> = <
294
529
  Path extends string,
@@ -297,20 +532,23 @@ export class Galbe {
297
532
  Q extends STQuery,
298
533
  B extends STBody,
299
534
  R extends STResponse,
535
+ C extends STCookies,
300
536
  >(
301
537
  path: Path,
302
538
  arg2:
303
- | RequestSchema<'head', Path, H, P, Q, B, R>
304
- | Hook<'head', Path, RequestSchema<'head', Path, H, P, Q, B, R>>[]
305
- | Handler<'head', Path, RequestSchema<'head', Path, H, P, Q, B, R>>,
539
+ | RequestSchema<'head', Path, H, P, Q, B, R, C>
540
+ | Hook<'head', Path, RequestSchema<'head', Path, H, P, Q, B, R, C>>[]
541
+ | Handler<'head', Path, RequestSchema<'head', Path, H, P, Q, B, R, C>>,
306
542
  arg3?:
307
- | Hook<'head', Path, RequestSchema<'head', Path, H, P, Q, B, R>>[]
308
- | Handler<'head', Path, RequestSchema<'head', Path, H, P, Q, B, R>>,
309
- arg4?: Handler<'head', Path, RequestSchema<'head', Path, H, P, Q, B, R>>
543
+ | Hook<'head', Path, RequestSchema<'head', Path, H, P, Q, B, R, C>>[]
544
+ | Handler<'head', Path, RequestSchema<'head', Path, H, P, Q, B, R, C>>,
545
+ arg4?: Handler<'head', Path, RequestSchema<'head', Path, H, P, Q, B, R, C>>
310
546
  ) => this.add(overloadDiscriminer(this, 'head', path, arg2, arg3, arg4))
311
547
  static: StaticEndpoint = (path: string, target: string, options?: StaticEndpointOptions) => {
312
548
  let { resolve } = options ?? {}
313
549
  const rootPath = path
550
+ const rootTarget = target
551
+ this.staticTargets.push({ path, target })
314
552
 
315
553
  const walkStatic = (path: string, target: string) => {
316
554
  path = path?.[0] === '/' ? path : `/${path}`
@@ -321,6 +559,8 @@ export class Galbe {
321
559
  t = resolvePath(import.meta.dir, `static-${Bun.env.GALBE_BUILD}/${target}`)
322
560
  }
323
561
 
562
+ if (!existsSync(t)) throw new Error(`galbe.static('${rootPath}', '${rootTarget}'): target does not exist: ${t}`)
563
+
324
564
  if (!statSync(t).isDirectory()) {
325
565
  let ut: string | null | undefined | void = t
326
566
  if (path.endsWith('.html')) path = path.slice(0, -5)
@@ -350,4 +590,69 @@ export class Galbe {
350
590
  }
351
591
  }
352
592
 
593
+ /**
594
+ * #### GalbeGroup
595
+ * Route sub-registrar created by {@link Galbe.group}. Paths are prefixed at
596
+ * registration time: router matching and precedence are unchanged, and the
597
+ * prefixed paths flow as-is into the generated OpenAPI spec. `F` carries the
598
+ * schema fragment of the group's middleware def, so routes registered here are
599
+ * typed with it — route-declared keys win, as they do at runtime.
600
+ */
601
+ export class GalbeGroup<Prefix extends string = string, F extends MiddlewareSchema = {}> {
602
+ #galbe: Galbe
603
+ #prefix: string
604
+ constructor(galbe: Galbe, prefix: string) {
605
+ this.#galbe = galbe
606
+ this.#prefix = joinPath('', prefix).replace(/\/+$/, '')
607
+ }
608
+ #route(method: Method, path: string, args: any[]): any {
609
+ // indexing by a Method union yields a union of Endpoint overload sets, which
610
+ // has no common call signature — the dispatch is checked at the call sites
611
+ return (this.#galbe[method] as (path: string, ...args: any[]) => any)(joinPath(this.#prefix, path), ...args)
612
+ }
613
+ get: Endpoint<'get', Prefix, F> = (path: any, ...args: any[]): any => this.#route('get', path, args)
614
+ post: Endpoint<'post', Prefix, F> = (path: any, ...args: any[]): any => this.#route('post', path, args)
615
+ put: Endpoint<'put', Prefix, F> = (path: any, ...args: any[]): any => this.#route('put', path, args)
616
+ patch: Endpoint<'patch', Prefix, F> = (path: any, ...args: any[]): any => this.#route('patch', path, args)
617
+ delete: Endpoint<'delete', Prefix, F> = (path: any, ...args: any[]): any => this.#route('delete', path, args)
618
+ options: Endpoint<'options', Prefix, F> = (path: any, ...args: any[]): any => this.#route('options', path, args)
619
+ head: Endpoint<'head', Prefix, F> = (path: any, ...args: any[]): any => this.#route('head', path, args)
620
+ static: StaticEndpoint = (path: any, target: any, options?: any): any =>
621
+ this.#galbe.static(joinPath(this.#prefix, path), target, options)
622
+ /**
623
+ * Register middleware scoped to the group: bare hooks or a def cover the
624
+ * group subtree, patterns are relative to the group prefix. The fragment is
625
+ * merged and validated, but only `group(prefix, def, cb)` can type the
626
+ * routes — a mutating call has no value to carry the type on.
627
+ */
628
+ middleware(hooks: MaybeArray<Hook>): void
629
+ middleware<G extends MiddlewareSchema>(def: MiddlewareDef<G>): void
630
+ middleware(pattern: string, hooks: MaybeArray<Hook>): void
631
+ middleware<G extends MiddlewareSchema>(pattern: string, def: MiddlewareDef<G>): void
632
+ middleware(arg1: string | MaybeArray<Hook> | MiddlewareDef, arg2?: MaybeArray<Hook> | MiddlewareDef): void {
633
+ const prefix = patternFromPath(this.#prefix)
634
+ // the overloads discriminate hooks from defs; the implementation forwards the union
635
+ const scope = typeof arg1 === 'string' ? joinPath(prefix, arg1) : joinPath(prefix, '/*')
636
+ this.#galbe.middleware(scope, (typeof arg1 === 'string' ? arg2! : arg1) as MaybeArray<Hook>)
637
+ }
638
+ group<P extends string>(
639
+ prefix: P,
640
+ cb: (group: GalbeGroup<`${Prefix}${P}`, F>) => void
641
+ ): GalbeGroup<`${Prefix}${P}`, F>
642
+ group<P extends string>(
643
+ prefix: P,
644
+ hooks: Hook[],
645
+ cb: (group: GalbeGroup<`${Prefix}${P}`, F>) => void
646
+ ): GalbeGroup<`${Prefix}${P}`, F>
647
+ // nested defs stack: the inner fragment merges over the outer one
648
+ group<P extends string, G extends MiddlewareSchema>(
649
+ prefix: P,
650
+ def: MiddlewareDef<G>,
651
+ cb: (group: GalbeGroup<`${Prefix}${P}`, F & G>) => void
652
+ ): GalbeGroup<`${Prefix}${P}`, F & G>
653
+ group(prefix: string, arg2: any, arg3?: any): any {
654
+ return this.#galbe.group(joinPath(this.#prefix, prefix), arg2, arg3)
655
+ }
656
+ }
657
+
353
658
  export * from './types'