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/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { RouteFileMeta } from './routes'
1
+ import type { MiddlewareFileMeta, RouteFileMeta } from './routes'
2
2
  import type {
3
3
  GalbeConfig,
4
4
  Method,
@@ -7,7 +7,6 @@ import type {
7
7
  Handler,
8
8
  Endpoint,
9
9
  Context,
10
- ContextSet,
11
10
  ErrorHandler,
12
11
  GalbePlugin,
13
12
  STBody,
@@ -15,17 +14,25 @@ import type {
15
14
  STParams,
16
15
  STHeaders,
17
16
  STQuery,
17
+ STCookies,
18
18
  StaticEndpoint,
19
19
  Route,
20
20
  StaticEndpointOptions,
21
- STBodyValue,
21
+ GalbeMiddleware,
22
+ MaybeArray,
23
+ MiddlewareDef,
24
+ MiddlewareSchema,
25
+ PreParseHook,
26
+ ResponseHook,
22
27
  } from './types'
23
28
 
24
- import { readdirSync, statSync } from 'fs'
29
+ import { existsSync, readdirSync, statSync } from 'fs'
25
30
  import { resolve as resolvePath } from 'path'
26
31
  import server from './server'
32
+ import { joinPath, matchMiddleware, mergeMiddlewareSchema, parseMiddlewarePattern, walkRoutes } from './util'
27
33
  import { GalbeRouter } from './router'
28
- import { SchemaType, type STObject, type Static } from './schema'
34
+ import { SchemaType } from './schema'
35
+ import { compileRoute } from './validator.compile'
29
36
 
30
37
  const overloadDiscriminer = <
31
38
  M extends Method,
@@ -35,19 +42,20 @@ const overloadDiscriminer = <
35
42
  Q extends STQuery,
36
43
  B extends STBody,
37
44
  R extends STResponse,
45
+ C extends STCookies,
38
46
  >(
39
47
  galbe: Galbe,
40
48
  method: M,
41
49
  path: Path,
42
50
  arg2:
43
- | RequestSchema<M, Path, H, P, Q, B, R>
44
- | Hook<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>[]
45
- | 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>>,
46
54
  arg3?:
47
- | Hook<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>[]
48
- | Handler<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>,
49
- arg4?: Handler<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>
50
- ): 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> => {
51
59
  const defaultSchema = {}
52
60
  if (typeof arg2 === 'function') {
53
61
  return galbeMethod(galbe, method, path, defaultSchema, undefined, arg2)
@@ -61,6 +69,99 @@ const overloadDiscriminer = <
61
69
  }
62
70
  throw new Error('Undefined route signature')
63
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
+
64
165
  const galbeMethod = <
65
166
  M extends Method,
66
167
  Path extends string,
@@ -69,34 +170,24 @@ const galbeMethod = <
69
170
  Q extends STQuery,
70
171
  B extends STBody,
71
172
  R extends STResponse,
173
+ C extends STCookies,
72
174
  >(
73
175
  _galbe: Galbe,
74
176
  method: M,
75
177
  path: Path,
76
- schema: RequestSchema<M, Path, H, P, Q, B, R> | undefined,
77
- hooks: Hook<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>[] | undefined,
78
- handler: Handler<M, Path, RequestSchema<M, Path, H, P, Q, B, R>>
79
- ): 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> => {
80
182
  schema = schema ?? {}
81
183
  hooks = hooks || []
82
- //@ts-ignore
83
- const context: Context<M, Path, typeof schema> = {
84
- headers: {} as Static<STObject<Exclude<(typeof schema)['headers'], undefined>>>,
85
- params: {} as any,
86
- query: {} as Static<STObject<Exclude<(typeof schema)['query'], undefined>>>,
87
- body: ['get', 'options', 'head'].includes(method) ? null : ({} as unknown as STBodyValue),
88
- request: {} as Request,
89
- cookies: {} as Record<string, string>,
90
- state: {},
91
- set: {} as ContextSet,
92
- }
93
184
  return {
94
185
  method,
95
186
  path,
96
187
  schema,
97
- context,
98
188
  hooks,
99
189
  handler,
190
+ composed: composeHooks(hooks, handler),
100
191
  }
101
192
  }
102
193
 
@@ -108,6 +199,27 @@ export type { STResponseContent, STResponseBodyKey, STResponseEntry } from './ty
108
199
 
109
200
  export const config = (config: GalbeConfig) => config
110
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
+
111
223
  /**
112
224
  * #### Galbe Server
113
225
  * Instanciate a Galbe web server
@@ -124,28 +236,133 @@ export const config = (config: GalbeConfig) => config
124
236
  export class Galbe {
125
237
  config: GalbeConfig
126
238
  meta?: Array<RouteFileMeta> = []
239
+ /** Header metadata of middleware files discovered by the Automatic Route Analyzer. */
240
+ metaMiddleware: Array<MiddlewareFileMeta> = []
127
241
  router: GalbeRouter
128
242
  startCb: (() => void)[] = []
129
243
  stopCb: (() => void)[] = []
130
244
  errorCb: ErrorHandler[] = []
245
+ routeAddedCb: ((event: { route: Route }) => void)[] = []
131
246
  listening: boolean = false
132
247
  server?: Awaited<ReturnType<typeof server>>
133
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 }> = []
134
252
  constructor(config?: GalbeConfig) {
135
253
  this.config = config ?? {}
136
254
  this.router = new GalbeRouter({
137
255
  prefix: this.config?.basePath || '',
138
256
  cacheEnabled: this.config?.router?.cacheEnabled,
139
257
  cacheLimit: this.config?.router?.cacheLimit,
258
+ warn: this.config?.router?.warn,
140
259
  })
141
260
  }
142
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)
143
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 })
144
268
  return route
145
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
+ }
146
286
  async use(plugin: GalbePlugin) {
147
287
  this.plugins.push(plugin)
148
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
+ }
149
366
  async init() {
150
367
  for (const p of this.plugins) {
151
368
  if (p.init) await p.init(this.config?.plugin?.[p.name] || {}, this)
@@ -180,6 +397,20 @@ export class Galbe {
180
397
  onError(handler: ErrorHandler) {
181
398
  this.errorCb.push(handler)
182
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
+ }
183
414
  get: Endpoint<'get'> = <
184
415
  Path extends string,
185
416
  P extends Partial<STParams<Path>>,
@@ -187,17 +418,17 @@ export class Galbe {
187
418
  Q extends STQuery,
188
419
  B extends STBody,
189
420
  R extends STResponse,
421
+ C extends STCookies,
190
422
  >(
191
423
  path: Path,
192
424
  arg2:
193
- | RequestSchema<'get', Path, H, P, Q, B, R>
194
- | Hook<'get', Path, RequestSchema<'get', Path, H, P, Q, B, R>>[]
195
- | 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>>,
196
428
  arg3?:
197
- | Hook<'get', Path, RequestSchema<'get', Path, H, P, Q, B, R>>[]
198
- | Handler<'get', Path, RequestSchema<'get', Path, H, P, Q, B, R>>,
199
- arg4?: Handler<'get', Path, RequestSchema<'get', Path, H, P, Q, B, R>>
200
- //@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>>
201
432
  ) => this.add(overloadDiscriminer(this, 'get', path, arg2, arg3, arg4))
202
433
  post: Endpoint<'post'> = <
203
434
  Path extends string,
@@ -206,16 +437,17 @@ export class Galbe {
206
437
  Q extends STQuery,
207
438
  B extends STBody,
208
439
  R extends STResponse,
440
+ C extends STCookies,
209
441
  >(
210
442
  path: Path,
211
443
  arg2:
212
- | RequestSchema<'post', Path, H, P, Q, B, R>
213
- | Hook<'post', Path, RequestSchema<'post', Path, H, P, Q, B, R>>[]
214
- | 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>>,
215
447
  arg3?:
216
- | Hook<'post', Path, RequestSchema<'post', Path, H, P, Q, B, R>>[]
217
- | Handler<'post', Path, RequestSchema<'post', Path, H, P, Q, B, R>>,
218
- 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>>
219
451
  ) => this.add(overloadDiscriminer(this, 'post', path, arg2, arg3, arg4))
220
452
  put: Endpoint<'put'> = <
221
453
  Path extends string,
@@ -224,16 +456,17 @@ export class Galbe {
224
456
  Q extends STQuery,
225
457
  B extends STBody,
226
458
  R extends STResponse,
459
+ C extends STCookies,
227
460
  >(
228
461
  path: Path,
229
462
  arg2:
230
- | RequestSchema<'put', Path, H, P, Q, B, R>
231
- | Hook<'put', Path, RequestSchema<'put', Path, H, P, Q, B, R>>[]
232
- | 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>>,
233
466
  arg3?:
234
- | Hook<'put', Path, RequestSchema<'put', Path, H, P, Q, B, R>>[]
235
- | Handler<'put', Path, RequestSchema<'put', Path, H, P, Q, B, R>>,
236
- 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>>
237
470
  ) => this.add(overloadDiscriminer(this, 'put', path, arg2, arg3, arg4))
238
471
  patch: Endpoint<'patch'> = <
239
472
  Path extends string,
@@ -242,16 +475,17 @@ export class Galbe {
242
475
  Q extends STQuery,
243
476
  B extends STBody,
244
477
  R extends STResponse,
478
+ C extends STCookies,
245
479
  >(
246
480
  path: Path,
247
481
  arg2:
248
- | RequestSchema<'patch', Path, H, P, Q, B, R>
249
- | Hook<'patch', Path, RequestSchema<'patch', Path, H, P, Q, B, R>>[]
250
- | 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>>,
251
485
  arg3?:
252
- | Hook<'patch', Path, RequestSchema<'patch', Path, H, P, Q, B, R>>[]
253
- | Handler<'patch', Path, RequestSchema<'patch', Path, H, P, Q, B, R>>,
254
- 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>>
255
489
  ) => this.add(overloadDiscriminer(this, 'patch', path, arg2, arg3, arg4))
256
490
  delete: Endpoint<'delete'> = <
257
491
  Path extends string,
@@ -260,16 +494,17 @@ export class Galbe {
260
494
  Q extends STQuery,
261
495
  B extends STBody,
262
496
  R extends STResponse,
497
+ C extends STCookies,
263
498
  >(
264
499
  path: Path,
265
500
  arg2:
266
- | RequestSchema<'delete', Path, H, P, Q, B, R>
267
- | Hook<'delete', Path, RequestSchema<'delete', Path, H, P, Q, B, R>>[]
268
- | 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>>,
269
504
  arg3?:
270
- | Hook<'delete', Path, RequestSchema<'delete', Path, H, P, Q, B, R>>[]
271
- | Handler<'delete', Path, RequestSchema<'delete', Path, H, P, Q, B, R>>,
272
- 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>>
273
508
  ) => this.add(overloadDiscriminer(this, 'delete', path, arg2, arg3, arg4))
274
509
  options: Endpoint<'options'> = <
275
510
  Path extends string,
@@ -278,16 +513,17 @@ export class Galbe {
278
513
  Q extends STQuery,
279
514
  B extends STBody,
280
515
  R extends STResponse,
516
+ C extends STCookies,
281
517
  >(
282
518
  path: Path,
283
519
  arg2:
284
- | RequestSchema<'options', Path, H, P, Q, B, R>
285
- | Hook<'options', Path, RequestSchema<'options', Path, H, P, Q, B, R>>[]
286
- | 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>>,
287
523
  arg3?:
288
- | Hook<'options', Path, RequestSchema<'options', Path, H, P, Q, B, R>>[]
289
- | Handler<'options', Path, RequestSchema<'options', Path, H, P, Q, B, R>>,
290
- 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>>
291
527
  ) => this.add(overloadDiscriminer(this, 'options', path, arg2, arg3, arg4))
292
528
  head: Endpoint<'head'> = <
293
529
  Path extends string,
@@ -296,20 +532,23 @@ export class Galbe {
296
532
  Q extends STQuery,
297
533
  B extends STBody,
298
534
  R extends STResponse,
535
+ C extends STCookies,
299
536
  >(
300
537
  path: Path,
301
538
  arg2:
302
- | RequestSchema<'head', Path, H, P, Q, B, R>
303
- | Hook<'head', Path, RequestSchema<'head', Path, H, P, Q, B, R>>[]
304
- | 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>>,
305
542
  arg3?:
306
- | Hook<'head', Path, RequestSchema<'head', Path, H, P, Q, B, R>>[]
307
- | Handler<'head', Path, RequestSchema<'head', Path, H, P, Q, B, R>>,
308
- 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>>
309
546
  ) => this.add(overloadDiscriminer(this, 'head', path, arg2, arg3, arg4))
310
547
  static: StaticEndpoint = (path: string, target: string, options?: StaticEndpointOptions) => {
311
548
  let { resolve } = options ?? {}
312
549
  const rootPath = path
550
+ const rootTarget = target
551
+ this.staticTargets.push({ path, target })
313
552
 
314
553
  const walkStatic = (path: string, target: string) => {
315
554
  path = path?.[0] === '/' ? path : `/${path}`
@@ -320,6 +559,8 @@ export class Galbe {
320
559
  t = resolvePath(import.meta.dir, `static-${Bun.env.GALBE_BUILD}/${target}`)
321
560
  }
322
561
 
562
+ if (!existsSync(t)) throw new Error(`galbe.static('${rootPath}', '${rootTarget}'): target does not exist: ${t}`)
563
+
323
564
  if (!statSync(t).isDirectory()) {
324
565
  let ut: string | null | undefined | void = t
325
566
  if (path.endsWith('.html')) path = path.slice(0, -5)
@@ -349,4 +590,69 @@ export class Galbe {
349
590
  }
350
591
  }
351
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
+
352
658
  export * from './types'