galbe 0.15.6 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +377 -71
- 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 +366 -90
- 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
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
|
-
import
|
|
2
|
+
import { joinPath } from '../../../../src/util'
|
|
3
|
+
import { schemaImportPath, type RoutePlanEntry, type ScopePlan } from './openapi.parser'
|
|
3
4
|
|
|
4
5
|
const METHODS = new Set(['get', 'put', 'patch', 'post', 'delete', 'options', 'head'])
|
|
5
6
|
|
|
6
7
|
export type RouteId = string
|
|
7
8
|
|
|
9
|
+
/** Ids are full paths: the file's prefix (dir-derived or `@prefix`) joined with the literal path. */
|
|
8
10
|
export const routeId = (method: string, path: string): RouteId => `${method.toUpperCase()} ${path}`
|
|
9
11
|
|
|
10
12
|
export type MergeOptions = {
|
|
@@ -39,18 +41,15 @@ type ExistingFile = {
|
|
|
39
41
|
text: string
|
|
40
42
|
sourceFile: ts.SourceFile
|
|
41
43
|
routes: ExistingRoute[]
|
|
44
|
+
/** effective route prefix: `@prefix` header if present, else the scope's dir-derived one */
|
|
45
|
+
prefix: string
|
|
42
46
|
schemaImport: ts.ImportDeclaration | null
|
|
43
47
|
bodyOpenBracePos: number | null
|
|
44
48
|
bodyCloseBracePos: number | null
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
const importPathForScope = (scopeKey: string): string => {
|
|
48
|
-
const deepness = scopeKey.split('/').length - 1
|
|
49
|
-
return `${Array(deepness).fill('../').join('')}schemas${scopeKey}.schema`
|
|
50
|
-
}
|
|
51
|
-
|
|
52
51
|
const renderFreshRouteFile = (scope: ScopePlan): string => {
|
|
53
|
-
const importPath =
|
|
52
|
+
const importPath = schemaImportPath(scope)
|
|
54
53
|
const rDecl = scope.routes.map(r => ` ${r.meta}\ng.${r.call}`)
|
|
55
54
|
return (
|
|
56
55
|
`import { NotImplementedError, type Galbe } from 'galbe'\n` +
|
|
@@ -78,6 +77,7 @@ const parseExistingFile = (text: string, scope: ScopePlan): ExistingFile => {
|
|
|
78
77
|
const expectedSuffix = `schemas${scope.scopeKey}.schema`
|
|
79
78
|
let schemaImport: ts.ImportDeclaration | null = null
|
|
80
79
|
let body: ts.Block | null = null
|
|
80
|
+
let prefix = scope.prefix
|
|
81
81
|
|
|
82
82
|
for (const stmt of sf.statements) {
|
|
83
83
|
if (ts.isImportDeclaration(stmt)) {
|
|
@@ -87,6 +87,10 @@ const parseExistingFile = (text: string, scope: ScopePlan): ExistingFile => {
|
|
|
87
87
|
const expr = stmt.expression
|
|
88
88
|
if (ts.isArrowFunction(expr) && ts.isBlock(expr.body)) body = expr.body
|
|
89
89
|
else if (ts.isFunctionExpression(expr)) body = expr.body
|
|
90
|
+
// a hand-written @prefix header overrides the dir-derived prefix
|
|
91
|
+
const jsdoc = findLeadingJsDoc(sf, stmt)
|
|
92
|
+
const m = jsdoc ? sf.text.slice(jsdoc.pos, jsdoc.end).match(/@prefix\s+(\S+)/) : null
|
|
93
|
+
if (m) prefix = m[1] === '/' ? '' : m[1]!.replace(/\/+$/, '')
|
|
90
94
|
}
|
|
91
95
|
}
|
|
92
96
|
|
|
@@ -110,7 +114,7 @@ const parseExistingFile = (text: string, scope: ScopePlan): ExistingFile => {
|
|
|
110
114
|
if (!ts.isIdentifier(schemaArg)) continue
|
|
111
115
|
|
|
112
116
|
routes.push({
|
|
113
|
-
origId: routeId(method, pathArg.text),
|
|
117
|
+
origId: routeId(method, joinPath(prefix, pathArg.text)),
|
|
114
118
|
method,
|
|
115
119
|
path: pathArg.text,
|
|
116
120
|
stmt,
|
|
@@ -125,6 +129,7 @@ const parseExistingFile = (text: string, scope: ScopePlan): ExistingFile => {
|
|
|
125
129
|
text,
|
|
126
130
|
sourceFile: sf,
|
|
127
131
|
routes,
|
|
132
|
+
prefix,
|
|
128
133
|
schemaImport,
|
|
129
134
|
bodyOpenBracePos: body ? body.getStart(sf) : null,
|
|
130
135
|
bodyCloseBracePos: body ? body.end - 1 : null,
|
|
@@ -141,17 +146,13 @@ const applyEdits = (text: string, edits: Edit[]): string => {
|
|
|
141
146
|
return out
|
|
142
147
|
}
|
|
143
148
|
|
|
144
|
-
export const mergeRouteFile = (
|
|
145
|
-
existing: string | null,
|
|
146
|
-
scope: ScopePlan,
|
|
147
|
-
opts: MergeOptions = {}
|
|
148
|
-
): MergeResult => {
|
|
149
|
+
export const mergeRouteFile = (existing: string | null, scope: ScopePlan, opts: MergeOptions = {}): MergeResult => {
|
|
149
150
|
const removeStale = opts.removeStale ?? false
|
|
150
151
|
const ignore = opts.ignore ?? new Set<RouteId>()
|
|
151
152
|
const rename = opts.rename ?? new Map<RouteId, RouteId>()
|
|
152
153
|
|
|
153
154
|
const planById = new Map<RouteId, RoutePlanEntry>()
|
|
154
|
-
for (const r of scope.routes) planById.set(routeId(r.method, r.path), r)
|
|
155
|
+
for (const r of scope.routes) planById.set(routeId(r.method, joinPath(scope.prefix, r.path)), r)
|
|
155
156
|
|
|
156
157
|
if (existing === null || existing.trim() === '') {
|
|
157
158
|
return {
|
|
@@ -162,6 +163,8 @@ export const mergeRouteFile = (
|
|
|
162
163
|
stale: [],
|
|
163
164
|
}
|
|
164
165
|
}
|
|
166
|
+
// diffing is prefix-aware on both sides: plan ids come from full spec paths,
|
|
167
|
+
// existing ids from the file's effective prefix joined with its literal paths
|
|
165
168
|
|
|
166
169
|
const file = parseExistingFile(existing, scope)
|
|
167
170
|
const sf = file.sourceFile
|
|
@@ -199,8 +202,12 @@ export const mergeRouteFile = (
|
|
|
199
202
|
const edits: Edit[] = []
|
|
200
203
|
|
|
201
204
|
for (const { er, entry } of updates) {
|
|
202
|
-
|
|
203
|
-
|
|
205
|
+
// the emitted literal is relative to the file's effective prefix, which may
|
|
206
|
+
// differ from the scope's when the file declares @prefix
|
|
207
|
+
const full = joinPath(scope.prefix, entry.path)
|
|
208
|
+
const literal = file.prefix && full.startsWith(file.prefix) ? full.slice(file.prefix.length) || '/' : full
|
|
209
|
+
if (er.path !== literal) {
|
|
210
|
+
edits.push({ pos: er.pathArg.getStart(sf), end: er.pathArg.end, text: JSON.stringify(literal) })
|
|
204
211
|
}
|
|
205
212
|
if (er.schemaArg.text !== entry.schemaName) {
|
|
206
213
|
edits.push({ pos: er.schemaArg.getStart(sf), end: er.schemaArg.end, text: entry.schemaName })
|
|
@@ -222,8 +229,7 @@ export const mergeRouteFile = (
|
|
|
222
229
|
if (sortedImports.length === 0) {
|
|
223
230
|
edits.push({ pos: file.schemaImport.pos, end: file.schemaImport.end, text: '' })
|
|
224
231
|
} else {
|
|
225
|
-
const
|
|
226
|
-
const newText = `import { ${sortedImports.join(', ')} } from '${importPath}'`
|
|
232
|
+
const newText = `import { ${sortedImports.join(', ')} } from '${schemaImportPath(scope)}'`
|
|
227
233
|
edits.push({
|
|
228
234
|
pos: file.schemaImport.getStart(sf),
|
|
229
235
|
end: file.schemaImport.end,
|
|
@@ -239,10 +245,9 @@ export const mergeRouteFile = (
|
|
|
239
245
|
|
|
240
246
|
return {
|
|
241
247
|
content: applyEdits(existing, edits),
|
|
242
|
-
added: additions.map(a => routeId(a.method, a.path)),
|
|
243
|
-
updated: updates.map(u => routeId(u.entry.method, u.entry.path)),
|
|
248
|
+
added: additions.map(a => routeId(a.method, joinPath(scope.prefix, a.path))),
|
|
249
|
+
updated: updates.map(u => routeId(u.entry.method, joinPath(scope.prefix, u.entry.path))),
|
|
244
250
|
removed: removals.map(r => r.origId),
|
|
245
251
|
stale: stale.map(s => s.origId),
|
|
246
252
|
}
|
|
247
253
|
}
|
|
248
|
-
|
|
@@ -6,7 +6,7 @@ import { readFile } from 'fs/promises'
|
|
|
6
6
|
import { existsSync } from 'fs'
|
|
7
7
|
|
|
8
8
|
import { CWD, fmtList, fmtVal } from '../../util'
|
|
9
|
-
import { applyPlan, planFromOapi, type GenerationPlan } from './code/openapi.parser'
|
|
9
|
+
import { applyPlan, planFromOapi, type GenerationPlan, type GenerationWarning } from './code/openapi.parser'
|
|
10
10
|
import { mergeRouteFile, type MergeOptions, type RouteId } from './code/route-merge'
|
|
11
11
|
|
|
12
12
|
const srcTargets = ['ts', 'js']
|
|
@@ -25,6 +25,18 @@ const parseRename = (raw: string): [RouteId, RouteId] => {
|
|
|
25
25
|
|
|
26
26
|
type ScopedDiff = { scope: string; id: RouteId }
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Report what the spec declared and the generated sources cannot carry. A
|
|
30
|
+
* dropped construct that nobody is told about is how a silently widened
|
|
31
|
+
* validator reaches production; printing it makes it a decision.
|
|
32
|
+
*/
|
|
33
|
+
const printWarnings = (warnings: GenerationWarning[] = []) => {
|
|
34
|
+
if (!warnings.length) return
|
|
35
|
+
console.log(`\x1b[33mNot carried into the generated sources\x1b[0m (${warnings.length}):`)
|
|
36
|
+
for (const w of warnings) console.log(` \x1b[33m!\x1b[0m ${w.at} \x1b[2m${w.message}\x1b[0m`)
|
|
37
|
+
console.log()
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
const printDiff = (diff: {
|
|
29
41
|
added: ScopedDiff[]
|
|
30
42
|
updated: ScopedDiff[]
|
|
@@ -152,6 +164,7 @@ export default (cmd: Command) => {
|
|
|
152
164
|
|
|
153
165
|
if (dryRun) {
|
|
154
166
|
console.log('\x1b[1;30mDry run — no files will be written.\x1b[0m')
|
|
167
|
+
printWarnings(plan.warnings)
|
|
155
168
|
printDiff(diff)
|
|
156
169
|
return
|
|
157
170
|
}
|
|
@@ -166,6 +179,7 @@ export default (cmd: Command) => {
|
|
|
166
179
|
process.exit(1)
|
|
167
180
|
}
|
|
168
181
|
Bun.write(Bun.stdout, ' : \x1b[1;30m\x1b[32mdone\x1b[0m\n')
|
|
182
|
+
printWarnings(plan.warnings)
|
|
169
183
|
printDiff(diff)
|
|
170
184
|
})
|
|
171
185
|
}
|
|
@@ -133,7 +133,10 @@ export default (cmd: Command) => {
|
|
|
133
133
|
FROM information_schema.columns
|
|
134
134
|
WHERE table_schema = '${schema}' AND table_name = '${tableName}'`)
|
|
135
135
|
types[tableName] = `type ${toPascalCase(tableName)} = {\n${t
|
|
136
|
-
.map(
|
|
136
|
+
.map(
|
|
137
|
+
(r: Record<string, string>) =>
|
|
138
|
+
` ${r.column_name}: ${TYPE_MAP?.[r.data_type] ?? 'any'}${r.is_nullable ? ' | null' : ''}`
|
|
139
|
+
)
|
|
137
140
|
.join(';\n')}\n}`
|
|
138
141
|
}
|
|
139
142
|
|
|
@@ -76,14 +76,16 @@ export default (cmd: Command) => {
|
|
|
76
76
|
|
|
77
77
|
if (tName === 'openapi') {
|
|
78
78
|
let openapiSpec = await OpenAPISerializer(g)
|
|
79
|
+
// Precedence: explicit `config.openapi.info` > package.json inference > serializer defaults.
|
|
79
80
|
openapiSpec = {
|
|
80
81
|
...openapiSpec,
|
|
81
82
|
info: {
|
|
82
83
|
title: pckg?.name || 'Galbe app',
|
|
83
84
|
description: pckg?.description,
|
|
84
85
|
contact: parseAuthor(pckg.author),
|
|
85
|
-
|
|
86
|
+
license: pckg?.license ? { name: pckg.license } : undefined,
|
|
86
87
|
version: pckg?.version || '0.1.0',
|
|
88
|
+
...g.config?.openapi?.info,
|
|
87
89
|
},
|
|
88
90
|
}
|
|
89
91
|
openapiSpec = softMerge(openapiSpec, baseSpec) as OpenAPIV3.Document
|
|
@@ -34,6 +34,11 @@ const _buildUrl = (base: string | undefined, path: string, query?: Record<string
|
|
|
34
34
|
for (const [k, v] of Object.entries(query)) {
|
|
35
35
|
if (v === undefined || v === null) continue
|
|
36
36
|
if (Array.isArray(v)) for (const item of v) params.append(k, String(item))
|
|
37
|
+
// an object parameter is serialized as deepObject — `?filter[lat]=1`
|
|
38
|
+
else if (typeof v === 'object')
|
|
39
|
+
for (const [pk, pv] of Object.entries(v as Record<string, any>)) {
|
|
40
|
+
if (pv !== undefined && pv !== null) params.append(`${k}[${pk}]`, String(pv))
|
|
41
|
+
}
|
|
37
42
|
else params.set(k, String(v))
|
|
38
43
|
}
|
|
39
44
|
const qs = params.toString()
|
package/bin/util.ts
CHANGED
|
@@ -2,12 +2,18 @@ import { relative } from 'path'
|
|
|
2
2
|
import { watch } from 'fs'
|
|
3
3
|
import { Galbe, type Route } from '../src'
|
|
4
4
|
import { logRoute, walkRoutes } from '../src/util'
|
|
5
|
-
import {
|
|
5
|
+
import { type RouteMeta, defineRoutes, NEVER_SCANNED_DIRS } from '../src/routes'
|
|
6
6
|
|
|
7
7
|
export { default as pckg } from '../package.json'
|
|
8
8
|
|
|
9
9
|
export const CWD = process.cwd()
|
|
10
|
-
|
|
10
|
+
// What the analyzer refuses to load, the watcher refuses to watch: reloading on
|
|
11
|
+
// a `bun install` or a `git checkout` only ever costs a respawn. `.galbe` is a
|
|
12
|
+
// legacy build directory, kept for apps that still carry one. Always applied —
|
|
13
|
+
// `--watchignore` adds to this list, it does not replace it.
|
|
14
|
+
export const WATCH_IGNORE = new RegExp(
|
|
15
|
+
`(^|[\\\\/])(${[...NEVER_SCANNED_DIRS, '.galbe'].map(d => d.replaceAll('.', '\\.')).join('|')})([\\\\/]|$)`
|
|
16
|
+
)
|
|
11
17
|
|
|
12
18
|
export const fmtVal = (v: any) => {
|
|
13
19
|
if (typeof v === 'boolean') return `\x1b[3${v ? '2' : '1'}m${v}\x1b[0m`
|
|
@@ -39,6 +45,12 @@ export const silentExec = async (fn: () => any) => {
|
|
|
39
45
|
process.stderr.write = _processStderrWrite
|
|
40
46
|
return r
|
|
41
47
|
}
|
|
48
|
+
export type ReloadStrategy = 'registry' | 'respawn'
|
|
49
|
+
// `Loader.registry` is a low-level JavaScriptCore API that current Bun versions (1.3.x)
|
|
50
|
+
// do not expose at runtime; when absent, watch-mode reload must respawn the app process.
|
|
51
|
+
export const resolveReloadStrategy = (scope: any = globalThis): ReloadStrategy =>
|
|
52
|
+
typeof scope?.Loader?.registry?.clear === 'function' ? 'registry' : 'respawn'
|
|
53
|
+
|
|
42
54
|
export const watchDir = async (
|
|
43
55
|
path: string,
|
|
44
56
|
callback: (event: {
|
|
@@ -67,14 +79,28 @@ export const instanciateRoutes = async (g: Galbe) => {
|
|
|
67
79
|
let routes: Record<string, { route?: Route; meta?: RouteMeta; error?: any }[]> = {}
|
|
68
80
|
let errors: Record<string, any> = {}
|
|
69
81
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
// static registrations emit one event per served file: collapse them to one
|
|
83
|
+
// log line per `static(path, target)` call
|
|
84
|
+
const seenStaticRoots = new Set<string>()
|
|
85
|
+
await defineRoutes(
|
|
86
|
+
{ routes: g?.config?.routes, middleware: g?.config?.middleware },
|
|
87
|
+
g,
|
|
88
|
+
({ type, route, error, filepath, meta }) => {
|
|
89
|
+
if (meta?.ignore || meta?.hide) return
|
|
90
|
+
if (!filepath) return
|
|
91
|
+
if (!(filepath in routes)) routes[filepath] = []
|
|
92
|
+
if (type === 'add' && route && filepath) {
|
|
93
|
+
const root = route.static?.root
|
|
94
|
+
if (root) {
|
|
95
|
+
if (seenStaticRoots.has(`${filepath}:${root}`)) return
|
|
96
|
+
seenStaticRoots.add(`${filepath}:${root}`)
|
|
97
|
+
const target = g.staticTargets.find(t => t.path === root)?.target ?? route.static!.path
|
|
98
|
+
routes[filepath].push({ route: { ...route, path: root, static: { path: target, root } }, meta })
|
|
99
|
+
} else routes[filepath].push({ route, meta })
|
|
100
|
+
}
|
|
101
|
+
if (type === 'error') errors[filepath] = error
|
|
102
|
+
}
|
|
103
|
+
)
|
|
78
104
|
for (let [fp, e] of Object.entries(routes)) {
|
|
79
105
|
console.log(`\x1b\[0;36m ${relative(CWD, fp)}\x1b[0m`)
|
|
80
106
|
let maxPathLength = e.reduce((p, c) => {
|
|
@@ -112,86 +138,6 @@ export function abbreviateVar(input: string): string {
|
|
|
112
138
|
.toLowerCase()
|
|
113
139
|
}
|
|
114
140
|
|
|
115
|
-
export const killPort = async (port: number) => {
|
|
116
|
-
let getProcCmd: string[], killCmd: (port: string) => string[]
|
|
117
|
-
|
|
118
|
-
if (process.platform === 'win32') {
|
|
119
|
-
getProcCmd = ['cmd', '-c', `netstat -aon | findstr ${port}`]
|
|
120
|
-
killCmd = (pid: string) => ['taskkill', '/pid', pid, '/f']
|
|
121
|
-
} else {
|
|
122
|
-
getProcCmd = ['lsof', '-t', `-i:${port}`, '-sTCP:LISTEN']
|
|
123
|
-
killCmd = (pid: string) => ['kill', '-9', pid]
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
let proc = Bun.spawn(getProcCmd, { stdout: 'pipe' })
|
|
127
|
-
const pid = (await new Response(proc.stdout).text()).trim()
|
|
128
|
-
if (pid) {
|
|
129
|
-
proc = Bun.spawn(killCmd(pid), { stdout: 'pipe' })
|
|
130
|
-
await proc.exited
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export const HttpStatus = {
|
|
135
|
-
100: 'Continue',
|
|
136
|
-
101: 'Switching Protocols',
|
|
137
|
-
102: 'Processing',
|
|
138
|
-
103: 'Early Hints',
|
|
139
|
-
200: 'OK',
|
|
140
|
-
201: 'Created',
|
|
141
|
-
202: 'Accepted',
|
|
142
|
-
203: 'Non Authoritative Information',
|
|
143
|
-
204: 'No Content',
|
|
144
|
-
205: 'Reset Content',
|
|
145
|
-
206: 'Partial Content',
|
|
146
|
-
207: 'Multi-Status',
|
|
147
|
-
300: 'Multiple Choices',
|
|
148
|
-
301: 'Moved Permanently',
|
|
149
|
-
302: 'Moved Temporarily',
|
|
150
|
-
303: 'See Other',
|
|
151
|
-
304: 'Not Modified',
|
|
152
|
-
305: 'Use Proxy',
|
|
153
|
-
307: 'Temporary Redirect',
|
|
154
|
-
308: 'Permanent Redirect',
|
|
155
|
-
400: 'Bad Request',
|
|
156
|
-
401: 'Unauthorized',
|
|
157
|
-
402: 'Payment Required',
|
|
158
|
-
403: 'Forbidden',
|
|
159
|
-
404: 'Not Found',
|
|
160
|
-
405: 'Method Not Allowed',
|
|
161
|
-
406: 'Not Acceptable',
|
|
162
|
-
407: 'Proxy Authentication Required',
|
|
163
|
-
408: 'Request Timeout',
|
|
164
|
-
409: 'Conflict',
|
|
165
|
-
410: 'Gone',
|
|
166
|
-
411: 'Length Required',
|
|
167
|
-
412: 'Precondition Failed',
|
|
168
|
-
413: 'Request Entity Too Large',
|
|
169
|
-
414: 'Request-URI Too Long',
|
|
170
|
-
415: 'Unsupported Media Type',
|
|
171
|
-
416: 'Requested Range Not Satisfiable',
|
|
172
|
-
417: 'Expectation Failed',
|
|
173
|
-
418: "I'm a teapot",
|
|
174
|
-
419: 'Insufficient Space on Resource',
|
|
175
|
-
420: 'Method Failure',
|
|
176
|
-
421: 'Misdirected Request',
|
|
177
|
-
422: 'Unprocessable Entity',
|
|
178
|
-
423: 'Locked',
|
|
179
|
-
424: 'Failed Dependency',
|
|
180
|
-
426: 'Upgrade Required',
|
|
181
|
-
428: 'Precondition Required',
|
|
182
|
-
429: 'Too Many Requests',
|
|
183
|
-
431: 'Request Header Fields Too Large',
|
|
184
|
-
451: 'Unavailable For Legal Reasons',
|
|
185
|
-
500: 'Internal Server Error',
|
|
186
|
-
501: 'Not Implemented',
|
|
187
|
-
502: 'Bad Gateway',
|
|
188
|
-
503: 'Service Unavailable',
|
|
189
|
-
504: 'Gateway Timeout',
|
|
190
|
-
505: 'HTTP Version Not Supported',
|
|
191
|
-
507: 'Insufficient Storage',
|
|
192
|
-
511: 'Network Authentication Required',
|
|
193
|
-
}
|
|
194
|
-
|
|
195
141
|
export const toPascalCase = (input: string) =>
|
|
196
142
|
input
|
|
197
143
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
package/package.json
CHANGED
|
@@ -1,21 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "galbe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
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",
|
|
7
7
|
"bin": "./bin/cli.ts",
|
|
8
8
|
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
9
10
|
"files": [
|
|
10
11
|
"bin/",
|
|
11
|
-
"scripts/",
|
|
12
12
|
"src/"
|
|
13
13
|
],
|
|
14
14
|
"exports": {
|
|
15
|
-
".":
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./src/index.ts",
|
|
17
|
+
"default": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"./schema": {
|
|
20
|
+
"types": "./src/schema.ts",
|
|
21
|
+
"default": "./src/schema.ts"
|
|
22
|
+
},
|
|
23
|
+
"./middlewares": {
|
|
24
|
+
"types": "./src/middlewares.ts",
|
|
25
|
+
"default": "./src/middlewares.ts"
|
|
26
|
+
},
|
|
27
|
+
"./middlewares/*": {
|
|
28
|
+
"types": "./src/middlewares/*.ts",
|
|
29
|
+
"default": "./src/middlewares/*.ts"
|
|
30
|
+
},
|
|
31
|
+
"./extras": {
|
|
32
|
+
"types": "./src/extras.ts",
|
|
33
|
+
"default": "./src/extras.ts"
|
|
34
|
+
},
|
|
35
|
+
"./utils": {
|
|
36
|
+
"types": "./src/util.ts",
|
|
37
|
+
"default": "./src/util.ts"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"bun": ">=1.2.21"
|
|
19
42
|
},
|
|
20
43
|
"repository": {
|
|
21
44
|
"type": "git",
|
|
@@ -35,20 +58,22 @@
|
|
|
35
58
|
"scripts": {
|
|
36
59
|
"test": "bun test",
|
|
37
60
|
"typecheck": "tsc --noEmit --emitDeclarationOnly false -p tsconfig.json && tsc --noEmit --emitDeclarationOnly false -p tsconfig.test.json",
|
|
61
|
+
"format": "prettier --write .",
|
|
62
|
+
"format:check": "prettier --check .",
|
|
38
63
|
"release": "bun run scripts/release.ts"
|
|
39
64
|
},
|
|
40
65
|
"devDependencies": {
|
|
41
66
|
"@types/bun": "latest",
|
|
42
|
-
"
|
|
67
|
+
"prettier": "3.9.6"
|
|
43
68
|
},
|
|
44
69
|
"peerDependencies": {
|
|
45
70
|
"typescript": "^5.0.0"
|
|
46
71
|
},
|
|
47
72
|
"dependencies": {
|
|
48
|
-
"@swc/core": "^1.3.107",
|
|
49
73
|
"@swc/wasm": "^1.4.0",
|
|
50
74
|
"acorn": "^8.11.2",
|
|
51
75
|
"acorn-walk": "^8.3.0",
|
|
52
|
-
"commander": "^11.1.0"
|
|
76
|
+
"commander": "^11.1.0",
|
|
77
|
+
"openapi-types": "^12.1.3"
|
|
53
78
|
}
|
|
54
79
|
}
|
package/src/cookies.ts
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
import { BadRequestError } from './types'
|
|
2
|
+
|
|
3
|
+
// encodeURIComponent throws on lone surrogates; surface that as a controlled 400
|
|
4
|
+
const encode = (str: string, part: string) => {
|
|
5
|
+
try {
|
|
6
|
+
return encodeURIComponent(str)
|
|
7
|
+
} catch {
|
|
8
|
+
throw new BadRequestError(`Invalid cookie ${part}`)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
// malformed %XX sequences fall back to the raw string instead of throwing
|
|
12
|
+
const decode = (str: string) => {
|
|
13
|
+
try {
|
|
14
|
+
return decodeURIComponent(str)
|
|
15
|
+
} catch {
|
|
16
|
+
return str
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
1
20
|
export type CookieOptions = {
|
|
2
21
|
path?: string
|
|
3
22
|
maxAge?: number
|
|
@@ -18,8 +37,8 @@ export const parseCookie = (str: string) => {
|
|
|
18
37
|
for (const [idx, entry] of entries.entries()) {
|
|
19
38
|
const [_, key, val] = [...(entry.match(/([^=]+)(?:=(.*))?/) || [])]
|
|
20
39
|
if (idx === 0) {
|
|
21
|
-
cookie.name = key ?? ''
|
|
22
|
-
cookie.value = val ?? ''
|
|
40
|
+
cookie.name = decode(key ?? '')
|
|
41
|
+
cookie.value = decode(val ?? '')
|
|
23
42
|
} else {
|
|
24
43
|
switch (key) {
|
|
25
44
|
case 'Path':
|
|
@@ -34,7 +53,7 @@ export const parseCookie = (str: string) => {
|
|
|
34
53
|
case 'SameSite':
|
|
35
54
|
cookie.sameSite =
|
|
36
55
|
({ true: true, false: false, lax: 'lax', strict: 'strict', none: 'none' } as const)[
|
|
37
|
-
|
|
56
|
+
val?.toLowerCase() || 'true'
|
|
38
57
|
] || true
|
|
39
58
|
break
|
|
40
59
|
case 'Secure':
|
|
@@ -59,16 +78,18 @@ export const stringifyCookie = (name: string, value: string, opt: CookieOptions
|
|
|
59
78
|
: opt.sameSite === true
|
|
60
79
|
? ' SameSite=Lax;'
|
|
61
80
|
: ''
|
|
62
|
-
|
|
63
|
-
`${name}=${value};` +
|
|
81
|
+
const cookie =
|
|
82
|
+
`${encode(name, 'name')}=${encode(value, 'value')};` +
|
|
64
83
|
` path=${opt.path || '/'};` +
|
|
65
|
-
(opt.domain ? ` Domain=${opt.domain};` : '') +
|
|
84
|
+
(opt.domain ? ` Domain=${encode(opt.domain, 'Domain')};` : '') +
|
|
66
85
|
(typeof opt.maxAge === 'number' ? ` Max-Age=${Math.floor(opt.maxAge)};` : '') +
|
|
67
86
|
(opt.expires ? ` Expires=${opt.expires.toUTCString()};` : '') +
|
|
68
87
|
(opt.secure ? ' Secure;' : '') +
|
|
69
88
|
sameSite +
|
|
70
89
|
(opt.httpOnly ? ' HttpOnly;' : '')
|
|
71
|
-
|
|
90
|
+
// path is the only free-form part left unencoded; block header injection through it
|
|
91
|
+
if (/[\x00-\x1f\x7f]/.test(cookie)) throw new BadRequestError('Invalid cookie: control characters not allowed')
|
|
92
|
+
return cookie
|
|
72
93
|
}
|
|
73
94
|
|
|
74
95
|
function capitalize(str?: string) {
|
|
@@ -81,7 +102,7 @@ export const readCookies = (cookies?: string | null) => {
|
|
|
81
102
|
return Object.fromEntries(
|
|
82
103
|
cookies.split(';').map(c => {
|
|
83
104
|
const [name, ...value] = c.split('=')
|
|
84
|
-
return [(name ?? '').trim(), value.join('=').trim()]
|
|
105
|
+
return [decode((name ?? '').trim()), decode(value.join('=').trim())]
|
|
85
106
|
})
|
|
86
107
|
)
|
|
87
108
|
}
|