galbe 0.15.4 → 0.15.6

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/bin/util.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { relative } from 'path'
2
- import { watch } from 'chokidar'
2
+ import { watch } from 'fs'
3
3
  import { Galbe, type Route } from '../src'
4
4
  import { logRoute, walkRoutes } from '../src/util'
5
5
  import { GalbeProxy, type RouteMeta, defineRoutes } from '../src/routes'
@@ -47,14 +47,11 @@ export const watchDir = async (
47
47
  }) => any | Promise<any>,
48
48
  options?: { ignore?: RegExp }
49
49
  ) => {
50
- let watcher = watch(path, {
51
- persistent: false,
52
- ignored: options?.ignore,
53
- ignoreInitial: true,
54
- })
55
- watcher.on('all', async (eventType, filename) => {
56
- if (filename.match(WATCH_IGNORE)) return
57
- await callback({ path: filename.toString(), eventType })
50
+ watch(path, { persistent: false, recursive: true }, async (eventType, filename) => {
51
+ const filePath = filename?.toString() ?? null
52
+ if (!filePath || filePath.match(WATCH_IGNORE)) return
53
+ if (options?.ignore && filePath.match(options.ignore)) return
54
+ await callback({ path: filePath, eventType: eventType === 'change' ? 'change' : 'add' })
58
55
  })
59
56
  }
60
57
  export const instanciateRoutes = async (g: Galbe) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "galbe",
3
- "version": "0.15.4",
3
+ "version": "0.15.6",
4
4
  "description": "Fast, lightweight and highly customizable JavaScript web framework based on Bun",
5
5
  "author": "Pierre Caillaud M (https://github.com/pierre-cm)",
6
6
  "type": "module",
@@ -34,7 +34,7 @@
34
34
  "license": "MIT",
35
35
  "scripts": {
36
36
  "test": "bun test",
37
- "typecheck": "tsc --noEmit --emitDeclarationOnly false",
37
+ "typecheck": "tsc --noEmit --emitDeclarationOnly false -p tsconfig.json && tsc --noEmit --emitDeclarationOnly false -p tsconfig.test.json",
38
38
  "release": "bun run scripts/release.ts"
39
39
  },
40
40
  "devDependencies": {
@@ -49,7 +49,6 @@
49
49
  "@swc/wasm": "^1.4.0",
50
50
  "acorn": "^8.11.2",
51
51
  "acorn-walk": "^8.3.0",
52
- "chokidar": "^3.6.0",
53
52
  "commander": "^11.1.0"
54
53
  }
55
54
  }
package/src/cookies.ts CHANGED
@@ -18,15 +18,15 @@ export const parseCookie = (str: string) => {
18
18
  for (const [idx, entry] of entries.entries()) {
19
19
  const [_, key, val] = [...(entry.match(/([^=]+)(?:=(.*))?/) || [])]
20
20
  if (idx === 0) {
21
- cookie.name = key
22
- cookie.value = val
21
+ cookie.name = key ?? ''
22
+ cookie.value = val ?? ''
23
23
  } else {
24
24
  switch (key) {
25
25
  case 'Path':
26
26
  cookie.path = val
27
27
  break
28
28
  case 'Max-Age':
29
- cookie.maxAge = parseInt(val)
29
+ if (val !== undefined) cookie.maxAge = parseInt(val)
30
30
  break
31
31
  case 'HttpOnly':
32
32
  cookie.httpOnly = true
@@ -44,7 +44,7 @@ export const parseCookie = (str: string) => {
44
44
  cookie.domain = val
45
45
  break
46
46
  case 'Expires':
47
- cookie.expires = new Date(val)
47
+ if (val !== undefined) cookie.expires = new Date(val)
48
48
  break
49
49
  }
50
50
  }
@@ -81,7 +81,7 @@ export const readCookies = (cookies?: string | null) => {
81
81
  return Object.fromEntries(
82
82
  cookies.split(';').map(c => {
83
83
  const [name, ...value] = c.split('=')
84
- return [name.trim(), value.join('=').trim()]
84
+ return [(name ?? '').trim(), value.join('=').trim()]
85
85
  })
86
86
  )
87
87
  }
@@ -117,7 +117,7 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
117
117
  if (members.length === 0) {
118
118
  s = {}
119
119
  } else if (members.length === 1) {
120
- s = schemaToOpenapi(members[0]).schema
120
+ s = schemaToOpenapi(members[0]!).schema
121
121
  } else if (allStringLiterals && !useOneOf) {
122
122
  s = { type: 'string', enum: members.map(e => (e as STLiteral).value) }
123
123
  } else if (members.length > 1) {
@@ -132,7 +132,7 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
132
132
  if (allOf.length === 0) {
133
133
  s = {}
134
134
  } else if (allOf.length === 1) {
135
- s = schemaToOpenapi(allOf[0]).schema
135
+ s = schemaToOpenapi(allOf[0]!).schema
136
136
  } else if (allOf.length > 1) {
137
137
  s = {
138
138
  allOf: allOf.map(s => schemaToOpenapi(s).schema),
@@ -209,7 +209,7 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
209
209
  securityExplicitlyEmpty = true
210
210
  } else {
211
211
  const [name, ...scopes] = trimmed.split(/\s+/)
212
- security.push({ [name]: scopes })
212
+ security.push({ [name!]: scopes })
213
213
  if (name === 'bearerAuth' && components.securitySchemes && !components.securitySchemes.bearerAuth) {
214
214
  components.securitySchemes.bearerAuth = { type: 'http', scheme: 'bearer' }
215
215
  }
@@ -296,8 +296,8 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
296
296
  content[key] = { schema: oaSchema }
297
297
  const ex = (bodySchema as any)?.examples
298
298
  const exSingle = (bodySchema as any)?.example
299
- if (ex && Object.keys(ex).length) content[key].examples = ex
300
- if (exSingle !== undefined) content[key].example = exSingle
299
+ if (ex && Object.keys(ex).length) content[key]!.examples = ex
300
+ if (exSingle !== undefined) content[key]!.example = exSingle
301
301
  }
302
302
  response = { description: desc, ...(Object.keys(content).length ? { content } : {}) }
303
303
  } else {
@@ -319,8 +319,8 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
319
319
  const content: Record<string, { schema: typeof schema; example?: any; examples?: Record<string, any> }> = {
320
320
  [mediaType]: { schema: { ...schema } },
321
321
  }
322
- if (explicitExamples && Object.keys(explicitExamples).length) content[mediaType].examples = explicitExamples
323
- if (explicitExample !== undefined) content[mediaType].example = explicitExample
322
+ if (explicitExamples && Object.keys(explicitExamples).length) content[mediaType]!.examples = explicitExamples
323
+ if (explicitExample !== undefined) content[mediaType]!.example = explicitExample
324
324
  response = {
325
325
  description: (v as any).description || HttpStatus[s as keyof typeof HttpStatus] || 'Response',
326
326
  content,
@@ -407,7 +407,7 @@ export const OpenAPISerializer = async (g: Galbe, version = '3.0.3'): Promise<Op
407
407
  }
408
408
  }
409
409
  }
410
- const cap = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
410
+ const cap = (s: string) => (s ? s[0]!.toUpperCase() + s.slice(1) : s)
411
411
  const promoted = new Map<string, string>() // hash -> component name
412
412
  const usedNames = new Set<string>(Object.keys(components.parameters || {}))
413
413
  for (const [hash, { count, param }] of paramHashes) {
package/src/index.ts CHANGED
@@ -1,4 +1,3 @@
1
- import type { Server } from 'bun'
2
1
  import type { RouteFileMeta } from './routes'
3
2
  import type {
4
3
  GalbeConfig,
@@ -130,13 +129,14 @@ export class Galbe {
130
129
  stopCb: (() => void)[] = []
131
130
  errorCb: ErrorHandler[] = []
132
131
  listening: boolean = false
133
- server?: Server<any>
132
+ server?: Awaited<ReturnType<typeof server>>
134
133
  plugins: GalbePlugin[] = []
135
134
  constructor(config?: GalbeConfig) {
136
135
  this.config = config ?? {}
137
136
  this.router = new GalbeRouter({
138
137
  prefix: this.config?.basePath || '',
139
138
  cacheEnabled: this.config?.router?.cacheEnabled,
139
+ cacheLimit: this.config?.router?.cacheLimit,
140
140
  })
141
141
  }
142
142
  private add(route: any) {
package/src/parser.ts CHANGED
@@ -25,6 +25,16 @@ import { isIterator, inferBodyType, type ParseMode } from './util'
25
25
  const textDecoder = new TextDecoder()
26
26
  const textEncoder = new TextEncoder()
27
27
 
28
+ // own-property schema lookup: field names come from the request and would
29
+ // otherwise hit Object.prototype members (constructor, toString, …)
30
+ const getProp = <T extends Record<string, any>>(props: T | undefined, key: string): T[string] | undefined =>
31
+ props && Object.hasOwn(props, key) ? props[key] : undefined
32
+
33
+ // application/x-www-form-urlencoded encodes spaces as `+`, which
34
+ // decodeURIComponent leaves untouched — translate first (a literal plus
35
+ // arrives percent-encoded as %2B)
36
+ const decodeFormComponent = (raw: string) => decodeURIComponent(raw.replace(/\+/g, ' '))
37
+
28
38
  async function* rsToAsyncIterator(readableStream: ReadableStream) {
29
39
  try {
30
40
  for await (const chunk of readableStream) yield chunk
@@ -201,7 +211,12 @@ export const requestBodyParser = async (
201
211
  }
202
212
  }
203
213
  async function* $streamToString(body: ReadableStream) {
204
- for await (const chunk of body) yield textDecoder.decode(chunk)
214
+ // per-call decoder: streaming decode is stateful (multibyte code points can
215
+ // straddle chunks), so the shared module-level decoder must not be used here
216
+ const decoder = new TextDecoder()
217
+ for await (const chunk of body) yield decoder.decode(chunk, { stream: true })
218
+ const tail = decoder.decode()
219
+ if (tail) yield tail
205
220
  }
206
221
  const streamToString = async (body: ReadableStream, schema?: STBodyValue): Promise<any> => {
207
222
  let res = ''
@@ -228,11 +243,11 @@ async function* $streamToUrlForm(
228
243
  bV.set(rest)
229
244
  bV.set(chunk.slice(start, i), rest.length)
230
245
  let [key, val]: [string, any] = [
231
- decodeURIComponent(textDecoder.decode(bK)),
232
- decodeURIComponent(textDecoder.decode(bV)),
246
+ decodeFormComponent(textDecoder.decode(bK)),
247
+ decodeFormComponent(textDecoder.decode(bV)),
233
248
  ]
234
249
  try {
235
- const propSchema = schema?.props?.[key]
250
+ const propSchema = getProp(schema?.props, key)
236
251
  let s = propSchema?.[Kind] === 'array' ? (propSchema as STArray).items : propSchema
237
252
  val = s ? paramParser(val, s) : val
238
253
  } catch (error) {
@@ -260,11 +275,11 @@ async function* $streamToUrlForm(
260
275
  }
261
276
  }
262
277
  let [key, val]: [string, any] = [
263
- decodeURIComponent(textDecoder.decode(bK)),
264
- decodeURIComponent(textDecoder.decode(rest)),
278
+ decodeFormComponent(textDecoder.decode(bK)),
279
+ decodeFormComponent(textDecoder.decode(rest)),
265
280
  ]
266
281
  try {
267
- const propSchema = schema?.props?.[key]
282
+ const propSchema = getProp(schema?.props, key)
268
283
  let s = propSchema?.[Kind] === 'array' ? (propSchema as STArray).items : propSchema
269
284
  val = s ? paramParser(val, s) : val
270
285
  } catch (error) {
@@ -290,20 +305,21 @@ const streamToUrlForm = async (body: ReadableStream<Uint8Array>, schema?: STObje
290
305
  const required = Object.fromEntries(
291
306
  Object.entries(schema?.props || {}).filter(([_, v]: [string, any]) => !v?.[Optional])
292
307
  )
293
- let errors: Record<string, any> = {}
308
+ let errors: Record<string, any> = Object.create(null)
294
309
  for await (const chunk of $streamToUrlForm(body)) entries.push(chunk)
295
- const object: Record<string, any> = {}
310
+ const object: Record<string, any> = Object.create(null)
296
311
  for (let e of entries.filter(([k]) => k)) {
297
312
  if (e[0] in object) {
298
313
  if (Array.isArray(object[e[0]])) object[e[0]].push(e[1])
299
314
  else object[e[0]] = [object[e[0]], e[1]]
300
- } else object[e[0]] = schema?.props?.[e[0]]?.[Kind] === 'array' ? [e[1]] : e[1]
315
+ } else object[e[0]] = getProp(schema?.props, e[0])?.[Kind] === 'array' ? [e[1]] : e[1]
301
316
  }
302
317
  if (schema?.props)
303
318
  for (let [k, v] of Object.entries(object)) {
304
319
  delete required[k]
305
320
  try {
306
- object[k] = schema?.props && k in schema?.props ? paramParser(v, schema?.props[k]) : v
321
+ const propSchema = getProp(schema.props, k)
322
+ object[k] = propSchema ? paramParser(v, propSchema) : v
307
323
  } catch (error) {
308
324
  errors[k] = k in errors ? [...errors[k], error] : error
309
325
  }
@@ -425,14 +441,14 @@ const parseMultipartHeader = (header: string): { name: string; [key: string]: st
425
441
  ...header.matchAll(/\s*([\w-]+)\s*:\s*([^;]*);?/g),
426
442
  ...header.matchAll(/;?\s*(\w+)\s*=\s*\"([^"]*)\";?/g),
427
443
  ].reduce((acc: Record<string, string>, v: string[]) => {
428
- const key = v[1].toLowerCase().replace(/^content-/, '')
444
+ const key = (v[1] ?? '').toLowerCase().replace(/^content-/, '')
429
445
  if (key === 'disposition') {
430
- disposition = v[2]
446
+ disposition = v[2] ?? 'form-data'
431
447
  return acc
432
448
  }
433
- acc[key] = v[2]
449
+ acc[key] = v[2] ?? ''
434
450
  return acc
435
- }, {})
451
+ }, Object.create(null))
436
452
  if (disposition !== 'form-data') return null
437
453
  //@ts-ignore
438
454
  return multipartHeader
@@ -446,17 +462,18 @@ const parseMultipartContent = (
446
462
  let result: any = content
447
463
  if (type === 'text/plain') {
448
464
  const str = textDecoder.decode(content).trim()
449
- let s = schema?.props?.[headers.name]
465
+ let s = getProp(schema?.props, headers.name)
450
466
  return s ? paramParser(str, s?.[Kind] === 'array' ? s?.items : s) : str
451
467
  } else if (type === 'application/json') {
452
- if (!schema?.props || !(headers.name in schema?.props)) {
468
+ if (!schema?.props || !Object.hasOwn(schema.props, headers.name)) {
453
469
  try {
454
470
  result = JSON.parse(textDecoder.decode(content).trim())
455
471
  } catch (err: any) {
456
472
  throw new RequestError({ status: 400, payload: { body: { [headers.name]: err?.message || 'Parsing error' } } })
457
473
  }
458
474
  } else if (schema?.props) {
459
- if (schema?.props[headers.name][Kind] === 'object') {
475
+ const prop = schema.props[headers.name]!
476
+ if (prop[Kind] === 'object') {
460
477
  try {
461
478
  result = JSON.parse(textDecoder.decode(content).trim())
462
479
  } catch (err: any) {
@@ -466,67 +483,76 @@ const parseMultipartContent = (
466
483
  })
467
484
  }
468
485
  try {
469
- validate(result, schema?.props[headers.name])
486
+ validate(result, prop)
487
+ } catch (err) {
488
+ throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
489
+ }
490
+ } else if (prop[Kind] === 'byteArray') {
491
+ try {
492
+ return validate(content, prop)
470
493
  } catch (err) {
471
494
  throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
472
495
  }
473
- } else if (schema?.props[headers.name][Kind] === 'byteArray') {
474
- return content
475
- } else if (schema?.props[headers.name][Kind] === 'string') {
496
+ } else if (prop[Kind] === 'string') {
476
497
  result = textDecoder.decode(content).trim()
477
498
  } else {
478
499
  throw new RequestError({
479
500
  status: 400,
480
- payload: { body: { [headers.name]: `Expected ${schema?.props[headers.name][Kind]} found json` } },
501
+ payload: { body: { [headers.name]: `Expected ${prop[Kind]} found json` } },
481
502
  })
482
503
  }
483
504
  }
484
- } else if (schema?.props?.[headers.name]) {
485
- try {
486
- let s = schema?.props[headers.name]
487
- validate(result, s?.[Kind] === 'array' ? s?.items : s)
488
- } catch (err) {
489
- throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
505
+ } else {
506
+ const s = getProp(schema?.props, headers.name)
507
+ if (s) {
508
+ try {
509
+ validate(result, s[Kind] === 'array' ? s.items : s)
510
+ } catch (err) {
511
+ throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
512
+ }
490
513
  }
491
514
  }
492
515
  return result
493
516
  }
494
517
  const streamToMultipartForm = async (data: ReadableStream<Uint8Array>, boundary: string, schema?: STMultipartForm) => {
495
- const res: Record<string, MultipartFormData> = {}
496
- const errors: Record<string, any> = {}
518
+ const res: Record<string, MultipartFormData> = Object.create(null)
519
+ const errors: Record<string, any> = Object.create(null)
497
520
  const required = Object.fromEntries(
498
521
  Object.entries(schema?.props || {}).filter(([_, v]: [string, any]) => !v?.[Optional])
499
522
  )
500
523
  for await (const chunk of $streamToMultipartForm(data, boundary)) {
501
- if (chunk.headers.name in res) {
502
- if (!Array.isArray(res[chunk.headers.name].content))
503
- res[chunk.headers.name].content = [res[chunk.headers.name].content]
504
- res[chunk.headers.name].content.push(chunk.content)
524
+ const name = chunk.headers.name
525
+ if (name in res) {
526
+ const existing = res[name]!
527
+ if (!Array.isArray(existing.content)) existing.content = [existing.content]
528
+ existing.content.push(chunk.content)
505
529
  } else {
506
- if (schema?.props?.[chunk.headers.name]?.[Kind] === 'array')
507
- res[chunk.headers.name] = { ...chunk, content: [chunk.content] }
508
- else res[chunk.headers.name] = chunk
530
+ if (getProp(schema?.props, name)?.[Kind] === 'array')
531
+ res[name] = { ...chunk, content: [chunk.content] }
532
+ else res[name] = chunk
509
533
  }
510
- delete required[chunk.headers.name]
534
+ delete required[name]
511
535
 
512
- if (schema?.props && chunk?.headers?.name in schema.props) {
536
+ if (schema?.props && Object.hasOwn(schema.props, name)) {
513
537
  try {
514
- if (Array.isArray(res[chunk.headers.name].content) && schema?.props?.[chunk.headers.name]?.[Kind] !== 'array')
538
+ const prop = schema.props[name]!
539
+ const entry = res[name]!
540
+ if (Array.isArray(entry.content) && prop[Kind] !== 'array')
515
541
  throw `Multiple values found`
516
- res[chunk.headers.name].content = validate(res[chunk.headers.name].content, schema?.props[chunk.headers.name], {
542
+ entry.content = validate(entry.content, prop, {
517
543
  parse: true,
518
544
  })
519
- if (schema.props[chunk.headers.name][Kind] === 'array')
520
- for (let [k, v] of Object.entries(res[chunk.headers.name].content)) {
545
+ if (prop[Kind] === 'array')
546
+ for (let [k, v] of Object.entries(entry.content)) {
521
547
  try {
522
548
  //@ts-ignore
523
- res[chunk.headers.name].content[k] = paramParser(v, schema.props[chunk.headers.name].items)
549
+ entry.content[k] = paramParser(v, prop.items)
524
550
  } catch (error) {
525
- errors[chunk.headers.name] = chunk.headers.name in errors ? [...errors[chunk.headers.name], error] : error
551
+ errors[name] = name in errors ? [...errors[name], error] : error
526
552
  }
527
553
  }
528
554
  } catch (err) {
529
- errors[chunk.headers.name] = err
555
+ errors[name] = err
530
556
  }
531
557
  }
532
558
  }
@@ -711,7 +737,23 @@ export const responseParser = (response: any, ctx: Context, cookies: string[], s
711
737
  value.forEach(v => details.headers.append(key, v))
712
738
  } else details.headers.set(key, value)
713
739
  }
714
- if (response instanceof Response) return response
740
+ if (response instanceof Response) {
741
+ // Merge cookies and ctx.set.headers into the raw Response without
742
+ // clobbering headers the user already set on it. `set-cookie` is always
743
+ // appended; other headers are only set when absent on the Response.
744
+ const existing = response.headers
745
+ for (const [key, value] of Object.entries(ctx.set.headers)) {
746
+ if (key.toLowerCase() === 'set-cookie') {
747
+ if (Array.isArray(value)) value.forEach(v => existing.append('set-cookie', v))
748
+ else if (value) existing.append('set-cookie', value as string)
749
+ } else if (!existing.has(key)) {
750
+ if (Array.isArray(value)) value.forEach(v => existing.append(key, v))
751
+ else existing.set(key, value as string)
752
+ }
753
+ }
754
+ for (const cookie of cookies) existing.append('set-cookie', cookie)
755
+ return response
756
+ }
715
757
  else if (typeof response === 'string') {
716
758
  if (!details?.headers?.has('content-type')) {
717
759
  const statusEntry: any = schema?.[details.status]
@@ -720,7 +762,7 @@ export const responseParser = (response: any, ctx: Context, cookies: string[], s
720
762
  : statusEntry?.['application/json'] && !statusEntry?.['text/plain']
721
763
  if (isJson) {
722
764
  details?.headers?.set('content-type', 'application/json')
723
- response = `"${response}"`
765
+ response = JSON.stringify(response)
724
766
  } else details?.headers?.set('content-type', 'text/plain')
725
767
  }
726
768
  return new Response(response, details)
package/src/router.ts CHANGED
@@ -3,6 +3,16 @@ import { MethodNotAllowedError, NotFoundError } from './types'
3
3
 
4
4
  const ROUTE_REGEX = /^(\/(\*|:?\d+|:?\w+|:?[\w\d.][\w-.]+[\w\d]))*\/?$/
5
5
 
6
+ // null-prototype map: segments are looked up with `in`, a plain {} would collide
7
+ // with Object.prototype members (constructor, __proto__, toString, …)
8
+ const newChildren = (): Record<string, RouteNode> => Object.create(null)
9
+
10
+ // trailing slashes are ignored when matching: /tail and /tail/ resolve to the
11
+ // same route regardless of how the route was declared ('/' itself excepted)
12
+ const normalizePath = (path: string) => (path.length > 1 ? path.replace(/\/+$/g, '') || '/' : path)
13
+
14
+ const DEFAULT_CACHE_LIMIT = 1024
15
+
6
16
  const walk = (path: string[], node: RouteNode, index: number = 0): RouteNode => {
7
17
  if (index === path.length - 1) {
8
18
  if (node.routes && !!Object.keys(node.routes).length) return node
@@ -16,9 +26,9 @@ const walk = (path: string[], node: RouteNode, index: number = 0): RouteNode =>
16
26
  const nextSegment = path[index + 1]
17
27
 
18
28
  // 1. Exact Match
19
- if (node.children && nextSegment in node.children) {
29
+ if (node.children && nextSegment !== undefined && nextSegment in node.children) {
20
30
  try {
21
- return walk(path, node.children[nextSegment], index + 1)
31
+ return walk(path, node.children[nextSegment]!, index + 1)
22
32
  } catch (error) {
23
33
  if (!(error instanceof NotFoundError)) throw error
24
34
  }
@@ -54,21 +64,39 @@ export class GalbeRouter {
54
64
  routes: RouteNode
55
65
  prefix: string
56
66
  cacheEnabled: boolean
67
+ cacheLimit: number
57
68
  cachedRoutes: Map<string, Route | null>
58
- constructor(options?: { prefix?: string; cacheEnabled?: boolean }) {
69
+ constructor(options?: { prefix?: string; cacheEnabled?: boolean; cacheLimit?: number }) {
59
70
  this.routes = { routes: {} }
60
71
  let prefix = options?.prefix || ''
61
72
  if (prefix && !prefix.match(/^\//)) prefix = `/${prefix}`
62
73
  this.prefix = prefix
63
74
  this.cachedRoutes = new Map()
64
75
  this.cacheEnabled = options?.cacheEnabled ?? false
76
+ this.cacheLimit = options?.cacheLimit ?? DEFAULT_CACHE_LIMIT
77
+ }
78
+ // bounded LRU: gets refresh recency, sets evict the oldest entry once past
79
+ // cacheLimit, so a flood of distinct lookups (including cached misses) can't
80
+ // grow the map without bound
81
+ private cacheGet(key: string): Route | null | undefined {
82
+ if (!this.cachedRoutes.has(key)) return undefined
83
+ const route = this.cachedRoutes.get(key) as Route | null
84
+ this.cachedRoutes.delete(key)
85
+ this.cachedRoutes.set(key, route)
86
+ return route
87
+ }
88
+ private cacheSet(key: string, route: Route | null) {
89
+ if (this.cachedRoutes.has(key)) this.cachedRoutes.delete(key)
90
+ this.cachedRoutes.set(key, route)
91
+ while (this.cachedRoutes.size > this.cacheLimit)
92
+ this.cachedRoutes.delete(this.cachedRoutes.keys().next().value as string)
65
93
  }
66
94
  add(route: Route) {
67
95
  route.path = route?.path?.[0] === '/' ? route.path : `/${route.path}`
68
96
  if (!route.path.match(ROUTE_REGEX)) throw new SyntaxError(`${route.path} is not a valid route path.`)
69
97
  const isStatic = !route.path.match(/(:[\w\d-]+|\*)/)
70
98
  route.path = `${this.prefix || ''}${route.path}`
71
- if (isStatic) this.cachedRoutes.set(`[${route.method}]${route.path}`, route)
99
+ if (isStatic) this.cacheSet(`[${route.method}]${normalizePath(route.path)}`, route)
72
100
  let path = route.path.replace(/^\/+|\/+$/g, '').split('/')
73
101
  if (path[0] === '') path.shift()
74
102
  let r = this.routes
@@ -83,35 +111,36 @@ export class GalbeRouter {
83
111
  if (!r.param) r.param = { routes: {} }
84
112
  r.param.routes[route.method] = route
85
113
  } else {
86
- if (!r.children) r.children = {}
114
+ if (!r.children) r.children = newChildren()
87
115
  if (!(p in r.children)) r.children[p] = { routes: {} }
88
- r.children[p].routes[route.method] = route
116
+ r.children[p]!.routes[route.method] = route
89
117
  }
90
118
  } else {
91
119
  if (p.match(/^:/)) {
92
120
  if (!r.param) r.param = { routes: {} }
93
121
  r = r.param
94
122
  } else {
95
- if (!r.children) r.children = {}
123
+ if (!r.children) r.children = newChildren()
96
124
  if (!(p in r.children)) r.children[p] = { routes: {} }
97
- r = r.children[p]
125
+ r = r.children[p]!
98
126
  }
99
127
  }
100
128
  }
101
129
  }
102
130
  }
103
131
  find(method: Method, path: string): Route {
104
- const staticRoute = this.cachedRoutes.get(`[${method}]${path}`)
132
+ path = normalizePath(path)
133
+ const staticRoute = this.cacheGet(`[${method}]${path}`)
105
134
  if (staticRoute === null) throw new NotFoundError()
106
135
  if (staticRoute !== undefined) return staticRoute
107
136
  let parts = path === '/' ? [''] : path.split('/')
108
137
  let r = walk(parts, this.routes)
109
138
  if (!r || !Object.keys(r.routes).length) {
110
- if (this.cacheEnabled) this.cachedRoutes.set(`[${method}]${path}`, null)
139
+ if (this.cacheEnabled) this.cacheSet(`[${method}]${path}`, null)
111
140
  throw new NotFoundError()
112
141
  } else if (!(method in r.routes)) throw new MethodNotAllowedError()
113
142
  const route = r.routes[method] as Route
114
- if (this.cacheEnabled) this.cachedRoutes.set(`[${method}]${path}`, route)
143
+ if (this.cacheEnabled) this.cacheSet(`[${method}]${path}`, route)
115
144
  return route
116
145
  }
117
146
  }
package/src/routes.ts CHANGED
@@ -132,14 +132,16 @@ const parseComment = (comment: string): Record<string, string | string[]> => {
132
132
  ...(head ? { head } : {}),
133
133
  ...[...tagsSrc.matchAll(new RegExp(`^\\s*\\*\\s*@([a-zA-Z_][0-9a-zA-Z_]*)(?:$|\\s+([^\\n]*)\\s*$)`, 'gm'))].reduce(
134
134
  (acc, n) => {
135
+ const tag = n[1]!
136
+ const val = n[2] ?? true
135
137
  return {
136
138
  ...acc,
137
- [n[1]]:
138
- n[1] in acc ? [...(typeof acc[n[1]] === 'string' ? [acc[n[1]]] : acc[n[1]]), n[2] ?? true] : n[2] ?? true
139
+ [tag]:
140
+ tag in acc ? [...(typeof acc[tag] === 'string' ? [acc[tag]] : acc[tag]), val] : val
139
141
  }
140
142
  },
141
143
  {} as Record<string, any>
142
- )
144
+ ),
143
145
  }
144
146
  return refs
145
147
  }
@@ -188,7 +190,7 @@ export const metaAnalysis = async (filePath: string): Promise<RoutesMeta> => {
188
190
  else if (HIDE_COMMENT_RGX.test(text)) {
189
191
  hideLines.add(locEnd.line + 1)
190
192
  }
191
- else comments[locEnd.line][locEnd.column] = text
193
+ else comments[locEnd.line]![locEnd.column] = text
192
194
  }
193
195
  }
194
196
  })
@@ -196,7 +198,7 @@ export const metaAnalysis = async (filePath: string): Promise<RoutesMeta> => {
196
198
  ExportDefaultDeclaration(node) {
197
199
  const headerLine = node.loc?.start.line || -1
198
200
  const headerCol = node.loc?.start.column || -1
199
- const headerCom = comments?.[headerLine]?.[headerCol - 1] ? comments[headerLine][headerCol - 1] : ''
201
+ const headerCom = comments?.[headerLine]?.[headerCol - 1] ?? ''
200
202
  const hide = hideLines.has(headerLine)
201
203
  if (hide) meta.hide = true
202
204
  if (ignoredLines.has(headerLine)) {
@@ -222,10 +224,10 @@ export const metaAnalysis = async (filePath: string): Promise<RoutesMeta> => {
222
224
  const method = node.callee.property.name as Method
223
225
  const line = node.loc?.start.line || -1
224
226
  const col = node.loc?.start.column || -1
225
- const com = comments?.[line]?.[col - 1] ? comments[line][col - 1] : ''
227
+ const com = comments?.[line]?.[col - 1] ?? ''
226
228
  const routeRefs = ignoredLines.has(line) ? { ignore: true } : { ...parseComment(com), ...(hide || hideLines.has(line) ? { hide: true } : {}) }
227
229
  if (!(path in meta.routes)) meta.routes[path] = {}
228
- if (!(method in meta.routes[path])) meta.routes[path][method] = routeRefs as RouteMeta
230
+ if (!(method in meta.routes[path]!)) meta.routes[path]![method] = routeRefs as RouteMeta
229
231
  }
230
232
  }
231
233
  })
package/src/schema.ts CHANGED
@@ -228,7 +228,7 @@ function _Object<T extends STProps>(properties?: T, options: Options = {}): STOb
228
228
  const propertyKeys = globalThis.Object.getOwnPropertyNames(properties)
229
229
  const optionalKeys = propertyKeys.filter(key => properties[key]?.[Optional])
230
230
  const requiredKeys = propertyKeys.filter(name => !optionalKeys.includes(name))
231
- const clonedProperties = propertyKeys.reduce((acc, key) => ({ ...acc, [key]: { ...properties[key] } }), {} as STProps)
231
+ const clonedProperties = propertyKeys.reduce<STProps>((acc, key) => ({ ...acc, [key]: { ...properties[key]! } }), {})
232
232
  return (requiredKeys.length > 0
233
233
  ? { ...options, [Kind]: 'object', props: clonedProperties, required: requiredKeys }
234
234
  : { ...options, [Kind]: 'object', props: clonedProperties }) as unknown as STObject<T>
@@ -273,7 +273,7 @@ function _MultipartForm<T extends STProps>(properties?: T, options: Options = {}
273
273
  const propertyKeys = globalThis.Object.getOwnPropertyNames(properties)
274
274
  const optionalKeys = propertyKeys.filter(key => properties[key]?.[Optional])
275
275
  const requiredKeys = propertyKeys.filter(name => !optionalKeys.includes(name))
276
- const clonedProperties = propertyKeys.reduce((acc, key) => ({ ...acc, [key]: { ...properties[key] } }), {} as STProps)
276
+ const clonedProperties = propertyKeys.reduce<STProps>((acc, key) => ({ ...acc, [key]: { ...properties[key]! } }), {})
277
277
  return (requiredKeys.length > 0
278
278
  ? { ...options, [Kind]: 'multipartForm', props: clonedProperties, required: requiredKeys }
279
279
  : { ...options, [Kind]: 'multipartForm', props: clonedProperties }) as unknown as STMultipartForm<T>
package/src/server.ts CHANGED
@@ -5,7 +5,7 @@ import { parseEntry, requestBodyParser, requestPathParser, responseParser } from
5
5
  import { Galbe } from './index'
6
6
  import { validateResponse } from './validator'
7
7
  const normalizeContentType = (ct: string | null): string | undefined =>
8
- ct ? ct.split(';')[0].trim() || undefined : undefined
8
+ ct ? (ct.split(';')[0] ?? '').trim() || undefined : undefined
9
9
  import { readCookies, stringifyCookie } from './cookies'
10
10
 
11
11
  type MakeOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
@@ -31,11 +31,19 @@ export default async (galbe: Galbe, port?: number, hostname?: string) => {
31
31
  galbe.config.basePath = `/${galbe?.config?.basePath}`
32
32
  let pluginsCb = setupPluginCallbacks(galbe)
33
33
 
34
+ // config.server is passed through to Bun.serve; port/fetch/error are owned by
35
+ // Galbe and the dedicated config keys (port, hostname, reusePort, tls) win
36
+ const serverOptions: Record<string, any> = { ...galbe.config?.server }
37
+ delete serverOptions.port
38
+ delete serverOptions.fetch
39
+ delete serverOptions.error
40
+
34
41
  const server = Bun.serve({
42
+ ...serverOptions,
35
43
  port: port || galbe.config?.port || 3000,
36
- reusePort: galbe?.config?.reusePort,
37
- hostname: hostname || galbe.config?.hostname || 'localhost',
38
- tls: galbe.config?.tls,
44
+ reusePort: galbe?.config?.reusePort ?? serverOptions.reusePort,
45
+ hostname: hostname || galbe.config?.hostname || serverOptions.hostname || 'localhost',
46
+ tls: galbe.config?.tls ?? serverOptions.tls,
39
47
 
40
48
  async fetch(req) {
41
49
  if (!METHODS.includes(req.method)) return new Response('', { status: 501 })
@@ -79,9 +87,11 @@ export default async (galbe: Galbe, port?: number, hostname?: string) => {
79
87
 
80
88
  // parse request
81
89
  const schema = route.schema
82
- const inHeaders: Record<string, any> = {}
90
+ // null-prototype maps: keys are untrusted, a plain {} would collide with
91
+ // Object.prototype members (constructor, __proto__, toString, …)
92
+ const inHeaders: Record<string, any> = Object.create(null)
83
93
  for (let [k, v] of req.headers) inHeaders[k] = v
84
- let inQuery: Record<string, any> = {}
94
+ let inQuery: Record<string, any> = Object.create(null)
85
95
  for (let [k, v] of url.searchParams) {
86
96
  if (k in inQuery) {
87
97
  const cur = inQuery[k]
@@ -150,7 +160,7 @@ export default async (galbe: Galbe, port?: number, hostname?: string) => {
150
160
  if (nextCalled) console.error('Hook already called - ignored')
151
161
  else {
152
162
  nextCalled = true
153
- return await callChain[idx + 1].call()
163
+ return await callChain[idx + 1]!.call()
154
164
  }
155
165
  }
156
166
  let r = await hook(context as Context, next)
@@ -164,7 +174,7 @@ export default async (galbe: Galbe, port?: number, hostname?: string) => {
164
174
  context.set.status = response instanceof Response ? response.status : context.set.status || 200
165
175
  },
166
176
  })
167
- const r = await callChain[0].call()
177
+ const r = await callChain[0]!.call()
168
178
  if (r) response = r
169
179
  if (context.set.status === undefined)
170
180
  context.set.status = response instanceof Response ? response.status : 200
@@ -184,8 +194,12 @@ export default async (galbe: Galbe, port?: number, hostname?: string) => {
184
194
  } catch (error) {
185
195
  context.set.status = error instanceof RequestError ? error.status : 500
186
196
  let customError
187
- for (let eh of galbe.errorCb)
188
- customError = responseParser(eh(error, context as Context), context as Context, cookies)
197
+ for (let eh of galbe.errorCb) {
198
+ const result = await eh(error, context as Context)
199
+ if (result === undefined) continue
200
+ customError = responseParser(result, context as Context, cookies)
201
+ break
202
+ }
189
203
  if (customError) return customError
190
204
  if (error instanceof InternalServerError) {
191
205
  let internalPayload = 'Internal Server Error'
@@ -196,7 +210,21 @@ export default async (galbe: Galbe, port?: number, hostname?: string) => {
196
210
  })
197
211
  } else if (error instanceof RequestError) {
198
212
  let payload = error.payload
199
- let headers = new Headers({ ...context.set.headers, ...error?.headers })
213
+ // append semantics: context.set.headers always carries the
214
+ // 'set-cookie': [] sentinel and may hold array values, which the
215
+ // Headers constructor would stringify ('set-cookie: ""', 'a,b').
216
+ // Cookies queued via ctx.set.cookie() are merged like on the
217
+ // success path.
218
+ let headers = new Headers()
219
+ const mergeHeaders = (h?: Record<string, string | string[]>) => {
220
+ for (const [k, v] of Object.entries(h ?? {})) {
221
+ if (Array.isArray(v)) v.forEach(x => headers.append(k, x))
222
+ else if (v) headers.set(k, v)
223
+ }
224
+ }
225
+ mergeHeaders(context.set.headers)
226
+ mergeHeaders(error?.headers)
227
+ for (const cookie of cookies) headers.append('set-cookie', cookie)
200
228
  if (!headers.has('content-type')) {
201
229
  if (typeof error.payload === 'string') headers.set('content-type', 'text/plain')
202
230
  else {
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Serve, SocketAddress, TLSOptions } from 'bun'
1
+ import type { SocketAddress, TLSOptions } from 'bun'
2
2
  import type {
3
3
  STAny,
4
4
  STArray,
@@ -140,10 +140,11 @@ export type GalbeConfig = {
140
140
  basePath?: string
141
141
  /** Enable or disable TLS support. */
142
142
  tls?: TLSOptions
143
- server?: Exclude<Serve.Options<any>, 'port'> | TLSOptions
143
+ /** Extra options passed through to `Bun.serve` (e.g. `maxRequestBodySize`, `idleTimeout`). `port`, `fetch` and `error` are ignored, and the dedicated `hostname`, `reusePort` and `tls` config keys take precedence. */
144
+ server?: Partial<Omit<Parameters<typeof Bun.serve>[0], 'port' | 'fetch' | 'error'>> | TLSOptions
144
145
  /** A Glob Pattern or a list of Glob patterns defining the route files to be analyzed by the Automatic Route Analyzer. */
145
146
  routes?: boolean | string | string[]
146
- router?: { cacheEnabled: boolean }
147
+ router?: { cacheEnabled: boolean; cacheLimit?: number }
147
148
  /** A property that can be used by plugins to add plugin's specific configuration. */
148
149
  plugin?: Record<string, any>
149
150
  /** Enable or disable the request schema validation.*/
package/src/util.ts CHANGED
@@ -12,6 +12,11 @@ const METHOD_COLOR: Record<string, string> = {
12
12
  }
13
13
  const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g
14
14
 
15
+ /**
16
+ * Deep-merges `override` into `base` **in place** — `base` is mutated and
17
+ * returned, so treat config merging as destructive. Plain objects merge
18
+ * recursively; arrays, `null` and primitives replace the base value wholesale.
19
+ */
15
20
  export const softMerge = <T>(base: T, override: T): T => {
16
21
  for (const key in override) {
17
22
  if (override[key] instanceof Object && !(override[key] instanceof Array)) {
package/src/validator.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  STJson,
8
8
  STLiteral,
9
9
  STArray,
10
+ STByteArray,
10
11
  STNumber,
11
12
  STInteger,
12
13
  STString,
@@ -84,6 +85,7 @@ export const validate = (elt: any, schema: STSchema, opt?: { parse?: boolean }):
84
85
  if (opt?.parse && typeof elt === 'string') elt = Uint8Array.from(elt, c => c.charCodeAt(0))
85
86
  else if (opt?.parse && Array.isArray(elt)) elt = new Uint8Array(elt)
86
87
  if (!(elt instanceof Uint8Array)) throw 'Not a valid byteArray'
88
+ schemaValidation(elt, schema)
87
89
  } else if (schema[Kind] === 'anyOf' || schema[Kind] === 'oneOf') {
88
90
  const union = Object.values((schema as STUnion).members)
89
91
  let valid = false
@@ -161,6 +163,12 @@ const schemaValidation = (value: any, schema: STSchema) => {
161
163
  errors.push(`Length is too large (${str.maxLength} char max)`)
162
164
  if (str.pattern !== undefined && !(value as string).match(str.pattern))
163
165
  errors.push(`Does not match pattern ${str.pattern}`)
166
+ } else if (schema[Kind] === 'byteArray') {
167
+ const ba = schema as STByteArray
168
+ if (ba.minLength !== undefined && (value as Uint8Array).length < ba.minLength)
169
+ errors.push(`Length is too small (${ba.minLength} bytes min)`)
170
+ if (ba.maxLength !== undefined && (value as Uint8Array).length > ba.maxLength)
171
+ errors.push(`Length is too large (${ba.maxLength} bytes max)`)
164
172
  } else if (schema[Kind] === 'array') {
165
173
  const arr = schema as STArray
166
174
  if (arr.minLength !== undefined && (value as any[]).length < arr.minLength)