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.
@@ -1,10 +1,12 @@
1
1
  import ts from 'typescript'
2
- import type { RoutePlanEntry, ScopePlan } from './openapi.parser'
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 = importPathForScope(scope.scopeKey)
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
- if (er.path !== entry.path) {
203
- edits.push({ pos: er.pathArg.getStart(sf), end: er.pathArg.end, text: JSON.stringify(entry.path) })
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 importPath = importPathForScope(scope.scopeKey)
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((r: Record<string, string>) => ` ${r.column_name}: ${TYPE_MAP?.[r.data_type] ?? 'any'}${r.is_nullable ? ' | null' : ''}`)
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
- //license: TODO
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 { GalbeProxy, type RouteMeta, defineRoutes } from '../src/routes'
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
- export const WATCH_IGNORE = /\.galbe/
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
- const proxy = new GalbeProxy(g, ({ type, route, error, filepath, meta }) => {
71
- if (meta?.ignore || meta?.hide) return
72
- if (!filepath) return
73
- if (!(filepath in routes)) routes[filepath] = []
74
- if (type === 'add' && route && filepath) routes[filepath].push({ route, meta })
75
- if (type === 'error') errors[filepath] = error
76
- })
77
- await defineRoutes({ routes: g?.config?.routes }, proxy)
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.15.6",
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
- ".": "./src/index.ts",
16
- "./schema": "./src/schema.ts",
17
- "./extras": "./src/extras.ts",
18
- "./utils": "./src/util.ts"
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
- "openapi-types": "^12.1.3"
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
- val?.toLowerCase() || 'true'
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
- return (
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
  }