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/README.md +3 -0
- package/bin/commands/build.ts +30 -19
- package/bin/commands/dev.ts +53 -5
- package/bin/commands/generate/cli/index.ts +4 -1
- package/bin/commands/generate/client.ts +61 -30
- package/bin/commands/generate/code/openapi.parser.ts +440 -163
- package/bin/commands/generate/code/route-merge.ts +26 -21
- package/bin/commands/generate/code.ts +15 -1
- package/bin/commands/generate/model.ts +4 -1
- package/bin/commands/generate/spec.ts +3 -1
- package/bin/res/client.runtime.ts +5 -0
- package/bin/util.ts +36 -90
- package/package.json +34 -9
- package/src/cookies.ts +29 -8
- package/src/extras/spec/openapi.serializer.ts +287 -99
- package/src/extras.ts +1 -1
- package/src/index.ts +378 -73
- package/src/middlewares/_auth.ts +178 -0
- package/src/middlewares/apiKey.ts +139 -0
- package/src/middlewares/basicAuth.ts +151 -0
- package/src/middlewares/bearer.ts +136 -0
- package/src/middlewares/jwt.ts +455 -0
- package/src/middlewares/logger.ts +120 -0
- package/src/middlewares/rateLimit.ts +153 -0
- package/src/middlewares/requestId.ts +94 -0
- package/src/middlewares/timing.ts +86 -0
- package/src/middlewares.ts +53 -0
- package/src/parser.ts +279 -133
- package/src/router.ts +74 -51
- package/src/routes.ts +220 -136
- package/src/schema.ts +123 -31
- package/src/server.ts +130 -70
- package/src/types.ts +368 -92
- package/src/util.ts +271 -5
- package/src/validator.compile.ts +343 -0
- package/src/validator.ts +64 -18
- package/bin/res/client.template.ts +0 -200
- package/scripts/release.ts +0 -196
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
|
-
|
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
[
|
|
462
|
-
MemberProps<T['members'][number]
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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:
|
|
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, '
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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) {
|