@c9up/ream 0.2.29 → 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 (220) hide show
  1. package/README.md +5 -6
  2. package/dist/Codemods.d.ts +8 -0
  3. package/dist/Codemods.d.ts.map +1 -1
  4. package/dist/Codemods.js +29 -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 +5 -0
  10. package/dist/Ignitor.d.ts.map +1 -1
  11. package/dist/Ignitor.js +12 -1
  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 +38 -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 +10 -0
  26. package/dist/bodyparser/qsParse.d.ts.map +1 -1
  27. package/dist/bodyparser/qsParse.js +27 -12
  28. package/dist/bodyparser/qsParse.js.map +1 -1
  29. package/dist/console/prompts.d.ts.map +1 -1
  30. package/dist/console/prompts.js +3 -1
  31. package/dist/console/prompts.js.map +1 -1
  32. package/dist/container/Container.d.ts.map +1 -1
  33. package/dist/container/Container.js +61 -71
  34. package/dist/container/Container.js.map +1 -1
  35. package/dist/events/Emitter.d.ts +12 -0
  36. package/dist/events/Emitter.d.ts.map +1 -1
  37. package/dist/events/Emitter.js +36 -9
  38. package/dist/events/Emitter.js.map +1 -1
  39. package/dist/events/helix/helpers.d.ts.map +1 -1
  40. package/dist/events/helix/helpers.js +19 -9
  41. package/dist/events/helix/helpers.js.map +1 -1
  42. package/dist/graphql/GraphQLEngine.d.ts +8 -0
  43. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  44. package/dist/graphql/GraphQLEngine.js +77 -11
  45. package/dist/graphql/GraphQLEngine.js.map +1 -1
  46. package/dist/helpers/MessageBuilder.d.ts +4 -2
  47. package/dist/helpers/MessageBuilder.d.ts.map +1 -1
  48. package/dist/helpers/MessageBuilder.js +4 -2
  49. package/dist/helpers/MessageBuilder.js.map +1 -1
  50. package/dist/helpers/errors.d.ts +11 -0
  51. package/dist/helpers/errors.d.ts.map +1 -0
  52. package/dist/helpers/errors.js +17 -0
  53. package/dist/helpers/errors.js.map +1 -0
  54. package/dist/helpers/napi-loader.d.ts.map +1 -1
  55. package/dist/helpers/napi-loader.js +3 -2
  56. package/dist/helpers/napi-loader.js.map +1 -1
  57. package/dist/http/HttpContext.d.ts +20 -2
  58. package/dist/http/HttpContext.d.ts.map +1 -1
  59. package/dist/http/HttpContext.js +10 -4
  60. package/dist/http/HttpContext.js.map +1 -1
  61. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  62. package/dist/http/RedirectBuilder.js +4 -0
  63. package/dist/http/RedirectBuilder.js.map +1 -1
  64. package/dist/http/Request.d.ts.map +1 -1
  65. package/dist/http/Request.js +15 -29
  66. package/dist/http/Request.js.map +1 -1
  67. package/dist/http/Response.d.ts +7 -4
  68. package/dist/http/Response.d.ts.map +1 -1
  69. package/dist/http/Response.js +14 -6
  70. package/dist/http/Response.js.map +1 -1
  71. package/dist/http/permissions.d.ts +14 -8
  72. package/dist/http/permissions.d.ts.map +1 -1
  73. package/dist/http/permissions.js +26 -16
  74. package/dist/http/permissions.js.map +1 -1
  75. package/dist/index.d.ts +2 -2
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +1 -1
  78. package/dist/index.js.map +1 -1
  79. package/dist/middleware/Pipeline.js +1 -1
  80. package/dist/middleware/Pipeline.js.map +1 -1
  81. package/dist/native/generated.d.ts +16 -30
  82. package/dist/native/generated.d.ts.map +1 -1
  83. package/dist/profiler/Profiler.d.ts.map +1 -1
  84. package/dist/profiler/Profiler.js +4 -1
  85. package/dist/profiler/Profiler.js.map +1 -1
  86. package/dist/profiler/client.generated.d.ts +5 -0
  87. package/dist/profiler/client.generated.d.ts.map +1 -0
  88. package/dist/profiler/client.generated.js +6 -0
  89. package/dist/profiler/client.generated.js.map +1 -0
  90. package/dist/profiler/collectors.d.ts +92 -13
  91. package/dist/profiler/collectors.d.ts.map +1 -1
  92. package/dist/profiler/collectors.js +106 -19
  93. package/dist/profiler/collectors.js.map +1 -1
  94. package/dist/profiler/endpoint.d.ts +2 -1
  95. package/dist/profiler/endpoint.d.ts.map +1 -1
  96. package/dist/profiler/endpoint.js +25 -1
  97. package/dist/profiler/endpoint.js.map +1 -1
  98. package/dist/profiler/index.d.ts +8 -4
  99. package/dist/profiler/index.d.ts.map +1 -1
  100. package/dist/profiler/index.js +11 -4
  101. package/dist/profiler/index.js.map +1 -1
  102. package/dist/profiler/middleware.d.ts.map +1 -1
  103. package/dist/profiler/middleware.js +76 -10
  104. package/dist/profiler/middleware.js.map +1 -1
  105. package/dist/profiler/preview.d.ts +17 -0
  106. package/dist/profiler/preview.d.ts.map +1 -0
  107. package/dist/profiler/preview.js +100 -0
  108. package/dist/profiler/preview.js.map +1 -0
  109. package/dist/profiler/toolbar.d.ts +15 -23
  110. package/dist/profiler/toolbar.d.ts.map +1 -1
  111. package/dist/profiler/toolbar.js +25 -91
  112. package/dist/profiler/toolbar.js.map +1 -1
  113. package/dist/profiler/types.d.ts +6 -1
  114. package/dist/profiler/types.d.ts.map +1 -1
  115. package/dist/router/Router.d.ts +6 -0
  116. package/dist/router/Router.d.ts.map +1 -1
  117. package/dist/router/Router.js +115 -12
  118. package/dist/router/Router.js.map +1 -1
  119. package/dist/rpc/RpcRouter.js +3 -3
  120. package/dist/rpc/RpcRouter.js.map +1 -1
  121. package/dist/scheduler/Scheduler.js +1 -1
  122. package/dist/scheduler/Scheduler.js.map +1 -1
  123. package/dist/security/CookieSigner.d.ts.map +1 -1
  124. package/dist/security/CookieSigner.js +15 -5
  125. package/dist/security/CookieSigner.js.map +1 -1
  126. package/dist/security/SignedUrl.d.ts.map +1 -1
  127. package/dist/security/SignedUrl.js +10 -18
  128. package/dist/security/SignedUrl.js.map +1 -1
  129. package/dist/server/Server.d.ts +4 -1
  130. package/dist/server/Server.d.ts.map +1 -1
  131. package/dist/server/Server.js.map +1 -1
  132. package/dist/server/nativeLimits.d.ts +9 -1
  133. package/dist/server/nativeLimits.d.ts.map +1 -1
  134. package/dist/server/nativeLimits.js +8 -0
  135. package/dist/server/nativeLimits.js.map +1 -1
  136. package/dist/session/Session.d.ts.map +1 -1
  137. package/dist/session/Session.js +13 -4
  138. package/dist/session/Session.js.map +1 -1
  139. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  140. package/dist/session/drivers/MemoryDriver.js +14 -2
  141. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  142. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  143. package/dist/storage/StaticMiddleware.js +20 -9
  144. package/dist/storage/StaticMiddleware.js.map +1 -1
  145. package/dist/testing/ApiResponse.d.ts.map +1 -1
  146. package/dist/testing/ApiResponse.js +7 -1
  147. package/dist/testing/ApiResponse.js.map +1 -1
  148. package/dist/testing/TestClient.d.ts.map +1 -1
  149. package/dist/testing/TestClient.js +16 -4
  150. package/dist/testing/TestClient.js.map +1 -1
  151. package/dist/utils/Macroable.d.ts +1 -1
  152. package/dist/utils/Macroable.js +2 -2
  153. package/dist/utils/Macroable.js.map +1 -1
  154. package/events.darwin-arm64.node +0 -0
  155. package/events.darwin-x64.node +0 -0
  156. package/events.linux-arm64-gnu.node +0 -0
  157. package/events.linux-x64-gnu.node +0 -0
  158. package/events.win32-x64-msvc.node +0 -0
  159. package/index.darwin-arm64.node +0 -0
  160. package/index.darwin-x64.node +0 -0
  161. package/index.linux-arm64-gnu.node +0 -0
  162. package/index.linux-x64-gnu.node +0 -0
  163. package/index.win32-x64-msvc.node +0 -0
  164. package/package.json +1 -2
  165. package/scheduler.darwin-arm64.node +0 -0
  166. package/scheduler.darwin-x64.node +0 -0
  167. package/scheduler.linux-arm64-gnu.node +0 -0
  168. package/scheduler.linux-x64-gnu.node +0 -0
  169. package/scheduler.win32-x64-msvc.node +0 -0
  170. package/src/Codemods.ts +49 -3
  171. package/src/HttpKernel.ts +18 -7
  172. package/src/Ignitor.ts +21 -4
  173. package/src/bodyparser/BodyParserMiddleware.ts +42 -16
  174. package/src/bodyparser/MultipartFile.ts +46 -67
  175. package/src/bodyparser/parseSize.ts +17 -17
  176. package/src/bodyparser/qsParse.ts +28 -13
  177. package/src/console/prompts.ts +3 -1
  178. package/src/container/Container.ts +97 -81
  179. package/src/events/Emitter.ts +37 -10
  180. package/src/events/helix/helpers.ts +24 -19
  181. package/src/graphql/GraphQLEngine.ts +89 -13
  182. package/src/helpers/MessageBuilder.ts +4 -2
  183. package/src/helpers/errors.ts +16 -0
  184. package/src/helpers/napi-loader.ts +3 -2
  185. package/src/http/HttpContext.ts +20 -4
  186. package/src/http/RedirectBuilder.ts +3 -0
  187. package/src/http/Request.ts +15 -29
  188. package/src/http/Response.ts +15 -7
  189. package/src/http/permissions.ts +31 -15
  190. package/src/index.ts +2 -1
  191. package/src/middleware/Pipeline.ts +1 -1
  192. package/src/native/generated.ts +18 -42
  193. package/src/profiler/Profiler.ts +4 -1
  194. package/src/profiler/client/toolbar.css +595 -0
  195. package/src/profiler/client/toolbar.js +1299 -0
  196. package/src/profiler/client.generated.ts +7 -0
  197. package/src/profiler/collectors.ts +156 -19
  198. package/src/profiler/endpoint.ts +27 -1
  199. package/src/profiler/index.ts +17 -3
  200. package/src/profiler/middleware.ts +74 -9
  201. package/src/profiler/preview.ts +104 -0
  202. package/src/profiler/toolbar.ts +28 -91
  203. package/src/profiler/types.ts +6 -1
  204. package/src/router/Router.ts +136 -13
  205. package/src/rpc/RpcRouter.ts +4 -4
  206. package/src/scheduler/Scheduler.ts +1 -1
  207. package/src/security/CookieSigner.ts +13 -5
  208. package/src/security/SignedUrl.ts +12 -16
  209. package/src/server/Server.ts +4 -1
  210. package/src/server/nativeLimits.ts +18 -1
  211. package/src/session/Session.ts +13 -4
  212. package/src/session/drivers/MemoryDriver.ts +15 -2
  213. package/src/storage/StaticMiddleware.ts +18 -9
  214. package/src/testing/ApiResponse.ts +7 -1
  215. package/src/testing/TestClient.ts +14 -5
  216. package/src/utils/Macroable.ts +2 -2
  217. package/scripts/build-napi-types.mjs +0 -69
  218. package/scripts/copy-napi.mjs +0 -121
  219. package/scripts/generate-mime.mjs +0 -119
  220. package/scripts/generate-napi-types.mjs +0 -187
@@ -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
  }
@@ -206,29 +206,44 @@ export function decodeFormComponent(value: string): string {
206
206
  export function parseQueryString(
207
207
  body: string,
208
208
  options: QsParseOptions = {},
209
+ ): Record<string, unknown> {
210
+ if (!body) return {}
211
+ const parameterLimit = options.parameterLimit ?? DEFAULT_PARAMETER_LIMIT
212
+ const fields: Array<{ name: string; value: string }> = []
213
+ for (const pair of body.split('&')) {
214
+ if (!pair) continue
215
+ // Nothing past the limit is even decoded.
216
+ if (fields.length >= parameterLimit) break
217
+ const eqIdx = pair.indexOf('=')
218
+ const rawKey = eqIdx === -1 ? pair : pair.slice(0, eqIdx)
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
+ }
224
+
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 = {},
209
234
  ): Record<string, unknown> {
210
235
  const depth = options.depth ?? DEFAULT_DEPTH
211
236
  const parameterLimit = options.parameterLimit ?? DEFAULT_PARAMETER_LIMIT
212
237
  const arrayLimit = options.arrayLimit ?? DEFAULT_ARRAY_LIMIT
213
238
  const result: Record<string, unknown> = {}
214
- if (!body) return result
215
239
 
216
- const pairs = body.split('&')
217
240
  let seen = 0
218
- for (const pair of pairs) {
219
- if (!pair) continue
241
+ for (const field of fields) {
220
242
  if (++seen > parameterLimit) break
221
-
222
- const eqIdx = pair.indexOf('=')
223
- const rawKey = eqIdx === -1 ? pair : pair.slice(0, eqIdx)
224
- const rawValue = eqIdx === -1 ? '' : pair.slice(eqIdx + 1)
225
-
226
- const key = decodeFormComponent(rawKey)
227
- let value: unknown = decodeFormComponent(rawValue)
243
+ let value: unknown = field.value
228
244
  if (options.trimWhitespaces && typeof value === 'string') value = value.trim()
229
245
  if (options.convertEmptyStringsToNull && value === '') value = null
230
-
231
- assign(result, splitKey(key, depth, options.allowDots ?? true), value, arrayLimit)
246
+ assign(result, splitKey(field.name, depth, options.allowDots ?? true), value, arrayLimit)
232
247
  }
233
248
  return result
234
249
  }
@@ -513,7 +513,9 @@ export class Prompt {
513
513
  rl.question(query, (answer) => {
514
514
  if (masked) stdout.write('\n')
515
515
  rl.close()
516
- resolve(answer.trim())
516
+ // A secret is returned as typed: a password may begin or end with a
517
+ // space, and trimming it would store or compare a different one.
518
+ resolve(masked ? answer : answer.trim())
517
519
  })
518
520
  })
519
521
  }
@@ -30,10 +30,23 @@ import type { Binding, ServiceFactory, ServiceToken } from './types.js'
30
30
  */
31
31
  const RESERVED_TOKEN_NAMES = new Set(['__proto__', 'constructor', 'prototype'])
32
32
 
33
- /** One resolution chain: the tokens on it, and a set for O(1) membership. */
33
+ /**
34
+ * Where one resolution stands: the tokens above it (a list for the message, a
35
+ * set for O(1) membership), and what it carries down.
36
+ *
37
+ * **Never mutated once in the async context.** Every resolution, and every
38
+ * construction that changes the parent, runs on its OWN copy
39
+ * (`#chain.run({ ...chain, ... })`). The chain used to be one object pushed to
40
+ * and popped from in place, and a chain opened around a whole body
41
+ * (`container.call()` around a command's `run()`, `resolveFor`,
42
+ * `runWithScopedValues`) was then shared by every concurrent task inside it:
43
+ * two tasks asking for the same singleton at once read as `catalog → catalog`,
44
+ * and one task's class could stand as another's parent for a contextual
45
+ * binding. A copy per step costs an array and a set the depth of the chain.
46
+ */
34
47
  interface ResolutionChain {
35
- stack: ServiceToken[]
36
- set: Set<ServiceToken>
48
+ readonly stack: readonly ServiceToken[]
49
+ readonly set: ReadonlySet<ServiceToken>
37
50
  /**
38
51
  * Values bound on the {@link ContainerResolver} that opened this chain.
39
52
  *
@@ -51,8 +64,13 @@ interface ResolutionChain {
51
64
  * `HttpContext`. Comparing this counter before and after a build says whether
52
65
  * it actually read one, so a singleton that touched no request state still
53
66
  * caches exactly as before.
67
+ *
68
+ * A box, the one mutable part, so the copies a chain is walked on all count
69
+ * into it: a read three dependencies down is a read by the singleton being
70
+ * built at the top. Concurrent siblings count into it too, which can only
71
+ * keep a singleton out of the cache, never put a request's into it.
54
72
  */
55
- scopedReads?: number
73
+ readonly scopedReads: { count: number }
56
74
  /**
57
75
  * The class whose dependencies are being resolved right now.
58
76
  *
@@ -60,14 +78,19 @@ interface ResolutionChain {
60
78
  * this", so the lookup needs the direct dependent. Carried on the chain for
61
79
  * the same reason the scoped values are: `#autoConstruct` knows it, and
62
80
  * every method between there and the lookup would otherwise have to pass it
63
- * down. Saved and restored around each construction, so a dependency's own
81
+ * down. Each construction runs on a copy that names it, so a dependency's own
64
82
  * dependencies see IT as their parent, not its parent.
65
83
  */
66
- parent?: abstract new (
84
+ readonly parent?: abstract new (
67
85
  ...args: never[]
68
86
  ) => unknown
69
87
  }
70
88
 
89
+ /** A chain with nothing in it yet. */
90
+ function openChain(extra: Partial<ResolutionChain> = {}): ResolutionChain {
91
+ return { stack: [], set: new Set(), scopedReads: { count: 0 }, ...extra }
92
+ }
93
+
71
94
  export class Container {
72
95
  #bindings: Map<ServiceToken, Binding> = new Map()
73
96
  #singletons: Map<ServiceToken, unknown> = new Map()
@@ -320,9 +343,7 @@ export class Container {
320
343
  // it and nothing outside it does.
321
344
  const chain = this.#chain.getStore()
322
345
  if (chain === undefined) {
323
- return this.#chain.run({ stack: [], set: new Set() }, () =>
324
- this.resolve<T>(token, runtimeValues),
325
- )
346
+ return this.#chain.run(openChain(), () => this.resolve<T>(token, runtimeValues))
326
347
  }
327
348
 
328
349
  if (chain.set.has(key)) {
@@ -332,15 +353,14 @@ export class Container {
332
353
  context: { chain: cycle },
333
354
  })
334
355
  }
335
- chain.stack.push(key)
336
- chain.set.add(key)
337
-
338
- try {
339
- return await this.#resolveInner<T>(key, token, runtimeValues)
340
- } finally {
341
- chain.stack.pop()
342
- chain.set.delete(key)
356
+ // On a copy: see `ResolutionChain`. What this resolution triggers sees the
357
+ // key above it; a sibling running at the same time does not.
358
+ const own: ResolutionChain = {
359
+ ...chain,
360
+ stack: [...chain.stack, key],
361
+ set: new Set(chain.set).add(key),
343
362
  }
363
+ return this.#chain.run(own, () => this.#resolveInner<T>(key, token, runtimeValues))
344
364
  }
345
365
 
346
366
  /**
@@ -364,52 +384,17 @@ export class Container {
364
384
  // its own with nobody in it.
365
385
  const chain = this.#chain.getStore()
366
386
  if (chain === undefined) {
367
- return this.#chain.run({ stack: [], set: new Set(), parent: target }, () =>
387
+ return this.#chain.run(openChain({ parent: target }), () =>
368
388
  this.call(instance, method, runtimeValues),
369
389
  )
370
390
  }
371
- const outerParent = chain.parent
372
- // Declared outside the try so the call below can still read it: only the
373
- // RESOLUTION is guarded, because that is what can throw with the parent
374
- // installed.
375
- const args: unknown[] = []
376
- chain.parent = target
377
- try {
378
- const paramTypes: unknown[] =
379
- Reflect.getMetadata('design:paramtypes', target.prototype, method) ?? []
380
- // `@Inject('token')` on a method parameter — a named binding the reflected
381
- // type cannot express (an interface, or a class registered under a name).
382
- const injectTokens = getMethodInjectTokens(target.prototype, method)
383
-
384
- // `@Inject` counts towards the parameter span too: a method may carry named
385
- // tokens without `design:paramtypes` being emitted for it, and computing the
386
- // span from the reflected types alone left the loop at zero iterations —
387
- // the tokens were read and then never used.
388
- const injectSpan = injectTokens.size === 0 ? 0 : Math.max(...injectTokens.keys()) + 1
389
- const len = Math.max(paramTypes.length, runtimeValues?.length ?? 0, injectSpan)
390
- // Sequential resolution keeps the shared cycle-detection stack consistent.
391
- for (let index = 0; index < len; index += 1) {
392
- // Runtime values take precedence (and fill slots beyond paramTypes).
393
- if (runtimeValues && index < runtimeValues.length) {
394
- args.push(runtimeValues[index])
395
- continue
396
- }
397
- const named = injectTokens.get(index)
398
- if (named !== undefined) {
399
- args.push(await this.resolve(named))
400
- continue
401
- }
402
- const type = paramTypes[index]
403
- args.push(isInjectableClass(type) ? await this.resolve(type) : undefined)
404
- }
405
- } finally {
406
- // RESTORED whatever happened. Without the `finally`, a parameter that
407
- // failed to resolve left the called class installed as the parent, so a
408
- // caller that caught the error saw its contextual bindings applied to
409
- // every later resolution — a binding meant for one handler leaking into
410
- // the whole application.
411
- chain.parent = outerParent
412
- }
391
+ // The parameters are resolved with `target` as the parent, on a copy of
392
+ // the chain (see `ResolutionChain`), so nothing outside them sees it: not
393
+ // the caller once they are resolved, whatever happened, and not a task
394
+ // running beside them. The method itself runs on the caller's chain.
395
+ const args = await this.#chain.run({ ...chain, parent: target }, () =>
396
+ this.#methodArgs(target, method, runtimeValues),
397
+ )
413
398
 
414
399
  const member: unknown = instance[method]
415
400
  if (!isCallable(member)) {
@@ -418,6 +403,44 @@ export class Container {
418
403
  return member.apply(instance, args)
419
404
  }
420
405
 
406
+ /** The arguments `call()` passes to `target.prototype[method]`. */
407
+ async #methodArgs(
408
+ target: new (...args: never[]) => unknown,
409
+ method: string,
410
+ runtimeValues?: unknown[],
411
+ ): Promise<unknown[]> {
412
+ const args: unknown[] = []
413
+ const paramTypes: unknown[] =
414
+ Reflect.getMetadata('design:paramtypes', target.prototype, method) ?? []
415
+ // `@Inject('token')` on a method parameter — a named binding the reflected
416
+ // type cannot express (an interface, or a class registered under a name).
417
+ const injectTokens = getMethodInjectTokens(target.prototype, method)
418
+
419
+ // `@Inject` counts towards the parameter span too: a method may carry named
420
+ // tokens without `design:paramtypes` being emitted for it, and computing the
421
+ // span from the reflected types alone left the loop at zero iterations —
422
+ // the tokens were read and then never used.
423
+ const injectSpan = injectTokens.size === 0 ? 0 : Math.max(...injectTokens.keys()) + 1
424
+ const len = Math.max(paramTypes.length, runtimeValues?.length ?? 0, injectSpan)
425
+ // One at a time, in parameter order: a factory with side effects runs
426
+ // in the order the signature reads.
427
+ for (let index = 0; index < len; index += 1) {
428
+ // Runtime values take precedence (and fill slots beyond paramTypes).
429
+ if (runtimeValues && index < runtimeValues.length) {
430
+ args.push(runtimeValues[index])
431
+ continue
432
+ }
433
+ const named = injectTokens.get(index)
434
+ if (named !== undefined) {
435
+ args.push(await this.resolve(named))
436
+ continue
437
+ }
438
+ const type = paramTypes[index]
439
+ args.push(isInjectableClass(type) ? await this.resolve(type) : undefined)
440
+ }
441
+ return args
442
+ }
443
+
421
444
  /**
422
445
  * A resolver whose {@link ContainerResolver.bindValue} values are isolated
423
446
  * from this container (AdonisJS `container.createResolver()`).
@@ -441,10 +464,7 @@ export class Container {
441
464
  fn: () => Promise<T>,
442
465
  ): Promise<T> {
443
466
  const chain = this.#chain.getStore()
444
- return this.#chain.run(
445
- chain === undefined ? { stack: [], set: new Set(), values } : { ...chain, values },
446
- fn,
447
- )
467
+ return this.#chain.run(chain === undefined ? openChain({ values }) : { ...chain, values }, fn)
448
468
  }
449
469
 
450
470
  /** @internal Validate a token and hand back the value the container keys on. */
@@ -464,9 +484,8 @@ export class Container {
464
484
  ): Promise<T> {
465
485
  if (parent === null) return this.make<T>(token, runtimeValues)
466
486
  const chain = this.#chain.getStore()
467
- return this.#chain.run(
468
- chain === undefined ? { stack: [], set: new Set(), parent } : { ...chain, parent },
469
- () => this.make<T>(token, runtimeValues),
487
+ return this.#chain.run(chain === undefined ? openChain({ parent }) : { ...chain, parent }, () =>
488
+ this.make<T>(token, runtimeValues),
470
489
  )
471
490
  }
472
491
 
@@ -588,7 +607,7 @@ export class Container {
588
607
  // resolves in (`resolveFor`: swaps, resolver values, container values).
589
608
  // No `resolving` hooks: the value was not constructed here.
590
609
  if (store?.values?.has(key)) {
591
- store.scopedReads = (store.scopedReads ?? 0) + 1
610
+ store.scopedReads.count += 1
592
611
  return store.values.get(key) as T
593
612
  }
594
613
 
@@ -619,7 +638,7 @@ export class Container {
619
638
  if (this.#wasPublished(key, shared)) return shared as T
620
639
  }
621
640
 
622
- const readsBefore = store?.scopedReads ?? 0
641
+ const readsBefore = store?.scopedReads.count ?? 0
623
642
  const building = (async (): Promise<unknown> => {
624
643
  const instance = binding.factory ? await binding.factory(this) : undefined
625
644
  // Only cache while this is still the binding that started the build.
@@ -731,7 +750,7 @@ export class Container {
731
750
 
732
751
  // Same rule as the explicit bindings: see `ResolutionChain.scopedReads`.
733
752
  const store = this.#chain.getStore()
734
- const readsBefore = store?.scopedReads ?? 0
753
+ const readsBefore = store?.scopedReads.count ?? 0
735
754
  const building = (async (): Promise<unknown> => {
736
755
  const instance = await this.#autoConstruct(target, runtimeValues)
737
756
  await this.#runResolvingHooks(key, instance)
@@ -754,16 +773,13 @@ export class Container {
754
773
  runtimeValues?: unknown[],
755
774
  ): Promise<unknown> {
756
775
  // Everything resolved inside is a dependency OF `target`, which is what a
757
- // contextual binding keys on. Restored on EVERY exit — the construction has
758
- // three returns and can throw — so `target`'s own parent is back in place.
759
- const store = this.#chain.getStore()
760
- const outerParent = store?.parent
761
- if (store !== undefined) store.parent = target
762
- try {
763
- return await this.#constructWithDeps(target, runtimeValues)
764
- } finally {
765
- if (store !== undefined) store.parent = outerParent
766
- }
776
+ // contextual binding keys on. On a copy of the chain (see
777
+ // `ResolutionChain`): nothing to restore on any of the construction's
778
+ // exits, and a construction running beside this one keeps its own parent.
779
+ const store = this.#chain.getStore() ?? openChain()
780
+ return this.#chain.run({ ...store, parent: target }, () =>
781
+ this.#constructWithDeps(target, runtimeValues),
782
+ )
767
783
  }
768
784
 
769
785
  async #constructWithDeps(
@@ -902,7 +918,7 @@ export class Container {
902
918
  readsBefore: number,
903
919
  ): void {
904
920
  if (scope !== 'singleton') return
905
- if ((store?.scopedReads ?? 0) !== readsBefore) return
921
+ if ((store?.scopedReads.count ?? 0) !== readsBefore) return
906
922
  this.#singletons.set(key, instance)
907
923
  }
908
924