@getsoda/core 0.1.1 → 0.2.1

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.
Files changed (120) hide show
  1. package/README.md +58 -0
  2. package/dist/app/app.d.ts +17 -1
  3. package/dist/app/app.d.ts.map +1 -1
  4. package/dist/app/app.js +46 -10
  5. package/dist/app/app.js.map +1 -1
  6. package/dist/di/container.d.ts +7 -0
  7. package/dist/di/container.d.ts.map +1 -1
  8. package/dist/di/container.js +24 -1
  9. package/dist/di/container.js.map +1 -1
  10. package/dist/di/graph.d.ts +2 -2
  11. package/dist/di/graph.d.ts.map +1 -1
  12. package/dist/di/graph.js +20 -10
  13. package/dist/di/graph.js.map +1 -1
  14. package/dist/http/arguments.d.ts +20 -2
  15. package/dist/http/arguments.d.ts.map +1 -1
  16. package/dist/http/arguments.js +29 -0
  17. package/dist/http/arguments.js.map +1 -1
  18. package/dist/http/body.d.ts +28 -0
  19. package/dist/http/body.d.ts.map +1 -1
  20. package/dist/http/body.js +96 -1
  21. package/dist/http/body.js.map +1 -1
  22. package/dist/http/context-key.d.ts +33 -0
  23. package/dist/http/context-key.d.ts.map +1 -0
  24. package/dist/http/context-key.js +37 -0
  25. package/dist/http/context-key.js.map +1 -0
  26. package/dist/http/context.d.ts +37 -3
  27. package/dist/http/context.d.ts.map +1 -1
  28. package/dist/http/context.js +63 -5
  29. package/dist/http/context.js.map +1 -1
  30. package/dist/http/cookies.d.ts +49 -0
  31. package/dist/http/cookies.d.ts.map +1 -0
  32. package/dist/http/cookies.js +77 -0
  33. package/dist/http/cookies.js.map +1 -0
  34. package/dist/http/cors.d.ts +41 -0
  35. package/dist/http/cors.d.ts.map +1 -0
  36. package/dist/http/cors.js +97 -0
  37. package/dist/http/cors.js.map +1 -0
  38. package/dist/http/index.d.ts +8 -2
  39. package/dist/http/index.d.ts.map +1 -1
  40. package/dist/http/index.js +4 -1
  41. package/dist/http/index.js.map +1 -1
  42. package/dist/http/middleware.d.ts +56 -0
  43. package/dist/http/middleware.d.ts.map +1 -0
  44. package/dist/http/middleware.js +83 -0
  45. package/dist/http/middleware.js.map +1 -0
  46. package/dist/http/parsers.d.ts +3 -0
  47. package/dist/http/parsers.d.ts.map +1 -1
  48. package/dist/http/parsers.js +2 -1
  49. package/dist/http/parsers.js.map +1 -1
  50. package/dist/http/routes.d.ts +10 -1
  51. package/dist/http/routes.d.ts.map +1 -1
  52. package/dist/http/routes.js +4 -1
  53. package/dist/http/routes.js.map +1 -1
  54. package/dist/index.d.ts +1 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/jwt/algorithms.d.ts +19 -0
  59. package/dist/jwt/algorithms.d.ts.map +1 -0
  60. package/dist/jwt/algorithms.js +60 -0
  61. package/dist/jwt/algorithms.js.map +1 -0
  62. package/dist/jwt/errors.d.ts +24 -0
  63. package/dist/jwt/errors.d.ts.map +1 -0
  64. package/dist/jwt/errors.js +14 -0
  65. package/dist/jwt/errors.js.map +1 -0
  66. package/dist/jwt/guard.d.ts +30 -0
  67. package/dist/jwt/guard.d.ts.map +1 -0
  68. package/dist/jwt/guard.js +61 -0
  69. package/dist/jwt/guard.js.map +1 -0
  70. package/dist/jwt/index.d.ts +14 -0
  71. package/dist/jwt/index.d.ts.map +1 -0
  72. package/dist/jwt/index.js +10 -0
  73. package/dist/jwt/index.js.map +1 -0
  74. package/dist/jwt/module.d.ts +36 -0
  75. package/dist/jwt/module.d.ts.map +1 -0
  76. package/dist/jwt/module.js +38 -0
  77. package/dist/jwt/module.js.map +1 -0
  78. package/dist/jwt/service.d.ts +66 -0
  79. package/dist/jwt/service.d.ts.map +1 -0
  80. package/dist/jwt/service.js +138 -0
  81. package/dist/jwt/service.js.map +1 -0
  82. package/dist/logger/console-logger.d.ts +36 -0
  83. package/dist/logger/console-logger.d.ts.map +1 -0
  84. package/dist/logger/console-logger.js +117 -0
  85. package/dist/logger/console-logger.js.map +1 -0
  86. package/dist/logger/index.d.ts +9 -0
  87. package/dist/logger/index.d.ts.map +1 -0
  88. package/dist/logger/index.js +7 -0
  89. package/dist/logger/index.js.map +1 -0
  90. package/dist/logger/logger.d.ts +55 -0
  91. package/dist/logger/logger.d.ts.map +1 -0
  92. package/dist/logger/logger.js +66 -0
  93. package/dist/logger/logger.js.map +1 -0
  94. package/dist/routing/types.d.ts +1 -1
  95. package/dist/routing/types.d.ts.map +1 -1
  96. package/package.json +6 -12
  97. package/src/app/app.ts +77 -13
  98. package/src/di/container.ts +38 -2
  99. package/src/di/graph.ts +26 -11
  100. package/src/http/arguments.ts +42 -2
  101. package/src/http/body.ts +142 -1
  102. package/src/http/context-key.ts +35 -0
  103. package/src/http/context.ts +94 -8
  104. package/src/http/cookies.ts +96 -0
  105. package/src/http/cors.ts +143 -0
  106. package/src/http/index.ts +8 -2
  107. package/src/http/middleware.ts +124 -0
  108. package/src/http/parsers.ts +2 -1
  109. package/src/http/routes.ts +17 -2
  110. package/src/index.ts +1 -0
  111. package/src/jwt/algorithms.ts +113 -0
  112. package/src/jwt/errors.ts +30 -0
  113. package/src/jwt/guard.ts +74 -0
  114. package/src/jwt/index.ts +14 -0
  115. package/src/jwt/module.ts +58 -0
  116. package/src/jwt/service.ts +216 -0
  117. package/src/logger/console-logger.ts +163 -0
  118. package/src/logger/index.ts +9 -0
  119. package/src/logger/logger.ts +96 -0
  120. package/src/routing/types.ts +1 -1
@@ -16,18 +16,21 @@
16
16
  * called by the framework before the method instead of by the method itself.
17
17
  */
18
18
 
19
+ import type { FormFields, FormLimits } from './body.ts'
20
+ import type { ContextKey } from './context-key.ts'
19
21
  import type { Context, ParamNameOf } from './context.ts'
20
22
  import type { Parsed, ValueParser } from './parsers.ts'
23
+ import { isStandardSchema } from './schema.ts'
21
24
  import type { InferOutput, StandardSchemaV1 } from './schema.ts'
22
25
 
23
- export type ArgumentKind = 'param' | 'query' | 'header' | 'body' | 'context'
26
+ export type ArgumentKind = 'param' | 'query' | 'header' | 'cookie' | 'body' | 'context' | 'value'
24
27
 
25
28
  /**
26
29
  * One argument of a handler: where it comes from and how it is read.
27
30
  */
28
31
  export interface Argument<T, K extends ArgumentKind = ArgumentKind, N extends string = string> {
29
32
  readonly kind: K
30
- /** Parameter, query or header name; empty for `body` and `context`. */
33
+ /** Parameter, query, header or cookie name; the key description for `value`; empty for `body` and `context`. */
31
34
  readonly name: N
32
35
  readonly read: (ctx: Context) => T | Promise<T>
33
36
  }
@@ -78,6 +81,17 @@ export function header(name: string, parser?: ValueParser<unknown>): Argument<un
78
81
  }
79
82
  }
80
83
 
84
+ /** Cookie. Like `@Cookies('session')` with `cookie-parser`; `signed(signer)` checks a signed one. */
85
+ export function cookie(name: string): Argument<string | undefined, 'cookie'>
86
+ export function cookie<V extends ValueParser<unknown>>(name: string, parser: V): Argument<Parsed<V>, 'cookie'>
87
+ export function cookie(name: string, parser?: ValueParser<unknown>): Argument<unknown, 'cookie'> {
88
+ return {
89
+ kind: 'cookie',
90
+ name,
91
+ read: parser === undefined ? (ctx) => ctx.cookie(name) : (ctx) => ctx.cookie(name, parser),
92
+ }
93
+ }
94
+
81
95
  /** JSON body, validated by `schema`. Like `@Body() dto: CreateUserDto` with a validation pipe. */
82
96
  export function body(): Argument<unknown, 'body'>
83
97
  export function body<S extends StandardSchemaV1>(schema: S): Argument<InferOutput<S>, 'body'>
@@ -91,3 +105,29 @@ export function body(schema?: StandardSchemaV1): Argument<unknown, 'body'> {
91
105
 
92
106
  /** The context itself, for what no other argument covers. Like `@Req()`. */
93
107
  export const context = (): Argument<Context, 'context'> => ({ kind: 'context', name: '', read: (ctx) => ctx })
108
+
109
+ /**
110
+ * Value a guard or an interceptor attached to the request. Like a custom Nest
111
+ * parameter decorator such as `@User()`.
112
+ *
113
+ * ```ts
114
+ * @Get('me', [value(USER)])
115
+ * me(user: User) {}
116
+ * ```
117
+ */
118
+ export const value = <T>(key: ContextKey<T>): Argument<T, 'value'> => ({
119
+ kind: 'value',
120
+ name: key.description,
121
+ read: (ctx) => ctx.get(key),
122
+ })
123
+
124
+ /** Form body, validated by `schema` when given, within `limits`. Like `@Body()` on a form, or Nest's file interceptors with multer limits. */
125
+ export function form(limits?: FormLimits): Argument<FormFields, 'body'>
126
+ export function form<S extends StandardSchemaV1>(schema: S, limits?: FormLimits): Argument<InferOutput<S>, 'body'>
127
+ export function form(schemaOrLimits?: StandardSchemaV1 | FormLimits, limits?: FormLimits): Argument<unknown, 'body'> {
128
+ return {
129
+ kind: 'body',
130
+ name: '',
131
+ read: isStandardSchema(schemaOrLimits) ? (ctx) => ctx.form(schemaOrLimits, limits) : (ctx) => ctx.form(schemaOrLimits),
132
+ }
133
+ }
package/src/http/body.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * (`"constructor": "Ford"`) is legitimate and kept.
16
16
  */
17
17
 
18
- import { BadRequestException, UnsupportedMediaTypeException } from './exceptions.ts'
18
+ import { BadRequestException, PayloadTooLargeException, UnsupportedMediaTypeException } from './exceptions.ts'
19
19
 
20
20
  const SUSPECT_PROTO =
21
21
  /"(?:_|\\u005[Ff])(?:_|\\u005[Ff])(?:p|\\u0070)(?:r|\\u0072)(?:o|\\u006[Ff])(?:t|\\u0074)(?:o|\\u006[Ff])(?:_|\\u005[Ff])(?:_|\\u005[Ff])"\s*:/
@@ -79,3 +79,144 @@ export const readJsonBody = async (req: Request): Promise<unknown> => {
79
79
 
80
80
  return parseJson(text)
81
81
  }
82
+
83
+ const FORM_TYPE = /^(?:multipart\/form-data|application\/x-www-form-urlencoded)(?:\s*;|$)/i
84
+ const ARRAY_SUFFIX = '[]'
85
+
86
+ /** Parsed form data, as `Request.formData()` gives it. */
87
+ type FormBody = Awaited<ReturnType<Request['formData']>>
88
+
89
+ /** A form value: text, or a file of a multipart body. */
90
+ export type FormValue = string | File
91
+
92
+ /** Fields of a form; repeated fields and fields named `name[]` are arrays. */
93
+ export type FormFields = Record<string, FormValue | FormValue[]>
94
+
95
+ /**
96
+ * Turns form data into an object without prototype.
97
+ *
98
+ * ```ts
99
+ * 'a=1&a=2' => { a: ['1', '2'] } // repeated fields are kept, not overwritten
100
+ * 'tags[]=x' => { tags: ['x'] } // `[]` always gives an array, even with one value
101
+ * 'name=Ann' => { name: 'Ann' }
102
+ * ```
103
+ */
104
+ const toFields = (data: FormBody): FormFields => {
105
+ const fields: FormFields = Object.create(null)
106
+
107
+ for (const [rawKey, value] of data) {
108
+ const array = rawKey.endsWith(ARRAY_SUFFIX)
109
+ const key = array ? rawKey.slice(0, -ARRAY_SUFFIX.length) : rawKey
110
+ const current = fields[key]
111
+
112
+ if (current === undefined) {
113
+ fields[key] = array ? [value] : value
114
+ } else if (Array.isArray(current)) {
115
+ current.push(value)
116
+ } else {
117
+ fields[key] = [current, value]
118
+ }
119
+ }
120
+
121
+ return fields
122
+ }
123
+
124
+ /**
125
+ * Limits of a form body, beyond the server's `maxRequestBodySize`; each one
126
+ * exceeded is a `413`. Like the `limits` of multer and `@fastify/multipart`.
127
+ */
128
+ export interface FormLimits {
129
+ /** Bytes of the whole body, refused as soon as they are exceeded, before the body is read entirely. */
130
+ readonly maxBodySize?: number
131
+ /** Bytes of each file. */
132
+ readonly maxFileSize?: number
133
+ /** Number of files. */
134
+ readonly maxFiles?: number
135
+ /** Number of fields, files included. */
136
+ readonly maxFields?: number
137
+ }
138
+
139
+ /** The body, read up to `max` bytes: `413` as soon as it is larger. */
140
+ const readUpTo = async (req: Request, max: number): Promise<Uint8Array<ArrayBuffer>> => {
141
+ if (Number(req.headers.get('content-length') ?? 0) > max) {
142
+ throw new PayloadTooLargeException(`Request body is larger than ${max} bytes`)
143
+ }
144
+
145
+ if (req.body === null) {
146
+ return new Uint8Array()
147
+ }
148
+
149
+ const chunks: Uint8Array[] = []
150
+ let size = 0
151
+
152
+ for await (const chunk of req.body) {
153
+ size += chunk.byteLength
154
+
155
+ if (size > max) {
156
+ throw new PayloadTooLargeException(`Request body is larger than ${max} bytes`)
157
+ }
158
+
159
+ chunks.push(chunk)
160
+ }
161
+
162
+ return Buffer.concat(chunks)
163
+ }
164
+
165
+ /** `413` for the first limit the form exceeds. */
166
+ const checkLimits = (data: FormBody, limits: FormLimits): void => {
167
+ const { maxFileSize = Infinity, maxFiles = Infinity, maxFields = Infinity } = limits
168
+ let fields = 0
169
+ let files = 0
170
+
171
+ for (const [name, value] of data) {
172
+ fields++
173
+
174
+ if (fields > maxFields) {
175
+ throw new PayloadTooLargeException(`Form has more than ${maxFields} fields`)
176
+ }
177
+
178
+ if (typeof value !== 'string') {
179
+ files++
180
+
181
+ if (files > maxFiles) {
182
+ throw new PayloadTooLargeException(`Form has more than ${maxFiles} files`)
183
+ }
184
+
185
+ if (value.size > maxFileSize) {
186
+ throw new PayloadTooLargeException(`File "${name}" is larger than ${maxFileSize} bytes`)
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Reads a `multipart/form-data` or `application/x-www-form-urlencoded` body:
194
+ * `415` for another content type, `400` for a malformed body, `413` past a limit.
195
+ *
196
+ * Kept apart from JSON on purpose: an HTML form on another site can post a form
197
+ * without a CORS preflight, a JSON body cannot. A JSON endpoint must not accept forms.
198
+ *
199
+ * `File.type` is the type the client declared, not checked against the content.
200
+ */
201
+ export const readFormBody = async (req: Request, limits: FormLimits = {}): Promise<FormFields> => {
202
+ const type = req.headers.get('content-type')
203
+
204
+ if (type === null || !FORM_TYPE.test(type)) {
205
+ throw new UnsupportedMediaTypeException(`Expected a form body, got ${type ?? 'no content type'}`)
206
+ }
207
+
208
+ // Parsed from the bytes read so far, as only a whole body can be parsed.
209
+ const source =
210
+ limits.maxBodySize === undefined ? req : new Response(await readUpTo(req, limits.maxBodySize), { headers: { 'content-type': type } })
211
+ let data: FormBody
212
+
213
+ try {
214
+ data = await source.formData()
215
+ } catch (error) {
216
+ throw new BadRequestException('Request body is not a valid form', { cause: error })
217
+ }
218
+
219
+ checkLimits(data, limits)
220
+
221
+ return toFields(data)
222
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @module
3
+ * Typed keys for values attached to a request: what a guard found (the user,
4
+ * the tenant), read later by interceptors and the handler.
5
+ *
6
+ * Like Angular's `HttpContextToken`: the key is an object, so two keys can
7
+ * never collide, and it carries the value type. Unlike it, a default is
8
+ * optional: reading a key nobody set and that has no default is a programming
9
+ * error (a guard missing on the route), reported as such.
10
+ *
11
+ * ```ts
12
+ * export const USER = new ContextKey<User>('user')
13
+ *
14
+ * // guard
15
+ * ctx.set(USER, await this.#auth.verify(ctx.header('authorization')))
16
+ *
17
+ * // handler
18
+ * const user = ctx.get(USER)
19
+ * ```
20
+ */
21
+
22
+ /**
23
+ * Key of a value attached to a request.
24
+ */
25
+ export class ContextKey<T> {
26
+ constructor(
27
+ readonly description: string,
28
+ /** Value of the key when nothing was set. Without it, reading an unset key throws. */
29
+ readonly defaultValue?: () => T
30
+ ) {}
31
+
32
+ toString(): string {
33
+ return `ContextKey(${this.description})`
34
+ }
35
+ }
@@ -4,11 +4,13 @@
4
4
  */
5
5
 
6
6
  import type { Constructor } from '../di/index.ts'
7
- import { readJsonBody } from './body.ts'
7
+ import { readFormBody, readJsonBody } from './body.ts'
8
+ import type { FormFields, FormLimits } from './body.ts'
9
+ import type { ContextKey } from './context-key.ts'
8
10
  import type { MetadataDecorator } from './enhancers.ts'
9
11
  import { applyParser } from './parsers.ts'
10
12
  import type { Parsed, ValueParser } from './parsers.ts'
11
- import { validate } from './schema.ts'
13
+ import { isStandardSchema, validate } from './schema.ts'
12
14
  import type { InferOutput, StandardSchemaV1 } from './schema.ts'
13
15
  import type { AnyParams, PathParams } from './types.ts'
14
16
 
@@ -49,18 +51,54 @@ export interface RouteInfo {
49
51
  * }
50
52
  * ```
51
53
  *
52
- * The URL is parsed only when `url` or `query()` is used; the body is read at most once.
54
+ * The URL is parsed only when `url` or `query()` is used; the body is read at
55
+ * most once; the storage of `set()` is created on the first `set()`.
53
56
  */
54
57
  export class Context<P extends string = string> {
55
58
  #url: URL | undefined
56
- #body: Promise<unknown> | undefined
59
+ #body: { readonly kind: 'JSON' | 'form'; readonly value: Promise<unknown> } | undefined
60
+ #values: Map<ContextKey<unknown>, unknown> | undefined
57
61
 
58
62
  constructor(
59
- readonly req: Request,
63
+ readonly req: Bun.BunRequest,
60
64
  readonly params: ContextParams<P>,
61
65
  readonly route: RouteInfo
62
66
  ) {}
63
67
 
68
+ /** Attaches a value to this request, for the guards, interceptors and handler that follow. */
69
+ set<T>(key: ContextKey<T>, value: T): void {
70
+ this.#values ??= new Map()
71
+ this.#values.set(key, value)
72
+ }
73
+
74
+ /**
75
+ * Value attached to this request under `key`, or its default. Throws if
76
+ * nothing was set and the key has no default: a guard is missing on the route.
77
+ */
78
+ get<T>(key: ContextKey<T>): T {
79
+ if (this.#values?.has(key) === true) {
80
+ return this.#values.get(key) as T
81
+ }
82
+
83
+ if (key.defaultValue === undefined) {
84
+ throw new Error(
85
+ `${key.toString()} is not set for ${this.route.controller.name}.${this.route.handler}: ` +
86
+ 'is the guard or interceptor that sets it applied to this route?'
87
+ )
88
+ }
89
+
90
+ const value = key.defaultValue()
91
+
92
+ this.set(key, value)
93
+
94
+ return value
95
+ }
96
+
97
+ /** `true` if a value was set under `key` for this request. */
98
+ has(key: ContextKey<unknown>): boolean {
99
+ return this.#values?.has(key) === true
100
+ }
101
+
64
102
  /**
65
103
  * Custom metadata of the route, the method's value over the class's.
66
104
  * Like Nest's `reflector.getAllAndOverride(Roles, [handler, class])`.
@@ -102,6 +140,28 @@ export class Context<P extends string = string> {
102
140
  return parser === undefined ? value : applyParser(parser, value, name)
103
141
  }
104
142
 
143
+ /**
144
+ * Cookies of the request: Bun's `CookieMap`. What `set()` and `delete()`
145
+ * change is sent with the response as `Set-Cookie`, whatever the response is.
146
+ *
147
+ * ```ts
148
+ * ctx.cookies.set('theme', 'dark', { maxAge: 60 * 60 * 24 * 365 })
149
+ * ctx.cookies.delete('session')
150
+ * ```
151
+ */
152
+ get cookies(): Bun.CookieMap {
153
+ return this.req.cookies
154
+ }
155
+
156
+ /** Cookie, decoded; converted and checked when a parser is given, such as `signed()`. */
157
+ cookie(name: string): string | undefined
158
+ cookie<V extends ValueParser<unknown>>(name: string, parser: V): Parsed<V>
159
+ cookie(name: string, parser?: ValueParser<unknown>): unknown {
160
+ const value = this.req.cookies.get(name) ?? undefined
161
+
162
+ return parser === undefined ? value : applyParser(parser, value, name)
163
+ }
164
+
105
165
  /**
106
166
  * JSON body, validated by `schema` when given. `415` for a non-JSON body,
107
167
  * `400` for invalid JSON or a failed validation.
@@ -109,10 +169,36 @@ export class Context<P extends string = string> {
109
169
  body(): Promise<unknown>
110
170
  body<S extends StandardSchemaV1>(schema: S): Promise<InferOutput<S>>
111
171
  async body(schema?: StandardSchemaV1): Promise<unknown> {
112
- this.#body ??= readJsonBody(this.req)
113
-
114
- const value = await this.#body
172
+ const value = await this.#read('JSON', readJsonBody)
115
173
 
116
174
  return schema === undefined ? value : validate(schema, value, '')
117
175
  }
176
+
177
+ /**
178
+ * Form body (`multipart/form-data` or `application/x-www-form-urlencoded`),
179
+ * validated by `schema` when given. `415` for another content type.
180
+ * Repeated fields and fields named `name[]` are arrays; files are `File`s
181
+ * whose `type` is what the client declared, not checked against the content.
182
+ * `limits` bound the body, the files and the fields: `413` past them.
183
+ */
184
+ form(limits?: FormLimits): Promise<FormFields>
185
+ form<S extends StandardSchemaV1>(schema: S, limits?: FormLimits): Promise<InferOutput<S>>
186
+ async form(schemaOrLimits?: StandardSchemaV1 | FormLimits, limits?: FormLimits): Promise<unknown> {
187
+ if (isStandardSchema(schemaOrLimits)) {
188
+ return validate(schemaOrLimits, await this.#read('form', (req) => readFormBody(req, limits)), '')
189
+ }
190
+
191
+ return this.#read('form', (req) => readFormBody(req, schemaOrLimits))
192
+ }
193
+
194
+ /** Reads the body once; reading it again in another format is a programming error. */
195
+ #read(kind: 'JSON' | 'form', read: (req: Request) => Promise<unknown>): Promise<unknown> {
196
+ if (this.#body === undefined) {
197
+ this.#body = { kind, value: read(this.req) }
198
+ } else if (this.#body.kind !== kind) {
199
+ throw new TypeError(`The body of this request was already read as ${this.#body.kind}, it cannot be read as ${kind}`)
200
+ }
201
+
202
+ return this.#body.value
203
+ }
118
204
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @module
3
+ * Signed cookies. Reading and writing cookies is Bun's: `ctx.cookies` is the
4
+ * request's `CookieMap`, whose changes Bun sends with the response as
5
+ * `Set-Cookie`. This module adds what `@fastify/cookie` and `cookie-signature`
6
+ * add on top: values signed with HMAC-SHA256, so the client can read them but
7
+ * not forge them.
8
+ *
9
+ * ```ts
10
+ * const signer = new CookieSigner(process.env.COOKIE_SECRET!)
11
+ *
12
+ * ctx.cookies.set('session', signer.sign(id), { httpOnly: true, secure: true })
13
+ *
14
+ * @Get('me', [cookie('session', signed(signer))])
15
+ * me(id: string) {}
16
+ * ```
17
+ *
18
+ * A signature proves the value was set by the server, not that it is secret
19
+ * or recent: store secrets server-side, and limit the lifetime with `maxAge`.
20
+ */
21
+
22
+ import { timingSafeEqual } from 'node:crypto'
23
+ import { failure } from './parsers.ts'
24
+ import type { Parser, ParserOptions } from './parsers.ts'
25
+
26
+ const hmac = (secret: string, value: string): string =>
27
+ new Bun.CryptoHasher('sha256', secret).update(value).digest('base64url')
28
+
29
+ /**
30
+ * Signs cookie values as `value.signature` and checks them. Several secrets
31
+ * allow rotation: the first signs, every one verifies.
32
+ *
33
+ * ```ts
34
+ * new CookieSigner([process.env.COOKIE_SECRET!, process.env.OLD_COOKIE_SECRET!])
35
+ * ```
36
+ */
37
+ export class CookieSigner {
38
+ readonly #secrets: readonly string[]
39
+
40
+ /** Secrets should be random and long: `openssl rand -base64 32`. */
41
+ constructor(secrets: string | readonly string[]) {
42
+ const list = typeof secrets === 'string' ? [secrets] : [...secrets]
43
+
44
+ if (list.length === 0 || list.includes('')) {
45
+ throw new TypeError('CookieSigner needs at least one secret, and no empty ones')
46
+ }
47
+
48
+ this.#secrets = list
49
+ }
50
+
51
+ /** `value` followed by `.` and its signature, in base64url. */
52
+ sign(value: string): string {
53
+ return `${value}.${hmac(this.#secrets[0]!, value)}`
54
+ }
55
+
56
+ /** The value of a signed string, or `undefined` if it is not signed by one of the secrets. */
57
+ unsign(signed: string): string | undefined {
58
+ const dot = signed.lastIndexOf('.')
59
+
60
+ if (dot === -1) {
61
+ return undefined
62
+ }
63
+
64
+ const value = signed.slice(0, dot)
65
+ const signature = Buffer.from(signed.slice(dot + 1))
66
+
67
+ for (const secret of this.#secrets) {
68
+ const expected = Buffer.from(hmac(secret, value))
69
+
70
+ if (expected.length === signature.length && timingSafeEqual(expected, signature)) {
71
+ return value
72
+ }
73
+ }
74
+
75
+ return undefined
76
+ }
77
+ }
78
+
79
+ type Result<O extends ParserOptions> = O extends { readonly optional: true } ? string | undefined : string
80
+
81
+ /**
82
+ * Parser of a signed cookie: its value, `400` when it is missing or its
83
+ * signature is wrong. With `{ optional: true }`, both give `undefined`, as a
84
+ * forged cookie is no better than none.
85
+ */
86
+ export const signed = <const O extends ParserOptions = {}>(signer: CookieSigner, options?: O): Parser<Result<O>> => ({
87
+ parse: (value, name) => {
88
+ const unsigned = value === undefined ? undefined : signer.unsign(value)
89
+
90
+ if (unsigned === undefined && options?.optional !== true) {
91
+ throw failure(name, 'signed cookie')
92
+ }
93
+
94
+ return unsigned as Result<O>
95
+ },
96
+ })
@@ -0,0 +1,143 @@
1
+ /**
2
+ * @module
3
+ * CORS middleware.
4
+ *
5
+ * Follows Hono's `cors()` and the `cors` package Nest uses, with two differences:
6
+ *
7
+ * - only a real preflight (`OPTIONS` with `Access-Control-Request-Method`, as
8
+ * the Fetch spec defines it) is answered here; other `OPTIONS` requests reach
9
+ * the application and its own `OPTIONS` routes;
10
+ * - `credentials: true` with `origin: '*'` is rejected when the middleware is
11
+ * created: browsers refuse that pair, so it could never work.
12
+ *
13
+ * Response headers are set in place: Bun's responses always have mutable headers.
14
+ *
15
+ * ```ts
16
+ * cors({ origin: ['https://app.example.com'], credentials: true, maxAge: 600 })
17
+ * ```
18
+ */
19
+
20
+ import type { MiddlewareFunction } from './middleware.ts'
21
+
22
+ export interface CorsOptions {
23
+ /**
24
+ * Allowed origins: `'*'` (default), one origin, a list, or a predicate.
25
+ * An allowed origin is echoed back; a refused one gets no CORS headers, so the browser blocks the response.
26
+ */
27
+ readonly origin?: '*' | string | readonly string[] | ((origin: string, req: Request) => boolean)
28
+ /** Methods announced to preflights. */
29
+ readonly methods?: readonly string[]
30
+ /** Headers announced to preflights. Defaults to the ones the preflight asks for. */
31
+ readonly allowedHeaders?: readonly string[]
32
+ /** Response headers the browser exposes to scripts. */
33
+ readonly exposedHeaders?: readonly string[]
34
+ /** Allows cookies and `Authorization`; needs explicit origins. */
35
+ readonly credentials?: boolean
36
+ /** Seconds a browser may cache a preflight. */
37
+ readonly maxAge?: number
38
+ }
39
+
40
+ const DEFAULT_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE']
41
+
42
+ const createOriginCheck = (origin: NonNullable<CorsOptions['origin']>): ((origin: string, req: Request) => boolean) => {
43
+ if (typeof origin === 'function') {
44
+ return origin
45
+ }
46
+
47
+ if (typeof origin === 'string') {
48
+ return (requested) => requested === origin
49
+ }
50
+
51
+ const allowed = new Set(origin)
52
+
53
+ return (requested) => allowed.has(requested)
54
+ }
55
+
56
+ const appendVary = (headers: Headers, value: string): void => {
57
+ const current = headers.get('vary')
58
+
59
+ if (current === null) {
60
+ headers.set('vary', value)
61
+ } else if (current !== '*' && !current.toLowerCase().split(/\s*,\s*/).includes(value.toLowerCase())) {
62
+ headers.set('vary', `${current}, ${value}`)
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Creates the CORS middleware. Throws a `TypeError` for options browsers would refuse.
68
+ */
69
+ export const cors = (options: CorsOptions = {}): MiddlewareFunction => {
70
+ const origin = options.origin ?? '*'
71
+ const any = origin === '*'
72
+
73
+ if (any && options.credentials === true) {
74
+ throw new TypeError(
75
+ "cors(): credentials cannot be used with origin '*', browsers reject that combination; list the allowed origins instead"
76
+ )
77
+ }
78
+
79
+ const isAllowed = any ? () => true : createOriginCheck(origin)
80
+ const methods = (options.methods ?? DEFAULT_METHODS).join(', ')
81
+ const allowedHeaders = options.allowedHeaders?.join(', ')
82
+ const exposedHeaders = options.exposedHeaders?.join(', ')
83
+ const maxAge = options.maxAge === undefined ? undefined : String(options.maxAge)
84
+
85
+ /** Headers every CORS response of an allowed origin gets. */
86
+ const applyCommon = (headers: Headers, requested: string | null, req: Request): void => {
87
+ if (requested !== null && isAllowed(requested, req)) {
88
+ headers.set('access-control-allow-origin', any ? '*' : requested)
89
+
90
+ if (options.credentials === true) {
91
+ headers.set('access-control-allow-credentials', 'true')
92
+ }
93
+ }
94
+
95
+ // The response depends on Origin as soon as it is not the same for every origin.
96
+ if (!any) {
97
+ appendVary(headers, 'Origin')
98
+ }
99
+ }
100
+
101
+ return async (req, next) => {
102
+ const requested = req.headers.get('origin')
103
+ const preflight = req.method === 'OPTIONS' && requested !== null && req.headers.has('access-control-request-method')
104
+
105
+ if (preflight) {
106
+ const headers = new Headers()
107
+
108
+ applyCommon(headers, requested, req)
109
+
110
+ if (headers.has('access-control-allow-origin')) {
111
+ headers.set('access-control-allow-methods', methods)
112
+
113
+ const requestedHeaders = req.headers.get('access-control-request-headers')
114
+ const allowHeaders = allowedHeaders ?? requestedHeaders ?? ''
115
+
116
+ if (allowHeaders !== '') {
117
+ headers.set('access-control-allow-headers', allowHeaders)
118
+ }
119
+
120
+ if (allowedHeaders === undefined && requestedHeaders !== null) {
121
+ appendVary(headers, 'Access-Control-Request-Headers')
122
+ }
123
+
124
+ if (maxAge !== undefined) {
125
+ headers.set('access-control-max-age', maxAge)
126
+ }
127
+ }
128
+
129
+ return new Response(null, { status: 204, headers })
130
+ }
131
+
132
+ const response = await next()
133
+ const headers = response.headers
134
+
135
+ applyCommon(headers, requested, req)
136
+
137
+ if (exposedHeaders !== undefined && headers.has('access-control-allow-origin')) {
138
+ headers.set('access-control-expose-headers', exposedHeaders)
139
+ }
140
+
141
+ return response
142
+ }
143
+ }
package/src/http/index.ts CHANGED
@@ -3,9 +3,11 @@
3
3
  * HTTP layer for Soda: controllers, route decorators, request context, validation, exceptions.
4
4
  */
5
5
 
6
- export { body, context, header, param, query } from './arguments.ts'
6
+ export { body, context, cookie, form, header, param, query, value } from './arguments.ts'
7
7
  export type { Argument, ArgumentFor, ArgumentKind, ArgumentValues } from './arguments.ts'
8
8
  export { Context } from './context.ts'
9
+ export { CookieSigner, signed } from './cookies.ts'
10
+ export { ContextKey } from './context-key.ts'
9
11
  export type { ContextParams, ParamNameOf } from './context.ts'
10
12
  export { All, Controller, Delete, Get, Head, Header, HttpCode, Options, Patch, Post, Put, Route } from './decorators.ts'
11
13
  export type {
@@ -36,7 +38,7 @@ export type { HttpExceptionBody, HttpExceptionOptions } from './exceptions.ts'
36
38
  export { boolean, int, number, uuid } from './parsers.ts'
37
39
  export type { Parsed, Parser, ParserOptions, ValueParser } from './parsers.ts'
38
40
  export { buildRoutes, controllerRoutes } from './routes.ts'
39
- export type { BuiltRoutes, ControllerRoutesOptions } from './routes.ts'
41
+ export type { BuiltRoutes, ControllerRoutesOptions, MappedController } from './routes.ts'
40
42
  export type { InferOutput, StandardSchemaV1 } from './schema.ts'
41
43
  export type { AnyParams, PathParams } from './types.ts'
42
44
  export { Catch, createDecorator, UseFilters, UseGuards, UseInterceptors } from './enhancers.ts'
@@ -56,3 +58,7 @@ export type {
56
58
  MetadataDecorator,
57
59
  } from './enhancers.ts'
58
60
  export type { RouteInfo } from './context.ts'
61
+ export { cors } from './cors.ts'
62
+ export type { CorsOptions } from './cors.ts'
63
+ export type { Middleware, MiddlewareFunction, NestMiddleware, Next } from './middleware.ts'
64
+ export type { FormFields, FormLimits, FormValue } from './body.ts'