@c9up/ream 0.2.28 → 0.2.30

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 (293) hide show
  1. package/README.md +5 -6
  2. package/dist/Codemods.d.ts +24 -0
  3. package/dist/Codemods.d.ts.map +1 -1
  4. package/dist/Codemods.js +211 -1
  5. package/dist/Codemods.js.map +1 -1
  6. package/dist/HttpKernel.d.ts.map +1 -1
  7. package/dist/HttpKernel.js +17 -5
  8. package/dist/HttpKernel.js.map +1 -1
  9. package/dist/Ignitor.d.ts +15 -2
  10. package/dist/Ignitor.d.ts.map +1 -1
  11. package/dist/Ignitor.js +33 -23
  12. package/dist/Ignitor.js.map +1 -1
  13. package/dist/bodyparser/BodyParserMiddleware.d.ts +4 -4
  14. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  15. package/dist/bodyparser/BodyParserMiddleware.js +41 -11
  16. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  17. package/dist/bodyparser/MultipartFile.d.ts +21 -15
  18. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  19. package/dist/bodyparser/MultipartFile.js +40 -58
  20. package/dist/bodyparser/MultipartFile.js.map +1 -1
  21. package/dist/bodyparser/parseSize.d.ts +7 -4
  22. package/dist/bodyparser/parseSize.d.ts.map +1 -1
  23. package/dist/bodyparser/parseSize.js +12 -17
  24. package/dist/bodyparser/parseSize.js.map +1 -1
  25. package/dist/bodyparser/qsParse.d.ts +20 -1
  26. package/dist/bodyparser/qsParse.d.ts.map +1 -1
  27. package/dist/bodyparser/qsParse.js +78 -25
  28. package/dist/bodyparser/qsParse.js.map +1 -1
  29. package/dist/codemods/source.d.ts +57 -0
  30. package/dist/codemods/source.d.ts.map +1 -0
  31. package/dist/codemods/source.js +282 -0
  32. package/dist/codemods/source.js.map +1 -0
  33. package/dist/console/prompts.d.ts.map +1 -1
  34. package/dist/console/prompts.js +3 -1
  35. package/dist/console/prompts.js.map +1 -1
  36. package/dist/container/Container.d.ts.map +1 -1
  37. package/dist/container/Container.js +61 -71
  38. package/dist/container/Container.js.map +1 -1
  39. package/dist/events/Emitter.d.ts +12 -0
  40. package/dist/events/Emitter.d.ts.map +1 -1
  41. package/dist/events/Emitter.js +36 -9
  42. package/dist/events/Emitter.js.map +1 -1
  43. package/dist/events/helix/helpers.d.ts.map +1 -1
  44. package/dist/events/helix/helpers.js +19 -9
  45. package/dist/events/helix/helpers.js.map +1 -1
  46. package/dist/graphql/GraphQLEngine.d.ts +8 -0
  47. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  48. package/dist/graphql/GraphQLEngine.js +79 -12
  49. package/dist/graphql/GraphQLEngine.js.map +1 -1
  50. package/dist/helpers/MessageBuilder.d.ts +4 -2
  51. package/dist/helpers/MessageBuilder.d.ts.map +1 -1
  52. package/dist/helpers/MessageBuilder.js +4 -2
  53. package/dist/helpers/MessageBuilder.js.map +1 -1
  54. package/dist/helpers/errors.d.ts +11 -0
  55. package/dist/helpers/errors.d.ts.map +1 -0
  56. package/dist/helpers/errors.js +17 -0
  57. package/dist/helpers/errors.js.map +1 -0
  58. package/dist/helpers/napi-loader.d.ts.map +1 -1
  59. package/dist/helpers/napi-loader.js +3 -2
  60. package/dist/helpers/napi-loader.js.map +1 -1
  61. package/dist/http/Exception.d.ts +14 -1
  62. package/dist/http/Exception.d.ts.map +1 -1
  63. package/dist/http/Exception.js +10 -2
  64. package/dist/http/Exception.js.map +1 -1
  65. package/dist/http/HttpContext.d.ts +31 -2
  66. package/dist/http/HttpContext.d.ts.map +1 -1
  67. package/dist/http/HttpContext.js +12 -5
  68. package/dist/http/HttpContext.js.map +1 -1
  69. package/dist/http/RedirectBuilder.d.ts +9 -3
  70. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  71. package/dist/http/RedirectBuilder.js +43 -37
  72. package/dist/http/RedirectBuilder.js.map +1 -1
  73. package/dist/http/Request.d.ts +42 -17
  74. package/dist/http/Request.d.ts.map +1 -1
  75. package/dist/http/Request.js +80 -58
  76. package/dist/http/Request.js.map +1 -1
  77. package/dist/http/Response.d.ts +7 -4
  78. package/dist/http/Response.d.ts.map +1 -1
  79. package/dist/http/Response.js +14 -6
  80. package/dist/http/Response.js.map +1 -1
  81. package/dist/http/SseStream.js +6 -7
  82. package/dist/http/SseStream.js.map +1 -1
  83. package/dist/http/permissions.d.ts +25 -0
  84. package/dist/http/permissions.d.ts.map +1 -0
  85. package/dist/http/permissions.js +38 -0
  86. package/dist/http/permissions.js.map +1 -0
  87. package/dist/http-client/HttpClient.d.ts +111 -0
  88. package/dist/http-client/HttpClient.d.ts.map +1 -0
  89. package/dist/http-client/HttpClient.js +386 -0
  90. package/dist/http-client/HttpClient.js.map +1 -0
  91. package/dist/http-client/addresses.d.ts +14 -0
  92. package/dist/http-client/addresses.d.ts.map +1 -0
  93. package/dist/http-client/addresses.js +57 -0
  94. package/dist/http-client/addresses.js.map +1 -0
  95. package/dist/http-client/fake.d.ts +50 -0
  96. package/dist/http-client/fake.d.ts.map +1 -0
  97. package/dist/http-client/fake.js +95 -0
  98. package/dist/http-client/fake.js.map +1 -0
  99. package/dist/http-client/index.d.ts +7 -0
  100. package/dist/http-client/index.d.ts.map +1 -0
  101. package/dist/http-client/index.js +7 -0
  102. package/dist/http-client/index.js.map +1 -0
  103. package/dist/index.d.ts +4 -3
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +1 -1
  106. package/dist/index.js.map +1 -1
  107. package/dist/middleware/Pipeline.d.ts.map +1 -1
  108. package/dist/middleware/Pipeline.js +6 -3
  109. package/dist/middleware/Pipeline.js.map +1 -1
  110. package/dist/native/generated.d.ts +93 -36
  111. package/dist/native/generated.d.ts.map +1 -1
  112. package/dist/profiler/Profiler.d.ts.map +1 -1
  113. package/dist/profiler/Profiler.js +4 -1
  114. package/dist/profiler/Profiler.js.map +1 -1
  115. package/dist/profiler/client.generated.d.ts +5 -0
  116. package/dist/profiler/client.generated.d.ts.map +1 -0
  117. package/dist/profiler/client.generated.js +6 -0
  118. package/dist/profiler/client.generated.js.map +1 -0
  119. package/dist/profiler/collectors.d.ts +92 -13
  120. package/dist/profiler/collectors.d.ts.map +1 -1
  121. package/dist/profiler/collectors.js +106 -19
  122. package/dist/profiler/collectors.js.map +1 -1
  123. package/dist/profiler/endpoint.d.ts +2 -1
  124. package/dist/profiler/endpoint.d.ts.map +1 -1
  125. package/dist/profiler/endpoint.js +25 -1
  126. package/dist/profiler/endpoint.js.map +1 -1
  127. package/dist/profiler/index.d.ts +8 -4
  128. package/dist/profiler/index.d.ts.map +1 -1
  129. package/dist/profiler/index.js +11 -4
  130. package/dist/profiler/index.js.map +1 -1
  131. package/dist/profiler/middleware.d.ts.map +1 -1
  132. package/dist/profiler/middleware.js +76 -10
  133. package/dist/profiler/middleware.js.map +1 -1
  134. package/dist/profiler/preview.d.ts +17 -0
  135. package/dist/profiler/preview.d.ts.map +1 -0
  136. package/dist/profiler/preview.js +100 -0
  137. package/dist/profiler/preview.js.map +1 -0
  138. package/dist/profiler/toolbar.d.ts +15 -23
  139. package/dist/profiler/toolbar.d.ts.map +1 -1
  140. package/dist/profiler/toolbar.js +25 -91
  141. package/dist/profiler/toolbar.js.map +1 -1
  142. package/dist/profiler/types.d.ts +6 -1
  143. package/dist/profiler/types.d.ts.map +1 -1
  144. package/dist/router/Router.d.ts +6 -0
  145. package/dist/router/Router.d.ts.map +1 -1
  146. package/dist/router/Router.js +115 -12
  147. package/dist/router/Router.js.map +1 -1
  148. package/dist/rpc/RpcProvider.d.ts +6 -0
  149. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  150. package/dist/rpc/RpcProvider.js +4 -1
  151. package/dist/rpc/RpcProvider.js.map +1 -1
  152. package/dist/rpc/RpcRouter.d.ts +27 -14
  153. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  154. package/dist/rpc/RpcRouter.js +97 -40
  155. package/dist/rpc/RpcRouter.js.map +1 -1
  156. package/dist/scheduler/Scheduler.js +1 -1
  157. package/dist/scheduler/Scheduler.js.map +1 -1
  158. package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -1
  159. package/dist/scheduler/locks/RedisLockBackend.js +13 -2
  160. package/dist/scheduler/locks/RedisLockBackend.js.map +1 -1
  161. package/dist/security/CookieSigner.d.ts.map +1 -1
  162. package/dist/security/CookieSigner.js +32 -8
  163. package/dist/security/CookieSigner.js.map +1 -1
  164. package/dist/security/SignedUrl.d.ts.map +1 -1
  165. package/dist/security/SignedUrl.js +16 -19
  166. package/dist/security/SignedUrl.js.map +1 -1
  167. package/dist/server/Server.d.ts +44 -12
  168. package/dist/server/Server.d.ts.map +1 -1
  169. package/dist/server/Server.js +31 -10
  170. package/dist/server/Server.js.map +1 -1
  171. package/dist/server/nativeGrpc.d.ts +27 -0
  172. package/dist/server/nativeGrpc.d.ts.map +1 -0
  173. package/dist/server/nativeGrpc.js +47 -0
  174. package/dist/server/nativeGrpc.js.map +1 -0
  175. package/dist/server/nativeLimits.d.ts +57 -0
  176. package/dist/server/nativeLimits.d.ts.map +1 -0
  177. package/dist/server/nativeLimits.js +75 -0
  178. package/dist/server/nativeLimits.js.map +1 -0
  179. package/dist/session/Session.d.ts +12 -0
  180. package/dist/session/Session.d.ts.map +1 -1
  181. package/dist/session/Session.js +13 -4
  182. package/dist/session/Session.js.map +1 -1
  183. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  184. package/dist/session/SessionMiddleware.js +28 -9
  185. package/dist/session/SessionMiddleware.js.map +1 -1
  186. package/dist/session/config.d.ts +5 -0
  187. package/dist/session/config.d.ts.map +1 -1
  188. package/dist/session/config.js +8 -0
  189. package/dist/session/config.js.map +1 -1
  190. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  191. package/dist/session/drivers/MemoryDriver.js +26 -7
  192. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  193. package/dist/session/errors.d.ts +17 -0
  194. package/dist/session/errors.d.ts.map +1 -1
  195. package/dist/session/errors.js +21 -0
  196. package/dist/session/errors.js.map +1 -1
  197. package/dist/session/templateTags.d.ts +14 -0
  198. package/dist/session/templateTags.d.ts.map +1 -1
  199. package/dist/session/templateTags.js +45 -17
  200. package/dist/session/templateTags.js.map +1 -1
  201. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  202. package/dist/storage/StaticMiddleware.js +49 -27
  203. package/dist/storage/StaticMiddleware.js.map +1 -1
  204. package/dist/testing/ApiResponse.d.ts.map +1 -1
  205. package/dist/testing/ApiResponse.js +7 -1
  206. package/dist/testing/ApiResponse.js.map +1 -1
  207. package/dist/testing/TestClient.d.ts.map +1 -1
  208. package/dist/testing/TestClient.js +16 -4
  209. package/dist/testing/TestClient.js.map +1 -1
  210. package/dist/utils/Macroable.d.ts +1 -1
  211. package/dist/utils/Macroable.js +2 -2
  212. package/dist/utils/Macroable.js.map +1 -1
  213. package/events.darwin-arm64.node +0 -0
  214. package/events.darwin-x64.node +0 -0
  215. package/events.linux-arm64-gnu.node +0 -0
  216. package/events.linux-x64-gnu.node +0 -0
  217. package/events.win32-x64-msvc.node +0 -0
  218. package/index.darwin-arm64.node +0 -0
  219. package/index.darwin-x64.node +0 -0
  220. package/index.linux-arm64-gnu.node +0 -0
  221. package/index.linux-x64-gnu.node +0 -0
  222. package/index.win32-x64-msvc.node +0 -0
  223. package/package.json +11 -3
  224. package/scheduler.darwin-arm64.node +0 -0
  225. package/scheduler.darwin-x64.node +0 -0
  226. package/scheduler.linux-arm64-gnu.node +0 -0
  227. package/scheduler.linux-x64-gnu.node +0 -0
  228. package/scheduler.win32-x64-msvc.node +0 -0
  229. package/src/Codemods.ts +314 -3
  230. package/src/HttpKernel.ts +18 -7
  231. package/src/Ignitor.ts +58 -26
  232. package/src/bodyparser/BodyParserMiddleware.ts +45 -16
  233. package/src/bodyparser/MultipartFile.ts +46 -67
  234. package/src/bodyparser/parseSize.ts +17 -17
  235. package/src/bodyparser/qsParse.ts +96 -26
  236. package/src/codemods/source.ts +303 -0
  237. package/src/console/prompts.ts +3 -1
  238. package/src/container/Container.ts +97 -81
  239. package/src/events/Emitter.ts +37 -10
  240. package/src/events/helix/helpers.ts +24 -19
  241. package/src/graphql/GraphQLEngine.ts +91 -14
  242. package/src/helpers/MessageBuilder.ts +4 -2
  243. package/src/helpers/errors.ts +16 -0
  244. package/src/helpers/napi-loader.ts +3 -2
  245. package/src/http/Exception.ts +17 -2
  246. package/src/http/HttpContext.ts +34 -5
  247. package/src/http/RedirectBuilder.ts +51 -37
  248. package/src/http/Request.ts +102 -58
  249. package/src/http/Response.ts +15 -7
  250. package/src/http/SseStream.ts +6 -7
  251. package/src/http/permissions.ts +47 -0
  252. package/src/http-client/HttpClient.ts +505 -0
  253. package/src/http-client/addresses.ts +58 -0
  254. package/src/http-client/fake.ts +141 -0
  255. package/src/http-client/index.ts +21 -0
  256. package/src/index.ts +6 -1
  257. package/src/middleware/Pipeline.ts +7 -3
  258. package/src/native/generated.ts +106 -48
  259. package/src/profiler/Profiler.ts +4 -1
  260. package/src/profiler/client/toolbar.css +595 -0
  261. package/src/profiler/client/toolbar.js +1299 -0
  262. package/src/profiler/client.generated.ts +7 -0
  263. package/src/profiler/collectors.ts +156 -19
  264. package/src/profiler/endpoint.ts +27 -1
  265. package/src/profiler/index.ts +17 -3
  266. package/src/profiler/middleware.ts +74 -9
  267. package/src/profiler/preview.ts +104 -0
  268. package/src/profiler/toolbar.ts +28 -91
  269. package/src/profiler/types.ts +6 -1
  270. package/src/router/Router.ts +136 -13
  271. package/src/rpc/RpcProvider.ts +10 -1
  272. package/src/rpc/RpcRouter.ts +129 -45
  273. package/src/scheduler/Scheduler.ts +1 -1
  274. package/src/scheduler/locks/RedisLockBackend.ts +12 -4
  275. package/src/security/CookieSigner.ts +30 -8
  276. package/src/security/SignedUrl.ts +18 -17
  277. package/src/server/Server.ts +60 -12
  278. package/src/server/nativeGrpc.ts +59 -0
  279. package/src/server/nativeLimits.ts +111 -0
  280. package/src/session/Session.ts +29 -4
  281. package/src/session/SessionMiddleware.ts +30 -10
  282. package/src/session/config.ts +7 -0
  283. package/src/session/drivers/MemoryDriver.ts +26 -7
  284. package/src/session/errors.ts +31 -0
  285. package/src/session/templateTags.ts +57 -0
  286. package/src/storage/StaticMiddleware.ts +46 -27
  287. package/src/testing/ApiResponse.ts +7 -1
  288. package/src/testing/TestClient.ts +14 -5
  289. package/src/utils/Macroable.ts +2 -2
  290. package/scripts/build-napi-types.mjs +0 -69
  291. package/scripts/copy-napi.mjs +0 -121
  292. package/scripts/generate-mime.mjs +0 -119
  293. package/scripts/generate-napi-types.mjs +0 -187
@@ -21,11 +21,11 @@ export interface BodyParserConfig {
21
21
  */
22
22
  allowedMethods?: string[]
23
23
  json?: {
24
- limit?: string // e.g. '1mb'
24
+ limit?: string | number // e.g. '1mb', '1.5 mb' or a number of bytes
25
25
  types?: string[]
26
26
  }
27
27
  form?: {
28
- limit?: string
28
+ limit?: string | number
29
29
  types?: string[]
30
30
  /**
31
31
  * Turn `''` into `null`. AdonisJS defaults this to `true`, so an empty
@@ -39,12 +39,12 @@ export interface BodyParserConfig {
39
39
  queryString?: QsParseOptions
40
40
  }
41
41
  raw?: {
42
- limit?: string
42
+ limit?: string | number
43
43
  types?: string[]
44
44
  }
45
45
  multipart?: {
46
46
  /** Total multipart body size cap (sum of all file sizes). Default: '20mb'. */
47
- limit?: string
47
+ limit?: string | number
48
48
  /**
49
49
  * Maximum number of files per request. AdonisJS has no such option — it
50
50
  * bounds a multipart body by total size and field count only, so ten
@@ -98,6 +98,8 @@ const DEFAULT_CONFIG: ResolvedBodyParserConfig = {
98
98
 
99
99
  export default class BodyParserMiddleware {
100
100
  #config: ResolvedBodyParserConfig
101
+ /** Each section's limit in bytes, read once so a bad size fails at boot. */
102
+ readonly #limits: { json: number; form: number; raw: number; multipart: number }
101
103
 
102
104
  constructor(explicitConfig: BodyParserConfig | undefined = undefined) {
103
105
  // The default value is load-bearing: an OPTIONAL parameter still counts
@@ -118,6 +120,12 @@ export default class BodyParserMiddleware {
118
120
  raw: { ...DEFAULT_CONFIG.raw, ...config?.raw },
119
121
  multipart: { ...DEFAULT_CONFIG.multipart, ...config?.multipart },
120
122
  }
123
+ this.#limits = {
124
+ json: parseSize(this.#config.json.limit),
125
+ form: parseSize(this.#config.form.limit),
126
+ raw: parseSize(this.#config.raw.limit),
127
+ multipart: parseSize(this.#config.multipart.limit),
128
+ }
121
129
  }
122
130
 
123
131
  async handle(ctx: HttpContext, next: () => Promise<void>) {
@@ -136,18 +144,22 @@ export default class BodyParserMiddleware {
136
144
  }
137
145
 
138
146
  const contentType = ctx.request.header('content-type') ?? ''
147
+ // A body over its native limit (`bodyTooLarge`) was never kept: `raw()`
148
+ // throws the same 413 below instead of handing back an empty string. The
149
+ // route is already matched here, so an unknown URL answered 404 first.
139
150
  const rawBody = ctx.request.raw()
140
151
 
141
152
  // Size check. Thrown rather than written: the app's exception handler
142
153
  // negotiates the response, so an HTML app gets an error page instead of a
143
154
  // JSON envelope it never asked for — and can override the rendering.
144
- if (Buffer.byteLength(rawBody, 'utf8') > parseSize(this.#getLimit(contentType))) {
155
+ if (Buffer.byteLength(rawBody, 'utf8') > this.#getLimit(contentType)) {
145
156
  throw entityTooLarge('request entity too large')
146
157
  }
147
158
 
148
- // JSON
159
+ // JSON — parsed here and only here, under one of `json.types`. A body
160
+ // with any other media type (or none) is never read as JSON.
149
161
  if (matchesType(contentType, this.#config.json.types)) {
150
- // `Request` lazy-parses JSON on first read; nothing to do here.
162
+ ctx.request.setParsedBody(parseJsonBody(rawBody))
151
163
  }
152
164
 
153
165
  // Form URL-encoded
@@ -188,11 +200,11 @@ export default class BodyParserMiddleware {
188
200
  await next()
189
201
  }
190
202
 
191
- #getLimit(contentType: string): string {
192
- if (matchesType(contentType, this.#config.json.types)) return this.#config.json.limit
193
- if (matchesType(contentType, this.#config.form.types)) return this.#config.form.limit
194
- if (matchesType(contentType, this.#config.multipart.types)) return this.#config.multipart.limit
195
- return this.#config.raw.limit
203
+ #getLimit(contentType: string): number {
204
+ if (matchesType(contentType, this.#config.json.types)) return this.#limits.json
205
+ if (matchesType(contentType, this.#config.form.types)) return this.#limits.form
206
+ if (matchesType(contentType, this.#config.multipart.types)) return this.#limits.multipart
207
+ return this.#limits.raw
196
208
  }
197
209
 
198
210
  /**
@@ -214,12 +226,30 @@ export default class BodyParserMiddleware {
214
226
  }
215
227
 
216
228
  const totalBytes = files.reduce((sum, f) => sum + f.size, 0)
217
- if (totalBytes > parseSize(cfg.limit)) {
229
+ if (totalBytes > this.#limits.multipart) {
218
230
  throw entityTooLarge('request entity too large')
219
231
  }
220
232
  }
221
233
  }
222
234
 
235
+ /**
236
+ * A JSON body as the request body: an object as is, any other JSON value under
237
+ * `_body` (the shape the raw-text parser uses), and `{}` for malformed JSON.
238
+ */
239
+ function parseJsonBody(raw: string): Record<string, unknown> {
240
+ if (raw.length === 0) return {}
241
+ let parsed: unknown
242
+ try {
243
+ parsed = JSON.parse(raw)
244
+ } catch {
245
+ return {}
246
+ }
247
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
248
+ return { ...parsed }
249
+ }
250
+ return { _body: parsed }
251
+ }
252
+
223
253
  /** The one exception every body-size limit raises (AdonisJS parity). */
224
254
  function entityTooLarge(message: string): Exception {
225
255
  return new Exception(message, { status: 413, code: 'E_REQUEST_ENTITY_TOO_LARGE' })
@@ -247,8 +277,7 @@ function matchesType(contentType: string, types: string[]): boolean {
247
277
 
248
278
  /**
249
279
  * `enabled` was an atlas-era invention: AdonisJS has no such flag, and a parser
250
- * is turned off by giving it no `types`. It also never worked for JSON, which
251
- * `Request` lazy-parses on first read regardless.
280
+ * is turned off by giving it no `types`.
252
281
  *
253
282
  * Silently ignoring a config key that used to mean something would re-enable a
254
283
  * parser an app deliberately switched off, so it fails loudly with the
@@ -275,7 +304,7 @@ function assertNoEnabledFlag(config?: BodyParserConfig): void {
275
304
  throw new Error(
276
305
  '[E_BODYPARSER_CONFIG] `multipart.tmpDir` is not supported — uploaded files are held in ' +
277
306
  'memory and never written to a temporary directory. Choose the destination when you ' +
278
- 'store the file: `await file.moveToDisk(directory)`.',
307
+ 'store the file: `await file.move(directory)`.',
279
308
  )
280
309
  }
281
310
  }
@@ -51,18 +51,27 @@ function isMimeType(value: string): value is MimeType {
51
51
  *
52
52
  * Like AdonisJS MultipartFile:
53
53
  * const avatar = request.file('avatar', { size: '2mb', extnames: ['jpg', 'png'] })
54
- * await avatar.moveToDisk('uploads')
54
+ * await avatar.move(app.makePath('uploads'))
55
+ *
56
+ * Macroable: a package extends every upload at boot, the way a storage
57
+ * package adds `moveToDisk(key, disk?, options?)` to write it to a configured
58
+ * disk. `moveToDisk` is deliberately not defined here, so that macro owns it.
55
59
  */
56
60
 
57
61
  import { randomBytes } from 'node:crypto'
58
62
  import { Readable } from 'node:stream'
63
+ import { errorCode } from '../helpers/errors.js'
64
+ import { Macroable } from '../utils/Macroable.js'
65
+ import { parseSize } from './parseSize.js'
66
+ import { parseFormFields } from './qsParse.js'
59
67
 
60
68
  export interface FileValidationOptions {
61
- size?: string
69
+ /** Largest accepted size: bytes, or a value such as `'2mb'` or `'1.5 mb'`. */
70
+ size?: string | number
62
71
  extnames?: string[]
63
72
  }
64
73
 
65
- export class MultipartFile {
74
+ export class MultipartFile extends Macroable {
66
75
  /** Original filename from the client. */
67
76
  readonly clientName: string
68
77
 
@@ -153,6 +162,12 @@ export class MultipartFile {
153
162
  /** The name it was written under, once it has been moved. */
154
163
  fileName?: string
155
164
 
165
+ /**
166
+ * Free-form data about the file, for whoever moves it: a storage macro
167
+ * records the URL of the stored object here.
168
+ */
169
+ meta: Record<string, unknown> = {}
170
+
156
171
  /** File size in bytes. */
157
172
  readonly size: number
158
173
 
@@ -188,6 +203,7 @@ export class MultipartFile {
188
203
  type: string
189
204
  content: Buffer
190
205
  }) {
206
+ super()
191
207
  this.fieldName = options.fieldName
192
208
  this.clientName = options.clientName
193
209
  this.headers = { 'content-type': options.type }
@@ -272,8 +288,8 @@ export class MultipartFile {
272
288
  * spells it.
273
289
  */
274
290
  validate(options: FileValidationOptions = this.#validationOptions): boolean {
275
- if (options.size) {
276
- const maxBytes = parseFileSize(options.size)
291
+ if (options.size !== undefined) {
292
+ const maxBytes = parseSize(options.size)
277
293
  if (this.size > maxBytes) {
278
294
  this.errors.push(`File size ${this.size} exceeds limit ${options.size}`)
279
295
  }
@@ -318,40 +334,6 @@ export class MultipartFile {
318
334
  return this.errors.length === 0
319
335
  }
320
336
 
321
- /**
322
- * Move file to a directory on disk. When `name` is supplied it must be
323
- * a plain filename — no path separators, no `.`/`..` segments. The
324
- * common footgun is passing the client-supplied `clientName` directly
325
- * (`await file.moveToDisk('uploads', file.clientName)`); a malicious
326
- * upload could otherwise traverse out of `directory` via `../../etc/...`.
327
- */
328
- async moveToDisk(directory: string, name?: string): Promise<string> {
329
- const { mkdirSync, writeFileSync } = await import('node:fs')
330
- const { join } = await import('node:path')
331
-
332
- if (name !== undefined && !isSafeFilename(name)) {
333
- throw new Error(
334
- `MultipartFile.moveToDisk: 'name' must be a plain filename (no path separators, '.', or '..'). Got: ${JSON.stringify(name)}`,
335
- )
336
- }
337
-
338
- mkdirSync(directory, { recursive: true })
339
- const fileName = name ?? `${randomBytes(16).toString('hex')}.${this.extname}`
340
- // Defense-in-depth: the default branch interpolates `extname` (derived
341
- // from attacker-controlled `clientName`) into the filename, bypassing
342
- // the `name` guard above. Re-validate the synthesized fileName before
343
- // join to reject a hostile extname (separators, NUL).
344
- if (!isSafeFilename(fileName)) {
345
- throw new Error(
346
- `MultipartFile.moveToDisk: derived filename '${fileName}' is unsafe (separator or invalid char in clientName-derived extname).`,
347
- )
348
- }
349
- const filePath = join(directory, fileName)
350
- writeFileSync(filePath, this.content)
351
- this.markAsMoved(fileName, filePath)
352
- return filePath
353
- }
354
-
355
337
  /**
356
338
  * Write the file into `location` (AdonisJS `move`).
357
339
  *
@@ -360,19 +342,31 @@ export class MultipartFile {
360
342
  * construction), so this writes the buffer. There is therefore no
361
343
  * `E_MISSING_FILE_TMP_PATH`: there is no temporary file to be missing.
362
344
  *
363
- * `overwrite` defaults to true, as upstream. The generated name carries the
364
- * detected extension, and both it and a caller-supplied `name` go through the
365
- * same plain-filename guard {@link moveToDisk} uses — the common footgun is
366
- * passing `file.clientName` straight through.
345
+ * `overwrite` defaults to true, as upstream.
346
+ *
347
+ * NAMED DEVIATION — without `name`, upstream writes under `clientName`, a
348
+ * value the client chose; Ream generates a random name carrying the
349
+ * detected extension, so an upload neither picks its own path nor
350
+ * overwrites another one by sharing its name.
351
+ *
352
+ * A caller-supplied `name` must be a plain filename (no separator, no `.`
353
+ * or `..`): the common footgun is passing `file.clientName` straight
354
+ * through. The generated name is checked too, since its extension comes
355
+ * from the client's filename when the bytes are not recognised.
367
356
  */
368
357
  async move(location: string, options?: { name?: string; overwrite?: boolean }): Promise<void> {
369
358
  const { mkdir, writeFile } = await import('node:fs/promises')
370
359
  const { join } = await import('node:path')
371
360
 
361
+ if (options?.name !== undefined && !isSafeFilename(options.name)) {
362
+ throw new Error(
363
+ `MultipartFile.move: 'name' must be a plain filename (no path separators, '.', or '..'). Got: ${JSON.stringify(options.name)}`,
364
+ )
365
+ }
372
366
  const name = options?.name ?? `${randomBytes(16).toString('hex')}.${this.extname || 'unknown'}`
373
367
  if (!isSafeFilename(name)) {
374
368
  throw new Error(
375
- `MultipartFile.move: 'name' must be a plain filename (no path separators, '.', or '..'). Got: ${JSON.stringify(name)}`,
369
+ `MultipartFile.move: the derived file name ${JSON.stringify(name)} is unsafe (a separator or NUL in the extension of the client's filename).`,
376
370
  )
377
371
  }
378
372
 
@@ -392,7 +386,7 @@ export class MultipartFile {
392
386
  try {
393
387
  await writeFile(filePath, this.content, options?.overwrite === false ? { flag: 'wx' } : {})
394
388
  } catch (error) {
395
- if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
389
+ if (errorCode(error) === 'EEXIST') {
396
390
  throw new Error(
397
391
  `"${name}" already exists at "${location}". Set "overwrite = true" to overwrite it`,
398
392
  )
@@ -432,23 +426,6 @@ function isSafeFilename(name: string): boolean {
432
426
  return true
433
427
  }
434
428
 
435
- function parseFileSize(size: string): number {
436
- const match = size.match(/^(\d+)(kb|mb|gb)?$/i)
437
- if (!match) return 1024 * 1024
438
- const [, digits, unit] = match
439
- const num = parseInt(digits ?? '0', 10)
440
- switch (unit?.toLowerCase()) {
441
- case 'kb':
442
- return num * 1024
443
- case 'mb':
444
- return num * 1024 * 1024
445
- case 'gb':
446
- return num * 1024 * 1024 * 1024
447
- default:
448
- return num
449
- }
450
- }
451
-
452
429
  /**
453
430
  * Hydrate `MultipartFile` instances from the typed payload the Rust
454
431
  * HyperServer attaches to the request. The wire-level parser (multer crate,
@@ -464,11 +441,13 @@ export function hydrateMultipartPayload(payload: {
464
441
  size: number
465
442
  contentB64: string
466
443
  }>
467
- }): { fields: Record<string, string>; files: MultipartFile[] } {
468
- const fields: Record<string, string> = {}
469
- for (const field of payload.fields) {
470
- fields[field.name] = field.value
471
- }
444
+ }): { fields: Record<string, unknown>; files: MultipartFile[] } {
445
+ // Nested and collected exactly like a urlencoded body: `tags[]` repeated is
446
+ // an array, `user[name]` an object. Flat, a repeated name kept only its
447
+ // last value once a file input was added to the form.
448
+ // The multipart part count is already bounded natively (`maxFields`), so
449
+ // no parameter cap applies on top of it here.
450
+ const fields = parseFormFields(payload.fields, { parameterLimit: Number.POSITIVE_INFINITY })
472
451
  const files = payload.files.map(
473
452
  (f) =>
474
453
  new MultipartFile({
@@ -1,21 +1,21 @@
1
+ import { ReamError } from '../errors/ReamError.js'
2
+ import { parse } from '../helpers/bytes.js'
3
+
1
4
  /**
2
- * Parse a human-readable size string (e.g. '1mb', '512kb', '2gb') to bytes.
3
- * Single source of truth, imported by BodyParserMiddleware (which previously
4
- * carried an identical inline copy — consolidated 2026-06-15).
5
+ * Read a size limit (`'1mb'`, `'50 mb'`, `'1.5kb'`, `1024`) into bytes, with
6
+ * the `bytes` grammar every size option of the body parser accepts.
7
+ *
8
+ * Throws on anything it cannot read, or on a negative size. A silent fallback
9
+ * is what turned `limit: '50 mb'` into a 1 MB cap with no word: a limit the
10
+ * application did not set is worse than a boot that refuses to start.
5
11
  */
6
- export function parseSize(size: string): number {
7
- const match = size.match(/^(\d+)(kb|mb|gb)?$/i)
8
- if (!match) return 1024 * 1024 // default 1mb
9
- const [, digits, unit] = match
10
- const num = parseInt(digits ?? '0', 10)
11
- switch (unit?.toLowerCase()) {
12
- case 'kb':
13
- return num * 1024
14
- case 'mb':
15
- return num * 1024 * 1024
16
- case 'gb':
17
- return num * 1024 * 1024 * 1024
18
- default:
19
- return num
12
+ export function parseSize(size: string | number): number {
13
+ const bytes = parse(size)
14
+ if (bytes === null || !Number.isFinite(bytes) || bytes < 0) {
15
+ throw new ReamError(
16
+ 'E_BODYPARSER_INVALID_SIZE',
17
+ `Cannot read the size ${JSON.stringify(size)}: expected a number of bytes or a value such as '512kb', '1.5mb' or '2 gb'.`,
18
+ )
20
19
  }
20
+ return bytes
21
21
  }
@@ -11,7 +11,7 @@
11
11
  * dependency it can write in a screenful, and the limits below are easier to
12
12
  * defend when they are visible.
13
13
  *
14
- * Three guards, all of which `qs` also applies:
14
+ * Four guards, all of which `qs` also applies:
15
15
  *
16
16
  * - **Prototype keys are dropped.** `__proto__[x]=1` in a form body is the
17
17
  * classic prototype-pollution vector; the key never reaches an object.
@@ -19,11 +19,14 @@
19
19
  * as a literal key rather than allocating unbounded nesting.
20
20
  * - **Parameter count is capped** (`qs` defaults to 1000), so a body of a
21
21
  * million `a[0]`, `a[1]`… cannot be turned into a million-entry object.
22
+ * - **Explicit array indices are capped** (`qs` `arrayLimit`, default 20).
23
+ * A larger index is an object key, so `ids[4294967294]=1` stays one entry.
22
24
  */
23
25
 
24
26
  /** `qs` defaults, reproduced so the behaviour is the same out of the box. */
25
27
  export const DEFAULT_DEPTH = 5
26
28
  export const DEFAULT_PARAMETER_LIMIT = 1000
29
+ export const DEFAULT_ARRAY_LIMIT = 20
27
30
 
28
31
  export interface QsParseOptions {
29
32
  /**
@@ -34,6 +37,12 @@ export interface QsParseOptions {
34
37
  allowDots?: boolean
35
38
  depth?: number
36
39
  parameterLimit?: number
40
+ /**
41
+ * Highest explicit index + 1 that still builds an array (`qs` defaults to
42
+ * 20). `ids[25]=x` becomes `{ ids: { '25': 'x' } }`, so a 16-byte request
43
+ * cannot allocate a 100-million-slot array.
44
+ */
45
+ arrayLimit?: number
37
46
  /** Turn `''` into `null`, as AdonisJS' `convertEmptyStringsToNull` does. */
38
47
  convertEmptyStringsToNull?: boolean
39
48
  /** Trim surrounding whitespace, as AdonisJS' `trimWhitespaces` does. */
@@ -77,15 +86,53 @@ function isIndex(segment: string): boolean {
77
86
  return /^\d+$/.test(segment)
78
87
  }
79
88
 
80
- /** Assign `value` at `segments` inside `target`, creating containers as needed. */
81
- function assign(target: Record<string, unknown>, segments: string[], value: unknown): void {
82
- let cursor: Record<string, unknown> | unknown[] = target
89
+ type Container = Record<string, unknown> | unknown[]
90
+
91
+ function isContainer(value: unknown): value is Container {
92
+ return typeof value === 'object' && value !== null
93
+ }
94
+
95
+ /** `{ '0': a, '1': b }` from `[a, b]`, holes left out — what `qs` produces on overflow. */
96
+ function arrayToObject(array: unknown[]): Record<string, unknown> {
97
+ const object: Record<string, unknown> = {}
98
+ array.forEach((item, index) => {
99
+ object[String(index)] = item
100
+ })
101
+ return object
102
+ }
103
+
104
+ /**
105
+ * Assign `value` at `segments` inside `target`, creating containers as needed.
106
+ *
107
+ * An explicit index at or above `arrayLimit` never addresses an array: the
108
+ * container becomes (or is converted into) an object keyed by that index,
109
+ * as `qs` does. Without it, `ids[100000000]=1` allocates a sparse array whose
110
+ * iteration or serialisation blocks the event loop for seconds. Appends
111
+ * (`tags[]`, a repeated key) stay arrays — each one costs a parameter, so
112
+ * `parameterLimit` already bounds them, and a long checkbox group keeps the
113
+ * shape its handler expects (`qs` turns those into objects past the limit).
114
+ */
115
+ function assign(
116
+ target: Record<string, unknown>,
117
+ segments: string[],
118
+ value: unknown,
119
+ arrayLimit: number,
120
+ ): void {
121
+ let cursor: Container = target
122
+ // Swaps `cursor` inside its parent when an array has to become an object.
123
+ let replace: (next: Container) => void = () => {}
83
124
  for (let i = 0; i < segments.length; i++) {
84
125
  const segment = segments[i] ?? ''
85
126
  const last = i === segments.length - 1
86
127
 
87
128
  if (FORBIDDEN_KEYS.has(segment)) return
88
129
 
130
+ if (Array.isArray(cursor) && isIndex(segment) && Number(segment) >= arrayLimit) {
131
+ const converted = arrayToObject(cursor)
132
+ replace(converted)
133
+ cursor = converted
134
+ }
135
+
89
136
  if (last) {
90
137
  if (segment === '') {
91
138
  // `tags[]` — append.
@@ -109,24 +156,31 @@ function assign(target: Record<string, unknown>, segments: string[], value: unkn
109
156
  }
110
157
 
111
158
  const nextSegment = segments[i + 1] ?? ''
112
- const wantsArray = nextSegment === '' || isIndex(nextSegment)
159
+ const wantsArray =
160
+ nextSegment === '' || (isIndex(nextSegment) && Number(nextSegment) < arrayLimit)
113
161
 
114
162
  if (Array.isArray(cursor)) {
115
163
  if (!isIndex(segment)) return
164
+ const array: unknown[] = cursor
116
165
  const index = Number(segment)
117
- const existing = cursor[index]
118
- if (existing === undefined || typeof existing !== 'object' || existing === null) {
119
- cursor[index] = wantsArray ? [] : {}
166
+ const existing: unknown = array[index]
167
+ const child: Container = isContainer(existing) ? existing : wantsArray ? [] : {}
168
+ array[index] = child
169
+ replace = (next) => {
170
+ array[index] = next
120
171
  }
121
- cursor = cursor[index] as Record<string, unknown> | unknown[]
172
+ cursor = child
122
173
  continue
123
174
  }
124
175
 
125
- const existing = cursor[segment]
126
- if (existing === undefined || typeof existing !== 'object' || existing === null) {
127
- cursor[segment] = wantsArray ? [] : {}
176
+ const object: Record<string, unknown> = cursor
177
+ const existing: unknown = object[segment]
178
+ const child: Container = isContainer(existing) ? existing : wantsArray ? [] : {}
179
+ object[segment] = child
180
+ replace = (next) => {
181
+ object[segment] = next
128
182
  }
129
- cursor = cursor[segment] as Record<string, unknown> | unknown[]
183
+ cursor = child
130
184
  }
131
185
  }
132
186
 
@@ -153,27 +207,43 @@ export function parseQueryString(
153
207
  body: string,
154
208
  options: QsParseOptions = {},
155
209
  ): Record<string, unknown> {
156
- const depth = options.depth ?? DEFAULT_DEPTH
210
+ if (!body) return {}
157
211
  const parameterLimit = options.parameterLimit ?? DEFAULT_PARAMETER_LIMIT
158
- const result: Record<string, unknown> = {}
159
- if (!body) return result
160
-
161
- const pairs = body.split('&')
162
- let seen = 0
163
- for (const pair of pairs) {
212
+ const fields: Array<{ name: string; value: string }> = []
213
+ for (const pair of body.split('&')) {
164
214
  if (!pair) continue
165
- if (++seen > parameterLimit) break
166
-
215
+ // Nothing past the limit is even decoded.
216
+ if (fields.length >= parameterLimit) break
167
217
  const eqIdx = pair.indexOf('=')
168
218
  const rawKey = eqIdx === -1 ? pair : pair.slice(0, eqIdx)
169
219
  const rawValue = eqIdx === -1 ? '' : pair.slice(eqIdx + 1)
220
+ fields.push({ name: decodeFormComponent(rawKey), value: decodeFormComponent(rawValue) })
221
+ }
222
+ return parseFormFields(fields, options)
223
+ }
170
224
 
171
- const key = decodeFormComponent(rawKey)
172
- let value: unknown = decodeFormComponent(rawValue)
225
+ /**
226
+ * Nest form fields that arrive as decoded name/value pairs — a multipart
227
+ * body's text parts — with the key grammar and the guards of a urlencoded
228
+ * body. A `<select multiple name="tags[]">` keeps every value, and
229
+ * `user[name]` nests, whether or not the form also carries a file input.
230
+ */
231
+ export function parseFormFields(
232
+ fields: Iterable<{ name: string; value: string }>,
233
+ options: QsParseOptions = {},
234
+ ): Record<string, unknown> {
235
+ const depth = options.depth ?? DEFAULT_DEPTH
236
+ const parameterLimit = options.parameterLimit ?? DEFAULT_PARAMETER_LIMIT
237
+ const arrayLimit = options.arrayLimit ?? DEFAULT_ARRAY_LIMIT
238
+ const result: Record<string, unknown> = {}
239
+
240
+ let seen = 0
241
+ for (const field of fields) {
242
+ if (++seen > parameterLimit) break
243
+ let value: unknown = field.value
173
244
  if (options.trimWhitespaces && typeof value === 'string') value = value.trim()
174
245
  if (options.convertEmptyStringsToNull && value === '') value = null
175
-
176
- assign(result, splitKey(key, depth, options.allowDots ?? true), value)
246
+ assign(result, splitKey(field.name, depth, options.allowDots ?? true), value, arrayLimit)
177
247
  }
178
248
  return result
179
249
  }