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 +6 -9
- package/package.json +2 -3
- package/src/cookies.ts +5 -5
- package/src/extras/spec/openapi.serializer.ts +8 -8
- package/src/index.ts +2 -2
- package/src/parser.ts +91 -49
- package/src/router.ts +40 -11
- package/src/routes.ts +9 -7
- package/src/schema.ts +2 -2
- package/src/server.ts +39 -11
- package/src/types.ts +4 -3
- package/src/util.ts +5 -0
- package/src/validator.ts +8 -0
package/bin/util.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { relative } from 'path'
|
|
2
|
-
import { watch } from '
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
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]
|
|
300
|
-
if (exSingle !== undefined) content[key]
|
|
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]
|
|
323
|
-
if (explicitExample !== undefined) content[mediaType]
|
|
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]
|
|
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?:
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
246
|
+
decodeFormComponent(textDecoder.decode(bK)),
|
|
247
|
+
decodeFormComponent(textDecoder.decode(bV)),
|
|
233
248
|
]
|
|
234
249
|
try {
|
|
235
|
-
const propSchema = schema?.props
|
|
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
|
-
|
|
264
|
-
|
|
278
|
+
decodeFormComponent(textDecoder.decode(bK)),
|
|
279
|
+
decodeFormComponent(textDecoder.decode(rest)),
|
|
265
280
|
]
|
|
266
281
|
try {
|
|
267
|
-
const propSchema = schema?.props
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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 ${
|
|
501
|
+
payload: { body: { [headers.name]: `Expected ${prop[Kind]} found json` } },
|
|
481
502
|
})
|
|
482
503
|
}
|
|
483
504
|
}
|
|
484
|
-
} else
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
|
507
|
-
res[
|
|
508
|
-
else res[
|
|
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[
|
|
534
|
+
delete required[name]
|
|
511
535
|
|
|
512
|
-
if (schema?.props &&
|
|
536
|
+
if (schema?.props && Object.hasOwn(schema.props, name)) {
|
|
513
537
|
try {
|
|
514
|
-
|
|
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
|
-
|
|
542
|
+
entry.content = validate(entry.content, prop, {
|
|
517
543
|
parse: true,
|
|
518
544
|
})
|
|
519
|
-
if (
|
|
520
|
-
for (let [k, v] of Object.entries(
|
|
545
|
+
if (prop[Kind] === 'array')
|
|
546
|
+
for (let [k, v] of Object.entries(entry.content)) {
|
|
521
547
|
try {
|
|
522
548
|
//@ts-ignore
|
|
523
|
-
|
|
549
|
+
entry.content[k] = paramParser(v, prop.items)
|
|
524
550
|
} catch (error) {
|
|
525
|
-
errors[
|
|
551
|
+
errors[name] = name in errors ? [...errors[name], error] : error
|
|
526
552
|
}
|
|
527
553
|
}
|
|
528
554
|
} catch (err) {
|
|
529
|
-
errors[
|
|
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)
|
|
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 =
|
|
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]
|
|
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.
|
|
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]
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
[
|
|
138
|
-
|
|
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]
|
|
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]
|
|
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] } }), {}
|
|
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] } }), {}
|
|
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
|
-
|
|
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]
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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)
|