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/schema.ts CHANGED
@@ -13,18 +13,29 @@ export interface Options {
13
13
  * `examples`). On any other schema: a single example value.
14
14
  */
15
15
  examples?: any
16
- /**
17
- * Response-only: declares response headers. Each value is a Galbe schema
18
- * describing the header's value type.
19
- */
20
- headers?: Record<string, any>
21
16
  /** Marks the schema as deprecated. Surfaced by spec generators (e.g. OpenAPI). */
22
17
  deprecated?: boolean
18
+ /**
19
+ * Documentation only: marks the value as sent by the server and never by the
20
+ * client (OpenAPI `readOnly`). Galbe does not enforce it — a `readOnly`
21
+ * property in a request body is still validated like any other.
22
+ */
23
+ readOnly?: boolean
24
+ /**
25
+ * Documentation only: marks the value as sent by the client and never
26
+ * returned by the server (OpenAPI `writeOnly`). Not enforced at runtime.
27
+ */
28
+ writeOnly?: boolean
23
29
  /**
24
30
  * Response-only: declares response headers emitted in the OpenAPI `responses` object.
25
31
  * Distinct from the request-level `headers` field.
26
32
  */
27
33
  responseHeaders?: Record<string, any>
34
+ /**
35
+ * Response-only, documentation only: the OpenAPI `links` object — operations
36
+ * reachable from this response, keyed by name. Carried verbatim.
37
+ */
38
+ responseLinks?: Record<string, any>
28
39
  }
29
40
  export interface ByteArrayOptions extends Options {
30
41
  minLength?: number
@@ -41,11 +52,71 @@ export interface NumberOptions extends Options {
41
52
  max?: number
42
53
  exclusiveMin?: number
43
54
  exclusiveMax?: number
55
+ /** The value must be an exact multiple of this number. */
56
+ multipleOf?: number
57
+ /**
58
+ * The numeric format, surfaced as OpenAPI's `format`. The four machine-integer
59
+ * formats (`int32`, `uint32`, `int64`, `uint64`) also constrain the value's
60
+ * range at runtime; every other format — `float` and `double` included — is
61
+ * documentation only, since any finite JSON number is a valid double.
62
+ */
63
+ format?: NumberFormat | (string & {})
64
+ }
65
+ export type NumberFormat = 'int32' | 'uint32' | 'int64' | 'uint64' | 'float' | 'double' | 'decimal'
66
+ /**
67
+ * Ranges for the integer formats, as `[min, max]`. The 64-bit bounds are not
68
+ * exactly representable as doubles, so they land on ±2^63 / 2^64: the check
69
+ * catches an overflow by orders of magnitude, which is what it is for, and
70
+ * never rejects a value a double could have held.
71
+ */
72
+ export const NUMBER_FORMAT_RANGES: Record<string, [number, number]> = {
73
+ int32: [-2147483648, 2147483647],
74
+ uint32: [0, 4294967295],
75
+ int64: [-(2 ** 63), 2 ** 63 - 1],
76
+ uint64: [0, 2 ** 64 - 1],
44
77
  }
45
78
  export interface ArrayOptions extends Options {
46
79
  minLength?: number
47
80
  maxLength?: number
48
81
  unique?: boolean
82
+ /**
83
+ * Query parameters only: the delimiter that splits a single value into
84
+ * several items — `?tags=a,b` with the default `','`. Repeated keys
85
+ * (`?tags=a&tags=b`) are always accepted regardless.
86
+ *
87
+ * `false` disables splitting, so a value containing the delimiter stays one
88
+ * item. `'|'` and `' '` are OpenAPI's `pipeDelimited` and `spaceDelimited`,
89
+ * and are emitted as such by spec generators.
90
+ */
91
+ split?: string | false
92
+ }
93
+ /** Per-part serialization details for a multipart body, as OpenAPI's `encoding`. */
94
+ export type EncodingProperty = {
95
+ contentType?: string
96
+ style?: string
97
+ explode?: boolean
98
+ allowReserved?: boolean
99
+ headers?: Record<string, any>
100
+ }
101
+ export interface MultipartFormOptions extends Options {
102
+ /**
103
+ * Documentation only: how each part is serialized, keyed by property name.
104
+ * Emitted as the media type's `encoding` object — Galbe's multipart parser
105
+ * validates parts from the schema alone and does not read this.
106
+ */
107
+ encoding?: Record<string, EncodingProperty>
108
+ }
109
+ export interface ObjectOptions extends Options {
110
+ /**
111
+ * What may appear beside the declared properties, mirroring OpenAPI's
112
+ * `additionalProperties`. A schema validates every undeclared property against
113
+ * it — `$T.record` is the sugar for the property-less case. `false` rejects
114
+ * undeclared properties outright. Unset (the default) accepts and ignores them.
115
+ *
116
+ * `Static<>` only ever surfaces the declared properties: an object that mixes
117
+ * both would otherwise index every declared key through the value schema too.
118
+ */
119
+ additionalProperties?: STSchema | false
49
120
  }
50
121
  export interface STSchema extends Options {
51
122
  [Kind]:
@@ -71,17 +142,7 @@ export interface STSchema extends Options {
71
142
  static: unknown
72
143
  }
73
144
  export type STPropsValue =
74
- | STBoolean
75
- | STByteArray
76
- | STNumber
77
- | STInteger
78
- | STString
79
- | STLiteral
80
- | STArray
81
- | STObject
82
- | STUnion
83
- | STAny
84
- | STNull
145
+ STBoolean | STByteArray | STNumber | STInteger | STString | STLiteral | STArray | STObject | STUnion | STAny | STNull
85
146
  export type STProps = Record<string | number, STPropsValue>
86
147
 
87
148
  type Evaluate<T> = T extends infer O ? { [K in keyof O]: O[K] } : never
@@ -209,6 +270,18 @@ export interface STObject<T extends STProps = STProps> extends STSchema {
209
270
  [Kind]: 'object'
210
271
  static: ObjectStatic<T, this['params']>
211
272
  props: T
273
+ additionalProperties?: STSchema | false
274
+ }
275
+ /**
276
+ * A free-form map: no declared properties, every value validated against one
277
+ * schema. Kind stays `'object'` — a record *is* an object with
278
+ * `additionalProperties`, so every consumer that walks objects keeps working.
279
+ */
280
+ export interface STRecord<V extends STSchema = STSchema> extends STSchema {
281
+ [Kind]: 'object'
282
+ static: Record<string, Static<V, this['params']>>
283
+ props: STProps
284
+ additionalProperties: V
212
285
  }
213
286
  export interface STJson<T extends STBoolean | STNumber | STString | STObject = any> extends STSchema {
214
287
  [Kind]: 'json'
@@ -223,7 +296,7 @@ type RequiredPropertyKeys<T extends STProps> = keyof Omit<T, OptionalPropertyKey
223
296
  type ObjectStaticProps<T extends STProps, R extends Record<keyof any, unknown>> = Evaluate<
224
297
  Partial<Pick<R, OptionalPropertyKeys<T>>> & Required<Pick<R, RequiredPropertyKeys<T>>>
225
298
  >
226
- function _Object<T extends STProps>(properties?: T, options: Options = {}): STObject<T> {
299
+ function _Object<T extends STProps>(properties?: T, options: ObjectOptions = {}): STObject<T> {
227
300
  if (!properties) return { ...options, [Kind]: 'object' } as unknown as STObject<T>
228
301
  const propertyKeys = globalThis.Object.getOwnPropertyNames(properties)
229
302
  const optionalKeys = propertyKeys.filter(key => properties[key]?.[Optional])
@@ -251,7 +324,7 @@ export interface MultipartFormData<K extends string = string, V extends Static<S
251
324
  headers: { type?: string; name: K; filename?: string }
252
325
  content: V
253
326
  }
254
- export interface STMultipartForm<T extends STProps = STProps> extends STSchema {
327
+ export interface STMultipartForm<T extends STProps = STProps> extends STSchema, MultipartFormOptions {
255
328
  [Kind]: 'multipartForm'
256
329
  static: T extends undefined
257
330
  ? {
@@ -268,7 +341,7 @@ export interface STMultipartForm<T extends STProps = STProps> extends STSchema {
268
341
  }
269
342
  props: T
270
343
  }
271
- function _MultipartForm<T extends STProps>(properties?: T, options: Options = {}): STMultipartForm<T> {
344
+ function _MultipartForm<T extends STProps>(properties?: T, options: MultipartFormOptions = {}): STMultipartForm<T> {
272
345
  if (!properties) return { ...options, [Kind]: 'multipartForm' } as unknown as STMultipartForm<T>
273
346
  const propertyKeys = globalThis.Object.getOwnPropertyNames(properties)
274
347
  const optionalKeys = propertyKeys.filter(key => properties[key]?.[Optional])
@@ -403,9 +476,18 @@ export class SchemaType {
403
476
  return _Any(options)
404
477
  }
405
478
  /** Creates an Object Schema Type */
406
- public object<T extends STProps>(properties?: T, options: Options = {}): STObject<T> {
479
+ public object<T extends STProps>(properties?: T, options: ObjectOptions = {}): STObject<T> {
407
480
  return _Object(properties, options)
408
481
  }
482
+ /**
483
+ * Creates a Record Schema Type: an object with no declared properties whose
484
+ * every value validates against `value`. Sugar for
485
+ * `$T.object(undefined, { additionalProperties: value })`, typed as
486
+ * `Record<string, Static<V>>`.
487
+ */
488
+ public record<V extends STSchema>(value: V, options: Options = {}): STRecord<V> {
489
+ return _Object(undefined, { ...options, additionalProperties: value }) as unknown as STRecord<V>
490
+ }
409
491
  /** Creates a JSON Schema Type */
410
492
  public json<T extends STString | STBoolean | STNumber | STObject<STProps>>(
411
493
  value: T,
@@ -414,7 +496,7 @@ export class SchemaType {
414
496
  return _Json(value, options)
415
497
  }
416
498
  /** Creates a MultipartForm Schema Type */
417
- public multipartForm<T extends STProps>(properties?: T, options: Options = {}): STMultipartForm<T> {
499
+ public multipartForm<T extends STProps>(properties?: T, options: MultipartFormOptions = {}): STMultipartForm<T> {
418
500
  return _MultipartForm(properties, options)
419
501
  }
420
502
  /** Creates an Array Schema Type */
@@ -458,12 +540,14 @@ export class SchemaType {
458
540
  ): Omit<STStream<T>, 'static'> & {
459
541
  static: AsyncGenerator<
460
542
  Entries<{
461
- [P in KeysOfUnion<MemberProps<T['members'][number]>> as ValueAt<
462
- MemberProps<T['members'][number]>,
463
- P
464
- > extends STSchema
465
- ? P
466
- : never]: Static<ValueAt<MemberProps<T['members'][number]>, P>>
543
+ [
544
+ P in KeysOfUnion<MemberProps<T['members'][number]>> as ValueAt<
545
+ MemberProps<T['members'][number]>,
546
+ P
547
+ > extends STSchema
548
+ ? P
549
+ : never
550
+ ]: Static<ValueAt<MemberProps<T['members'][number]>, P>>
467
551
  }>
468
552
  >
469
553
  params: unknown[]
@@ -536,10 +620,18 @@ export const schemaToTypeStr = (schema: STSchema): string => {
536
620
  } else if (kind === 'array') {
537
621
  type = `Array<${schemaToTypeStr((schema as STArray).items)}>`
538
622
  } else if (kind === 'object') {
539
- let props = (schema as STObject).props
540
- type = `{${Object.entries(props)
541
- .map(([k, v]) => `${typeof k === 'string' ? `'${k}'` : k}${v?.[Optional] ? '?' : ''}:${schemaToTypeStr(v)}`)
542
- .join(';')}}`
623
+ let entries = Object.entries((schema as STObject).props ?? {})
624
+ const ap = (schema as STObject).additionalProperties
625
+ // prop-less object accepts any object at runtime, so `{}` would be too loose
626
+ type = entries.length
627
+ ? // declared properties win: intersecting them with the record type would
628
+ // resolve every declared key to `declared & value`, i.e. usually `never`
629
+ `{${entries
630
+ .map(([k, v]) => `${typeof k === 'string' ? `'${k}'` : k}${v?.[Optional] ? '?' : ''}:${schemaToTypeStr(v)}`)
631
+ .join(';')}}`
632
+ : ap
633
+ ? `Record<string, ${schemaToTypeStr(ap)}>`
634
+ : 'Record<string, unknown>'
543
635
  } else if (kind === 'json') {
544
636
  type = `Json<${schemaToTypeStr((schema as STJson).value)}>`
545
637
  } else if (kind === 'anyOf' || kind === 'oneOf') {
package/src/server.ts CHANGED
@@ -1,12 +1,13 @@
1
- import type { Context, Method, Route } from './types'
1
+ import type { Context, GalbePlugin, Method, Route } from './types'
2
2
 
3
- import { InternalServerError, RequestError } from './types'
3
+ import { InternalServerError, PayloadTooLargeError, RequestError } from './types'
4
4
  import { parseEntry, requestBodyParser, requestPathParser, responseParser } from './parser'
5
5
  import { Galbe } from './index'
6
6
  import { validateResponse } from './validator'
7
7
  const normalizeContentType = (ct: string | null): string | undefined =>
8
8
  ct ? (ct.split(';')[0] ?? '').trim() || undefined : undefined
9
9
  import { readCookies, stringifyCookie } from './cookies'
10
+ import { clientAddressResolver } from './util'
10
11
 
11
12
  type MakeOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
12
13
 
@@ -18,11 +19,14 @@ const handleInternalError = (error: any) => {
18
19
  return new InternalServerError()
19
20
  }
20
21
 
22
+ // the predicates narrow the hook to non-optional, so the lifecycle can call it
23
+ // without a null check it has already made
24
+ type PluginWith<K extends keyof GalbePlugin> = GalbePlugin & Required<Pick<GalbePlugin, K>>
21
25
  const setupPluginCallbacks = (galbe: Galbe) => ({
22
- onFetch: galbe.plugins.filter(p => p.onFetch),
23
- onRoute: galbe.plugins.filter(p => p.onRoute),
24
- beforeHandle: galbe.plugins.filter(p => p.beforeHandle),
25
- afterHandle: galbe.plugins.filter(p => p.afterHandle),
26
+ onFetch: galbe.plugins.filter((p): p is PluginWith<'onFetch'> => !!p.onFetch),
27
+ onRoute: galbe.plugins.filter((p): p is PluginWith<'onRoute'> => !!p.onRoute),
28
+ beforeHandle: galbe.plugins.filter((p): p is PluginWith<'beforeHandle'> => !!p.beforeHandle),
29
+ afterHandle: galbe.plugins.filter((p): p is PluginWith<'afterHandle'> => !!p.afterHandle),
26
30
  })
27
31
 
28
32
  export default async (galbe: Galbe, port?: number, hostname?: string) => {
@@ -30,6 +34,8 @@ export default async (galbe: Galbe, port?: number, hostname?: string) => {
30
34
  if (galbe?.config?.basePath && galbe?.config?.basePath[0] !== '/')
31
35
  galbe.config.basePath = `/${galbe?.config?.basePath}`
32
36
  let pluginsCb = setupPluginCallbacks(galbe)
37
+ // compiled once: a trustProxy misconfiguration must fail at boot, not per request
38
+ const resolveClientAddress = clientAddressResolver(galbe.config?.trustProxy)
33
39
 
34
40
  // config.server is passed through to Bun.serve; port/fetch/error are owned by
35
41
  // Galbe and the dedicated config keys (port, hostname, reusePort, tls) win
@@ -48,25 +54,47 @@ export default async (galbe: Galbe, port?: number, hostname?: string) => {
48
54
  async fetch(req) {
49
55
  if (!METHODS.includes(req.method)) return new Response('', { status: 501 })
50
56
  const cookies: string[] = []
57
+ let reqHeaders: Record<string, any> | undefined
58
+ let clientAddress: string | null | undefined
59
+ const peer = server.requestIP(req)
51
60
  const context = {
52
61
  request: req,
62
+ // null-prototype: header names are untrusted keys and must never reach
63
+ // Object.prototype members (constructor, __proto__, toString, …)
64
+ get headers() {
65
+ if (reqHeaders) return reqHeaders
66
+ const h: Record<string, any> = Object.create(null)
67
+ for (const [k, v] of req.headers) h[k] = v
68
+ return (reqHeaders = h)
69
+ },
70
+ set headers(v: Record<string, any>) {
71
+ reqHeaders = v
72
+ },
53
73
  contentType: !EMPTY_BODY_METHODS.includes(req.method)
54
74
  ? normalizeContentType(req.headers.get('content-type'))
55
75
  : undefined,
56
- remoteAddress: server.requestIP(req),
76
+ remoteAddress: peer,
77
+ // lazy like `headers`: an app that never asks does not pay for the
78
+ // X-Forwarded-For walk. `remoteAddress` stays the raw socket peer.
79
+ get clientAddress() {
80
+ if (clientAddress === undefined) clientAddress = resolveClientAddress(req, peer?.address ?? null)
81
+ return clientAddress
82
+ },
57
83
  set: {
58
84
  headers: { 'set-cookie': [] },
59
85
  cookie: (name, value, opt = { path: '/' }) => cookies.push(stringifyCookie(name, value, opt)),
60
86
  },
61
87
  state: {},
62
88
  cookies: readCookies(req.headers.get('cookie')),
63
- } as MakeOptional<Context, 'headers' | 'params' | 'query' | 'body'>
89
+ } as MakeOptional<Context, 'params' | 'query' | 'body'>
64
90
  const url = new URL(req.url)
65
- let route: Route
91
+ let route: Route | undefined
66
92
  let response: any = ''
93
+ // the route-scoped post-parse stage runs once per request, so a hook that
94
+ // threw on the success path is not run a second time on the error response
95
+ let postRan = false
67
96
  try {
68
97
  for (const p of pluginsCb.onFetch) {
69
- //@ts-ignore
70
98
  const r = await p.onFetch(context)
71
99
  if (r) return r
72
100
  }
@@ -80,17 +108,22 @@ export default async (galbe: Galbe, port?: number, hostname?: string) => {
80
108
  context.route = route
81
109
 
82
110
  for (const p of pluginsCb.onRoute) {
83
- //@ts-ignore
84
111
  const r = await p.onRoute(context)
85
112
  if (r) return r
86
113
  }
87
114
 
115
+ // middleware pre-parse slot, composed at registration (see composePreParse):
116
+ // the route is known, nothing has been committed yet — an auth or rate
117
+ // limit rejection here answers before a byte of body is read
118
+ if (route.composedPre) {
119
+ const r = await route.composedPre(context)
120
+ if (r) return r
121
+ }
122
+
88
123
  // parse request
89
124
  const schema = route.schema
90
- // null-prototype maps: keys are untrusted, a plain {} would collide with
125
+ // null-prototype map: keys are untrusted, a plain {} would collide with
91
126
  // Object.prototype members (constructor, __proto__, toString, …)
92
- const inHeaders: Record<string, any> = Object.create(null)
93
- for (let [k, v] of req.headers) inHeaders[k] = v
94
127
  let inQuery: Record<string, any> = Object.create(null)
95
128
  for (let [k, v] of url.searchParams) {
96
129
  if (k in inQuery) {
@@ -101,13 +134,17 @@ export default async (galbe: Galbe, port?: number, hostname?: string) => {
101
134
  }
102
135
  let inParams = requestPathParser(url.pathname, route.path)
103
136
 
137
+ // reject oversized bodies declared by honest clients before reading a byte
138
+ const bodyLimit = schema.bodyLimit ?? galbe.config?.bodyLimit
139
+ if (bodyLimit !== undefined && Number(req.headers.get('content-length')) > bodyLimit)
140
+ throw new PayloadTooLargeError()
141
+
104
142
  context.body = await requestBodyParser(
105
- req.body,
106
- inHeaders,
143
+ req,
107
144
  EMPTY_BODY_METHODS.includes(req.method) ? undefined : schema.body,
108
- context.contentType
145
+ context.contentType,
146
+ bodyLimit
109
147
  )
110
- context.headers = inHeaders
111
148
  context.query = inQuery
112
149
  context.params = inParams
113
150
 
@@ -136,63 +173,78 @@ export default async (galbe: Galbe, port?: number, hostname?: string) => {
136
173
  if (error instanceof RequestError) errors.push(error)
137
174
  else throw handleInternalError(error)
138
175
  }
176
+ try {
177
+ // declared cookies are parsed and validated; undeclared ones stay
178
+ // on the context as the raw strings they arrived as
179
+ if (schema?.cookies)
180
+ context.cookies = {
181
+ ...context.cookies,
182
+ ...parseEntry(context.cookies, schema.cookies, { name: 'cookies' }),
183
+ }
184
+ } catch (error) {
185
+ if (error instanceof RequestError) errors.push(error)
186
+ else throw handleInternalError(error)
187
+ }
139
188
  if (errors.length) {
140
189
  throw new RequestError({ status: 400, payload: errors.reduce((acc, c) => ({ ...acc, ...c.payload }), {}) })
141
190
  }
142
191
  }
143
192
 
144
193
  for (const p of pluginsCb.beforeHandle) {
145
- //@ts-ignore
146
- const r = await p.beforeHandle(context)
194
+ const r = await p.beforeHandle(context as Context)
147
195
  if (r) return r
148
196
  }
149
197
 
150
- // call chain
151
- let handlerCalled = false
152
- const handlerWrapper = async (context: Context) => {
153
- handlerCalled = true
154
- return route.handler(context)
155
- }
156
- const callChain: { call: () => any }[] = route.hooks.map((hook, idx) => ({
157
- call: async () => {
158
- let nextCalled = false
159
- let next = async () => {
160
- if (nextCalled) console.error('Hook already called - ignored')
161
- else {
162
- nextCalled = true
163
- return await callChain[idx + 1]!.call()
164
- }
165
- }
166
- let r = await hook(context as Context, next)
167
- if (r) return r
168
- if (!nextCalled && !handlerCalled) return await next()
169
- },
170
- }))
171
- callChain.push({
172
- call: async () => {
173
- response = await handlerWrapper(context as Context)
174
- context.set.status = response instanceof Response ? response.status : context.set.status || 200
175
- },
176
- })
177
- const r = await callChain[0]!.call()
178
- if (r) response = r
179
- if (context.set.status === undefined)
180
- context.set.status = response instanceof Response ? response.status : 200
198
+ // hook/handler chain, composed once at route registration (see galbeMethod)
199
+ response = await route.composed(context as Context)
200
+ if (context.set.status === undefined) context.set.status = response instanceof Response ? response.status : 200
181
201
 
182
202
  const parsedResponse = responseParser(response, context as Context, cookies, schema.response)
183
203
 
184
204
  if (galbe.config?.responseValidator?.enabled !== false && schema.response && !(response instanceof Response))
185
205
  validateResponse(response, schema.response, parsedResponse.status || 200)
186
206
 
207
+ // middleware post-parse slot, composed at registration (see composePost):
208
+ // route-scoped, so it runs inside the app-scoped plugin slot below
209
+ let finalResponse = parsedResponse
210
+ if (route.composedPost) {
211
+ postRan = true
212
+ finalResponse = await route.composedPost(finalResponse, context as Context)
213
+ }
214
+
187
215
  for (const p of pluginsCb.afterHandle) {
188
- //@ts-ignore
189
- const r = await p.afterHandle(parsedResponse, context)
216
+ const r = await p.afterHandle(finalResponse, context as Context)
190
217
  if (r) return r
191
218
  }
192
219
 
193
- return parsedResponse
220
+ // HEAD falls back to the GET route in the router (RFC 9110 §9.3.3):
221
+ // run the normal lifecycle, then strip the body while preserving the
222
+ // status and headers (incl. content-length) the GET would have sent.
223
+ if (req.method === 'HEAD')
224
+ return new Response(null, {
225
+ status: finalResponse.status,
226
+ statusText: finalResponse.statusText,
227
+ headers: finalResponse.headers,
228
+ })
229
+
230
+ return finalResponse
194
231
  } catch (error) {
195
232
  context.set.status = error instanceof RequestError ? error.status : 500
233
+ // Route-scoped post-parse hooks run on the error response too: a 400
234
+ // from validation and a 401 from a `beforeParse` middleware are exactly
235
+ // the responses an access log exists for. One that throws while an error
236
+ // is already being answered must not cost us that answer.
237
+ const post = postRan ? undefined : route?.composedPost
238
+ const respond = async (res: Response) => {
239
+ if (!post) return res
240
+ postRan = true
241
+ try {
242
+ return await post(res, context as Context, error)
243
+ } catch (postError) {
244
+ console.log(postError)
245
+ return res
246
+ }
247
+ }
196
248
  let customError
197
249
  for (let eh of galbe.errorCb) {
198
250
  const result = await eh(error, context as Context)
@@ -200,14 +252,18 @@ export default async (galbe: Galbe, port?: number, hostname?: string) => {
200
252
  customError = responseParser(result, context as Context, cookies)
201
253
  break
202
254
  }
203
- if (customError) return customError
255
+ if (customError) return await respond(customError)
204
256
  if (error instanceof InternalServerError) {
205
257
  let internalPayload = 'Internal Server Error'
206
- try { internalPayload = JSON.stringify(error?.payload || internalPayload) } catch {}
207
- return new Response(internalPayload, {
208
- status: error.status,
209
- headers: { 'content-type': 'application/json' },
210
- })
258
+ try {
259
+ internalPayload = JSON.stringify(error?.payload || internalPayload)
260
+ } catch {}
261
+ return await respond(
262
+ new Response(internalPayload, {
263
+ status: error.status,
264
+ headers: { 'content-type': 'application/json' },
265
+ })
266
+ )
211
267
  } else if (error instanceof RequestError) {
212
268
  let payload = error.payload
213
269
  // append semantics: context.set.headers always carries the
@@ -234,17 +290,21 @@ export default async (galbe: Galbe, port?: number, hostname?: string) => {
234
290
  } catch (err) {}
235
291
  }
236
292
  }
237
- return new Response(payload, {
238
- status: error.status,
239
- headers,
240
- })
293
+ return await respond(
294
+ new Response(payload, {
295
+ status: error.status,
296
+ headers,
297
+ })
298
+ )
241
299
  } else console.log(error)
242
- return new Response('"Internal Server Error"', {
243
- status: 500,
244
- headers: {
245
- 'content-type': 'application/json',
246
- },
247
- })
300
+ return await respond(
301
+ new Response('"Internal Server Error"', {
302
+ status: 500,
303
+ headers: {
304
+ 'content-type': 'application/json',
305
+ },
306
+ })
307
+ )
248
308
  }
249
309
  },
250
310
  error(error) {