@c9up/ream 0.1.31 → 0.1.33

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 (189) hide show
  1. package/README.md +4 -1
  2. package/dist/Application.d.ts +27 -0
  3. package/dist/Application.d.ts.map +1 -1
  4. package/dist/Application.js +29 -0
  5. package/dist/Application.js.map +1 -1
  6. package/dist/ErrorBoundary.d.ts.map +1 -1
  7. package/dist/ErrorBoundary.js +13 -1
  8. package/dist/ErrorBoundary.js.map +1 -1
  9. package/dist/HttpKernel.d.ts +8 -0
  10. package/dist/HttpKernel.d.ts.map +1 -1
  11. package/dist/HttpKernel.js +35 -12
  12. package/dist/HttpKernel.js.map +1 -1
  13. package/dist/Ignitor.d.ts +8 -0
  14. package/dist/Ignitor.d.ts.map +1 -1
  15. package/dist/Ignitor.js +39 -0
  16. package/dist/Ignitor.js.map +1 -1
  17. package/dist/bodyparser/BodyParserMiddleware.d.ts +11 -4
  18. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  19. package/dist/bodyparser/BodyParserMiddleware.js +55 -43
  20. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  21. package/dist/bodyparser/qsParse.d.ts +52 -0
  22. package/dist/bodyparser/qsParse.d.ts.map +1 -0
  23. package/dist/bodyparser/qsParse.js +161 -0
  24. package/dist/bodyparser/qsParse.js.map +1 -0
  25. package/dist/bootstrap/hyper-server.d.ts.map +1 -1
  26. package/dist/bootstrap/hyper-server.js +2 -8
  27. package/dist/bootstrap/hyper-server.js.map +1 -1
  28. package/dist/container/Container.d.ts +41 -0
  29. package/dist/container/Container.d.ts.map +1 -1
  30. package/dist/container/Container.js +193 -26
  31. package/dist/container/Container.js.map +1 -1
  32. package/dist/container/ContainerResolver.d.ts +46 -0
  33. package/dist/container/ContainerResolver.d.ts.map +1 -0
  34. package/dist/container/ContainerResolver.js +60 -0
  35. package/dist/container/ContainerResolver.js.map +1 -0
  36. package/dist/container/ContextualBindingsBuilder.d.ts +20 -0
  37. package/dist/container/ContextualBindingsBuilder.d.ts.map +1 -0
  38. package/dist/container/ContextualBindingsBuilder.js +31 -0
  39. package/dist/container/ContextualBindingsBuilder.js.map +1 -0
  40. package/dist/container/types.d.ts +9 -6
  41. package/dist/container/types.d.ts.map +1 -1
  42. package/dist/decorators/Service.d.ts +1 -5
  43. package/dist/decorators/Service.d.ts.map +1 -1
  44. package/dist/decorators/Service.js +10 -1
  45. package/dist/decorators/Service.js.map +1 -1
  46. package/dist/events/Emitter.d.ts +66 -6
  47. package/dist/events/Emitter.d.ts.map +1 -1
  48. package/dist/events/Emitter.js +165 -28
  49. package/dist/events/Emitter.js.map +1 -1
  50. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  51. package/dist/graphql/GraphQLEngine.js +5 -5
  52. package/dist/graphql/GraphQLEngine.js.map +1 -1
  53. package/dist/helpers/duration.d.ts +32 -0
  54. package/dist/helpers/duration.d.ts.map +1 -0
  55. package/dist/helpers/duration.js +74 -0
  56. package/dist/helpers/duration.js.map +1 -0
  57. package/dist/helpers/napi-loader.d.ts +10 -1
  58. package/dist/helpers/napi-loader.d.ts.map +1 -1
  59. package/dist/helpers/napi-loader.js +18 -3
  60. package/dist/helpers/napi-loader.js.map +1 -1
  61. package/dist/helpers/types.d.ts +1 -1
  62. package/dist/helpers/types.d.ts.map +1 -1
  63. package/dist/http/Exception.d.ts.map +1 -1
  64. package/dist/http/Exception.js +17 -5
  65. package/dist/http/Exception.js.map +1 -1
  66. package/dist/http/HttpContext.d.ts +16 -5
  67. package/dist/http/HttpContext.d.ts.map +1 -1
  68. package/dist/http/HttpContext.js +4 -2
  69. package/dist/http/HttpContext.js.map +1 -1
  70. package/dist/http/RedirectBuilder.d.ts +48 -2
  71. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  72. package/dist/http/RedirectBuilder.js +95 -14
  73. package/dist/http/RedirectBuilder.js.map +1 -1
  74. package/dist/http/Request.d.ts +128 -8
  75. package/dist/http/Request.d.ts.map +1 -1
  76. package/dist/http/Request.js +259 -8
  77. package/dist/http/Request.js.map +1 -1
  78. package/dist/http/Response.d.ts +74 -3
  79. package/dist/http/Response.d.ts.map +1 -1
  80. package/dist/http/Response.js +177 -4
  81. package/dist/http/Response.js.map +1 -1
  82. package/dist/index.d.ts +9 -3
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +5 -1
  85. package/dist/index.js.map +1 -1
  86. package/dist/router/Router.d.ts +36 -7
  87. package/dist/router/Router.d.ts.map +1 -1
  88. package/dist/router/Router.js +66 -22
  89. package/dist/router/Router.js.map +1 -1
  90. package/dist/security/CookieSigner.d.ts +35 -4
  91. package/dist/security/CookieSigner.d.ts.map +1 -1
  92. package/dist/security/CookieSigner.js +99 -10
  93. package/dist/security/CookieSigner.js.map +1 -1
  94. package/dist/server/Server.d.ts +2 -3
  95. package/dist/server/Server.d.ts.map +1 -1
  96. package/dist/server/Server.js +30 -13
  97. package/dist/server/Server.js.map +1 -1
  98. package/dist/session/ReadOnlyValuesStore.d.ts +24 -0
  99. package/dist/session/ReadOnlyValuesStore.d.ts.map +1 -0
  100. package/dist/session/ReadOnlyValuesStore.js +45 -0
  101. package/dist/session/ReadOnlyValuesStore.js.map +1 -0
  102. package/dist/session/Session.d.ts +181 -9
  103. package/dist/session/Session.d.ts.map +1 -1
  104. package/dist/session/Session.js +284 -33
  105. package/dist/session/Session.js.map +1 -1
  106. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  107. package/dist/session/SessionMiddleware.js +87 -33
  108. package/dist/session/SessionMiddleware.js.map +1 -1
  109. package/dist/session/drivers/DatabaseDriver.d.ts +66 -0
  110. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -0
  111. package/dist/session/drivers/DatabaseDriver.js +132 -0
  112. package/dist/session/drivers/DatabaseDriver.js.map +1 -0
  113. package/dist/session/drivers/FileDriver.d.ts +22 -0
  114. package/dist/session/drivers/FileDriver.d.ts.map +1 -0
  115. package/dist/session/drivers/FileDriver.js +66 -0
  116. package/dist/session/drivers/FileDriver.js.map +1 -0
  117. package/dist/session/drivers/MemoryDriver.d.ts +10 -2
  118. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  119. package/dist/session/drivers/MemoryDriver.js +70 -0
  120. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  121. package/dist/session/drivers/RedisDriver.d.ts +10 -2
  122. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  123. package/dist/session/drivers/RedisDriver.js +58 -0
  124. package/dist/session/drivers/RedisDriver.js.map +1 -1
  125. package/dist/session/templateTags.d.ts +42 -0
  126. package/dist/session/templateTags.d.ts.map +1 -0
  127. package/dist/session/templateTags.js +101 -0
  128. package/dist/session/templateTags.js.map +1 -0
  129. package/dist/testing/RequestBuilder.d.ts +38 -1
  130. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  131. package/dist/testing/RequestBuilder.js +40 -1
  132. package/dist/testing/RequestBuilder.js.map +1 -1
  133. package/dist/testing/TestClient.d.ts +3 -1
  134. package/dist/testing/TestClient.d.ts.map +1 -1
  135. package/dist/testing/TestClient.js +3 -1
  136. package/dist/testing/TestClient.js.map +1 -1
  137. package/dist/types/helpers.d.ts +1 -1
  138. package/dist/types/helpers.d.ts.map +1 -1
  139. package/dist/utils/Macroable.d.ts +14 -5
  140. package/dist/utils/Macroable.d.ts.map +1 -1
  141. package/dist/utils/Macroable.js +20 -18
  142. package/dist/utils/Macroable.js.map +1 -1
  143. package/events.win32-x64-msvc.node +0 -0
  144. package/index.win32-x64-msvc.node +0 -0
  145. package/package.json +1 -1
  146. package/scheduler.win32-x64-msvc.node +0 -0
  147. package/src/Application.ts +39 -0
  148. package/src/ErrorBoundary.ts +13 -1
  149. package/src/HttpKernel.ts +45 -19
  150. package/src/Ignitor.ts +39 -0
  151. package/src/bodyparser/BodyParserMiddleware.ts +66 -44
  152. package/src/bodyparser/qsParse.ts +179 -0
  153. package/src/bootstrap/hyper-server.ts +2 -9
  154. package/src/container/Container.ts +265 -25
  155. package/src/container/ContainerResolver.ts +84 -0
  156. package/src/container/ContextualBindingsBuilder.ts +41 -0
  157. package/src/container/types.ts +11 -7
  158. package/src/decorators/Service.ts +16 -3
  159. package/src/events/Emitter.ts +200 -25
  160. package/src/graphql/GraphQLEngine.ts +7 -8
  161. package/src/helpers/duration.ts +71 -0
  162. package/src/helpers/napi-loader.ts +19 -3
  163. package/src/helpers/types.ts +4 -1
  164. package/src/http/Exception.ts +17 -5
  165. package/src/http/HttpContext.ts +20 -5
  166. package/src/http/RedirectBuilder.ts +126 -11
  167. package/src/http/Request.ts +283 -13
  168. package/src/http/Response.ts +213 -6
  169. package/src/index.ts +17 -3
  170. package/src/router/Router.ts +98 -33
  171. package/src/security/CookieSigner.ts +102 -10
  172. package/src/server/Server.ts +36 -16
  173. package/src/session/ReadOnlyValuesStore.ts +52 -0
  174. package/src/session/Session.ts +364 -37
  175. package/src/session/SessionMiddleware.ts +93 -30
  176. package/src/session/drivers/DatabaseDriver.ts +184 -0
  177. package/src/session/drivers/FileDriver.ts +82 -0
  178. package/src/session/drivers/MemoryDriver.ts +75 -2
  179. package/src/session/drivers/RedisDriver.ts +77 -2
  180. package/src/session/templateTags.ts +124 -0
  181. package/src/testing/RequestBuilder.ts +76 -0
  182. package/src/testing/TestClient.ts +10 -2
  183. package/src/types/helpers.ts +4 -1
  184. package/src/utils/Macroable.ts +23 -8
  185. package/dist/utils/inflect.d.ts +0 -17
  186. package/dist/utils/inflect.d.ts.map +0 -1
  187. package/dist/utils/inflect.js +0 -32
  188. package/dist/utils/inflect.js.map +0 -1
  189. package/src/utils/inflect.ts +0 -29
@@ -20,12 +20,21 @@ import { Container } from './container/Container.js'
20
20
  import type { AppContext, ProviderContract } from './Provider.js'
21
21
  import { callProviderPhase } from './Provider.js'
22
22
 
23
+ /**
24
+ * How far the app intends to go in its lifecycle.
25
+ *
26
+ * `run` is a real boot. `warmup` means the app is being INSPECTED — a codegen
27
+ * or listing command — so it will never become ready.
28
+ */
29
+ export type ApplicationMode = 'run' | 'warmup'
30
+
23
31
  export class Application implements AppContext {
24
32
  readonly container: Container
25
33
  readonly config: ConfigStore
26
34
  #appRoot?: URL
27
35
  private providers: ProviderContract[] = []
28
36
  private _booted = false
37
+ #mode: ApplicationMode = 'run'
29
38
  private _bootingHooks: Array<() => Promise<void> | void> = []
30
39
  private _bootedHooks: Array<() => Promise<void> | void> = []
31
40
  private _shutdownHooks: Array<() => Promise<void> | void> = []
@@ -35,6 +44,36 @@ export class Application implements AppContext {
35
44
  this.config = new ConfigStore()
36
45
  }
37
46
 
47
+ /**
48
+ * The current mode (AdonisJS `getMode`).
49
+ *
50
+ * A provider reads it to skip its SIDE EFFECTS — starting queue workers,
51
+ * opening a connection — when the app is only being inspected. It must never
52
+ * change which bindings are registered: the app being inspected has to match
53
+ * the app that runs, or the generated types describe something else.
54
+ *
55
+ * async start() {
56
+ * if (this.app.getMode() !== 'run') return
57
+ * await startQueueWorkers()
58
+ * }
59
+ */
60
+ getMode(): ApplicationMode {
61
+ return this.#mode
62
+ }
63
+
64
+ /**
65
+ * Switch the mode (AdonisJS `setMode`). Only before boot — afterwards the
66
+ * side effects a provider skipped have already been skipped, and pretending
67
+ * otherwise would leave the app half-started.
68
+ */
69
+ setMode(mode: ApplicationMode): this {
70
+ if (this._booted) {
71
+ throw new Error(`Cannot switch to '${mode}' mode: the application is already booted.`)
72
+ }
73
+ this.#mode = mode
74
+ return this
75
+ }
76
+
38
77
  // ─── Paths ────────────────────────────────────────────────
39
78
  // AdonisJS-style path helpers — resolve against the project root passed to
40
79
  // the Ignitor (`new Ignitor(new URL('../', import.meta.url))`). App code uses
@@ -142,7 +142,15 @@ export class ErrorBoundary {
142
142
  process.stderr.write(`[ErrorBoundary] Failed to emit: ${JSON.stringify(event)}\n`)
143
143
  }
144
144
 
145
- if (this.devMode) {
145
+ // A fatal always reaches stderr, dev or not.
146
+ //
147
+ // Installing this boundary REPLACES Node's own handling of an unhandled
148
+ // rejection, which prints the reason and exits. An app that registered no
149
+ // error listener would otherwise swallow every one of them silently — the
150
+ // boundary would turn each forgotten `await` from a loud crash into
151
+ // nothing at all, which is the opposite of what it is for. A duplicated
152
+ // line when a listener also logs it is a much smaller price.
153
+ if (this.devMode || event.type === 'system.fatal') {
146
154
  const prefix =
147
155
  event.type === 'system.fatal'
148
156
  ? '✗ FATAL'
@@ -152,6 +160,10 @@ export class ErrorBoundary {
152
160
  ? '⚠ SECURITY'
153
161
  : '✗ SERVICE'
154
162
  process.stderr.write(`${prefix} [${event.source}] ${event.message}\n`)
163
+ // The stack is the only part that says WHERE the rejection came from.
164
+ if (event.type === 'system.fatal' && event.originalError !== undefined) {
165
+ process.stderr.write(`${event.originalError}\n`)
166
+ }
155
167
  }
156
168
  }
157
169
  }
package/src/HttpKernel.ts CHANGED
@@ -20,7 +20,7 @@ import type {
20
20
  RuntimeValidator,
21
21
  } from './middleware/Pipeline.js'
22
22
  import { compose } from './middleware/Pipeline.js'
23
- import type { RouteDefinition, Router } from './router/Router.js'
23
+ import type { AnyConstructor, RouteDefinition, Router } from './router/Router.js'
24
24
  import type { CookieSigner } from './security/CookieSigner.js'
25
25
  import type { SignedUrl } from './security/SignedUrl.js'
26
26
  import type { Dict } from './types/helpers.js'
@@ -41,6 +41,14 @@ export interface HttpKernelConfig {
41
41
  * SSE path with a clean error — used by mock servers in unit tests.
42
42
  */
43
43
  streamBackend?: () => import('./http/SseStream.js').StreamBackend | undefined
44
+ /**
45
+ * Honour a `_method` form field on POST (AdonisJS `http.allowMethodSpoofing`).
46
+ *
47
+ * OFF by default, as upstream. An HTML form cannot send PUT or DELETE, so an
48
+ * app that relies on the workaround has to say so — otherwise any POST body
49
+ * could rewrite itself into a DELETE.
50
+ */
51
+ allowMethodSpoofing?: boolean
44
52
  }
45
53
 
46
54
  export interface HttpKernelResponse {
@@ -152,17 +160,22 @@ export function createHttpKernel(
152
160
  }
153
161
  : { pattern: '', middleware: [] }
154
162
 
155
- // 4. Create HttpContext. Pass the app container as the per-request
156
- // `ctx.containerResolver` (Adonis idiom) so agnostic middleware
157
- // (Warden, Blackhole) resolve host services from the context they are
158
- // handed instead of importing `@c9up/ream/services/app` at runtime.
159
- const ctx = new HttpContext(
160
- correlationId,
161
- reqData,
162
- match?.params ?? {},
163
- routeInfo,
164
- config.container,
165
- )
163
+ // 4. Create HttpContext with its OWN resolver (Adonis
164
+ // `container.createResolver()`), so agnostic middleware (Warden,
165
+ // Blackhole) resolve host services from the context they are handed
166
+ // instead of importing `@c9up/ream/services/app` at runtime — and so a
167
+ // value bound for this request cannot be seen by any other.
168
+ const resolver = config.container?.createResolver()
169
+ const ctx = new HttpContext(correlationId, reqData, match?.params ?? {}, routeInfo, resolver)
170
+ // What Adonis binds on every request's resolver: the context itself, so a
171
+ // controller or service can take `HttpContext` as a constructor dependency.
172
+ resolver?.bindValue(HttpContext, ctx)
173
+ if (config.allowMethodSpoofing === true) {
174
+ // Was never wired: the setter existed but nothing called it, so a
175
+ // migrated config asking for spoofing got a `_method` field that did
176
+ // nothing at all.
177
+ ctx.request.setMethodSpoofing(true)
178
+ }
166
179
  ctx.setRouteUrlResolver((name, params) => config.router.urlFor(name, params))
167
180
  // Inject the async-resolved APP_KEY services + base logger (resolved once).
168
181
  await resolveServices()
@@ -296,7 +309,11 @@ export function createHttpKernel(
296
309
  path: reqData.path,
297
310
  status: ctx.response.getStatus(),
298
311
  })
299
- return serializeResponse(ctx)
312
+ const serialized = await serializeResponse(ctx)
313
+ // The body is built; anything registered with `response.onFinish()` runs
314
+ // now — after the answer is ready, before we hand it back.
315
+ ctx.response.runFinishCallbacks()
316
+ return serialized
300
317
  } catch (error) {
301
318
  // If the handler opened an SSE stream via `response.sse()` and
302
319
  // THEN threw, the reserved stream id is still on the response.
@@ -334,18 +351,24 @@ export function createHttpKernel(
334
351
  path: reqData.path,
335
352
  status: ctx.response.getStatus(),
336
353
  })
337
- return serializeResponse(ctx)
354
+ const serialized = await serializeResponse(ctx)
355
+ // The body is built; anything registered with `response.onFinish()` runs
356
+ // now — after the answer is ready, before we hand it back.
357
+ ctx.response.runFinishCallbacks()
358
+ return serialized
338
359
  }
339
360
  }
340
361
  }
341
362
 
342
363
  function createControllerHandler(
343
- // biome-ignore lint/suspicious/noExplicitAny: see ControllerAction type — IoC resolves constructor params
344
- controller: { target: new (...args: any[]) => any; method: string },
364
+ controller: { target: AnyConstructor; method: string },
345
365
  container?: Container,
346
366
  ): (ctx: HttpContext) => Promise<void> {
347
367
  return async (ctx: HttpContext) => {
348
- const instance = container ? await container.make(controller.target) : new controller.target()
368
+ // Through the REQUEST resolver when there is one: a controller taking
369
+ // `HttpContext` as a dependency must get this request's, not a stale one.
370
+ const resolve = ctx.containerResolver ?? container
371
+ const instance = resolve ? await resolve.make(controller.target) : new controller.target()
349
372
  const method = (instance as Record<string, (ctx: HttpContext) => Promise<void> | void>)[
350
373
  controller.method
351
374
  ]
@@ -363,7 +386,7 @@ function createControllerHandler(
363
386
  * Returns empty arrays if no decorators are present.
364
387
  */
365
388
  function readControllerGuardMetadata(
366
- target: new (...args: unknown[]) => unknown,
389
+ target: AnyConstructor,
367
390
  method: string,
368
391
  ): { guards: string[]; roles: string[]; permissions: string[] } {
369
392
  try {
@@ -379,7 +402,10 @@ function readControllerGuardMetadata(
379
402
  }
380
403
  }
381
404
 
382
- function serializeResponse(ctx: HttpContext): HttpKernelResponse {
405
+ async function serializeResponse(ctx: HttpContext): Promise<HttpKernelResponse> {
406
+ // A body still being drained by `response.stream()` — upstream returns void
407
+ // there, so a migrated controller does not await it.
408
+ await ctx.response.settle()
383
409
  const streamId = ctx.response.getStreamId()
384
410
  return {
385
411
  status: ctx.response.getStatus(),
package/src/Ignitor.ts CHANGED
@@ -29,6 +29,7 @@ import { startHotReload } from './HotReload.js'
29
29
  import type { HttpKernelRequest, HttpKernelResponse } from './HttpKernel.js'
30
30
  import { createHttpKernel } from './HttpKernel.js'
31
31
  import { ExceptionHandler } from './http/Exception.js'
32
+ import { HttpContext } from './http/HttpContext.js'
32
33
  import type { MiddlewareFunction } from './middleware/Pipeline.js'
33
34
  import { MiddlewareRegistry } from './middleware/Pipeline.js'
34
35
  import type { AppContext, ProviderContract } from './Provider.js'
@@ -40,6 +41,7 @@ import { Server } from './server/Server.js'
40
41
  import { clearApp, setApp } from './services/app.js'
41
42
  import { clearRouter, setRouter } from './services/router.js'
42
43
  import { clearServer, setServer } from './services/server.js'
44
+ import { registerSessionTemplateTags } from './session/templateTags.js'
43
45
 
44
46
  /** Application environment. */
45
47
  export type AppEnvironment = 'web' | 'console' | 'test' | 'unknown'
@@ -253,6 +255,12 @@ export class Ignitor {
253
255
  this.app.container.singleton('server', () => this.server)
254
256
  this.app.container.singleton('middleware', () => this.middleware)
255
257
  this.app.container.singleton('app', () => this.app)
258
+ // The HttpContext CLASS, so an integration provider can extend it the way
259
+ // AdonisJS does — `HttpContext.getter('view', …)` is how the view layer
260
+ // attaches `ctx.view`. Exported from the barrel too, but a provider that
261
+ // must stay agnostic reaches it through the container instead of importing
262
+ // ream at runtime.
263
+ this.app.container.singleton('HttpContext', () => HttpContext)
256
264
  // `appRoot` is the URL passed to `new Ignitor(new URL('../', import.meta.url))`.
257
265
  // Providers resolve it through the container so they can interpret
258
266
  // relative paths in config files (e.g. `pages.root: './resources/pages'`)
@@ -431,6 +439,30 @@ export class Ignitor {
431
439
  loadEnvFiles(this.appRoot, { skipEnvLocal: this.environment === 'test' })
432
440
  }
433
441
 
442
+ /**
443
+ * Give the template engine `@error` / `@errors` / `@inputError` /
444
+ * `@flashMessage`, as AdonisJS's session plugin does.
445
+ *
446
+ * The engine is duck-typed out of the container so ream carries no dependency
447
+ * on it, and an app with no template layer simply skips this.
448
+ */
449
+ private async registerSessionTemplateTags(): Promise<void> {
450
+ for (const token of ['inker', 'view']) {
451
+ if (!this.app.container.has(token)) continue
452
+ const binding: unknown = await this.app.container.resolve(token)
453
+ for (const candidate of [binding, Reflect.get(Object(binding), '_templates')]) {
454
+ const register = Reflect.get(Object(candidate), 'registerTag')
455
+ if (typeof register !== 'function') continue
456
+ registerSessionTemplateTags({
457
+ registerTag: (tag) => {
458
+ register.call(candidate, tag)
459
+ },
460
+ })
461
+ return
462
+ }
463
+ }
464
+ }
465
+
434
466
  private async phaseRegister(): Promise<void> {
435
467
  // Auto-load config/*.ts files into app.config
436
468
  await this.autoloadConfig()
@@ -527,6 +559,8 @@ export class Ignitor {
527
559
  onError: (error, ctx) => {
528
560
  this.errorBoundary.serviceError('HttpKernel', error, ctx.id)
529
561
  },
562
+ // AdonisJS `config/app.ts` → `http: { allowMethodSpoofing: true }`.
563
+ allowMethodSpoofing: this.app.config.get<boolean>('app.http.allowMethodSpoofing') === true,
530
564
  streamBackend: () => {
531
565
  const server = this._httpServer
532
566
  if (
@@ -601,6 +635,11 @@ export class Ignitor {
601
635
  // Install error boundary
602
636
  this.errorBoundary.install()
603
637
 
638
+ // Publish the flash-message tags to the template engine, if one is
639
+ // installed. After boot(), so the engine's own provider has bound it;
640
+ // before ready(), so a provider that pre-renders finds the tags in place.
641
+ await this.registerSessionTemplateTags()
642
+
604
643
  // Call ready() on providers
605
644
  for (const provider of this.providers) {
606
645
  await callProviderPhase(provider, 'ready')
@@ -9,25 +9,32 @@
9
9
  import type { HttpContext } from '../http/HttpContext.js'
10
10
  import { hydrateMultipartPayload } from './MultipartFile.js'
11
11
  import { parseSize } from './parseSize.js'
12
+ import { parseQueryString, type QsParseOptions } from './qsParse.js'
12
13
 
13
14
  export interface BodyParserConfig {
14
15
  json?: {
15
- enabled?: boolean
16
16
  limit?: string // e.g. '1mb'
17
17
  types?: string[]
18
18
  }
19
19
  form?: {
20
- enabled?: boolean
21
20
  limit?: string
22
21
  types?: string[]
22
+ /**
23
+ * Turn `''` into `null`. AdonisJS defaults this to `true`, so an empty
24
+ * text input arrives as `null` rather than an empty string — which is what
25
+ * a "nullable" validation rule expects to see.
26
+ */
27
+ convertEmptyStringsToNull?: boolean
28
+ /** Trim surrounding whitespace. AdonisJS defaults this to `true`. */
29
+ trimWhitespaces?: boolean
30
+ /** Options for the bracket-notation parser (`depth`, `parameterLimit`). */
31
+ queryString?: QsParseOptions
23
32
  }
24
33
  raw?: {
25
- enabled?: boolean
26
34
  limit?: string
27
35
  types?: string[]
28
36
  }
29
37
  multipart?: {
30
- enabled?: boolean
31
38
  /** Total multipart body size cap (sum of all file sizes). Default: '20mb'. */
32
39
  limit?: string
33
40
  /** Maximum number of files per request. Default: 20. */
@@ -47,22 +54,24 @@ interface ResolvedBodyParserConfig {
47
54
 
48
55
  const DEFAULT_CONFIG: ResolvedBodyParserConfig = {
49
56
  json: {
50
- enabled: true,
51
57
  limit: '1mb',
52
58
  types: ['application/json', 'application/vnd.api+json'],
53
59
  },
54
60
  form: {
55
- enabled: true,
56
61
  limit: '1mb',
57
62
  types: ['application/x-www-form-urlencoded'],
63
+ // AdonisJS ships both as `true` (define_config: form.convertEmptyStringsToNull
64
+ // / trimWhitespaces), so a migrated form behaves the same here.
65
+ convertEmptyStringsToNull: true,
66
+ trimWhitespaces: true,
67
+ queryString: {},
58
68
  },
59
69
  raw: {
60
- enabled: false,
70
+ // AdonisJS parses `text/*` out of the box (define_config: raw.types).
61
71
  limit: '1mb',
62
- types: ['text/plain'],
72
+ types: ['text/*'],
63
73
  },
64
74
  multipart: {
65
- enabled: true,
66
75
  limit: '20mb',
67
76
  maxFiles: 20,
68
77
  maxFields: 500,
@@ -75,6 +84,7 @@ export default class BodyParserMiddleware {
75
84
  #config: ResolvedBodyParserConfig
76
85
 
77
86
  constructor(config?: BodyParserConfig) {
87
+ assertNoEnabledFlag(config)
78
88
  this.#config = {
79
89
  json: { ...DEFAULT_CONFIG.json, ...config?.json },
80
90
  form: { ...DEFAULT_CONFIG.form, ...config?.form },
@@ -96,26 +106,32 @@ export default class BodyParserMiddleware {
96
106
  }
97
107
 
98
108
  // JSON
99
- if (this.#config.json.enabled && matchesType(contentType, this.#config.json.types)) {
100
- // Request already lazy-parses JSON — nothing to do
109
+ if (matchesType(contentType, this.#config.json.types)) {
110
+ // `Request` lazy-parses JSON on first read; nothing to do here.
101
111
  }
102
112
 
103
113
  // Form URL-encoded
104
- if (this.#config.form.enabled && matchesType(contentType, this.#config.form.types)) {
105
- ctx.request.setParsedBody(parseFormUrlEncoded(rawBody))
114
+ if (matchesType(contentType, this.#config.form.types)) {
115
+ ctx.request.setParsedBody(
116
+ parseQueryString(rawBody, {
117
+ ...this.#config.form.queryString,
118
+ convertEmptyStringsToNull: this.#config.form.convertEmptyStringsToNull,
119
+ trimWhitespaces: this.#config.form.trimWhitespaces,
120
+ }),
121
+ )
106
122
  }
107
123
 
108
124
  // Raw text — wrap the string under `_body` so consumers can still
109
125
  // reach it through `request.input('_body')`. This matches the fallback
110
126
  // shape used by `Request.#ensureParsedBody` when JSON yields a non-object.
111
- if (this.#config.raw.enabled && matchesType(contentType, this.#config.raw.types)) {
127
+ if (matchesType(contentType, this.#config.raw.types)) {
112
128
  ctx.request.setParsedBody({ _body: rawBody })
113
129
  }
114
130
 
115
131
  // Multipart — the Rust-side HyperServer parses the body server-side
116
132
  // (multer crate) and ships the structured payload on `request.multipart`.
117
133
  // JS just hydrates `MultipartFile` instances from the typed envelope.
118
- if (this.#config.multipart.enabled && matchesType(contentType, this.#config.multipart.types)) {
134
+ if (matchesType(contentType, this.#config.multipart.types)) {
119
135
  const payload = ctx.request.multipart()
120
136
  if (payload) {
121
137
  if (this.#rejectMultipart(ctx, payload.files, payload.fields)) return
@@ -177,38 +193,44 @@ export default class BodyParserMiddleware {
177
193
  }
178
194
  }
179
195
 
196
+ /**
197
+ * Whether a request's content-type is claimed by one of `types`.
198
+ *
199
+ * AdonisJS configures wildcards (`raw.types` defaults to `text/*`), so a bare
200
+ * substring test would never match `text/plain` against `text/*`. Parameters
201
+ * are stripped first: `application/json; charset=utf-8` is a JSON body.
202
+ */
180
203
  function matchesType(contentType: string, types: string[]): boolean {
181
- return types.some((t) => contentType.includes(t))
182
- }
183
-
184
- function parseFormUrlEncoded(body: string): Record<string, unknown> {
185
- const result: Record<string, unknown> = {}
186
- if (!body) return result
187
- for (const pair of body.split('&')) {
188
- if (!pair) continue
189
- const eqIdx = pair.indexOf('=')
190
- if (eqIdx === -1) {
191
- result[decodeFormComponent(pair)] = ''
192
- } else {
193
- result[decodeFormComponent(pair.slice(0, eqIdx))] = decodeFormComponent(pair.slice(eqIdx + 1))
204
+ const actual = (contentType.split(';')[0] ?? '').trim().toLowerCase()
205
+ if (!actual) return false
206
+ return types.some((candidate) => {
207
+ const pattern = candidate.trim().toLowerCase()
208
+ if (pattern === '*/*') return true
209
+ if (pattern.endsWith('/*')) {
210
+ return actual.startsWith(`${pattern.slice(0, -1)}`)
194
211
  }
195
- }
196
- return result
212
+ return actual === pattern
213
+ })
197
214
  }
198
215
 
199
- // `application/x-www-form-urlencoded` reserves `+` for space (RFC 1866 / WHATWG
200
- // URL form spec), so the substitution must happen before percent-decoding —
201
- // otherwise a literal `+` (encoded as `%2B`) would be turned into a space too.
202
- //
203
- // Malformed percent-encoding (e.g. a truncated `a=%E0%A4%A`) makes
204
- // decodeURIComponent throw URIError; mirror Request.ts#safeDecode and fall back
205
- // to the substituted-but-undecoded value so an invalid body is a parse miss,
206
- // not a 500.
207
- function decodeFormComponent(value: string): string {
208
- const spaced = value.replace(/\+/g, ' ')
209
- try {
210
- return decodeURIComponent(spaced)
211
- } catch {
212
- return spaced
216
+ /**
217
+ * `enabled` was an atlas-era invention: AdonisJS has no such flag, and a parser
218
+ * is turned off by giving it no `types`. It also never worked for JSON, which
219
+ * `Request` lazy-parses on first read regardless.
220
+ *
221
+ * Silently ignoring a config key that used to mean something would re-enable a
222
+ * parser an app deliberately switched off, so it fails loudly with the
223
+ * replacement spelled out.
224
+ */
225
+ function assertNoEnabledFlag(config?: BodyParserConfig): void {
226
+ if (!config) return
227
+ for (const section of ['json', 'form', 'raw', 'multipart'] as const) {
228
+ const value: unknown = config[section]
229
+ if (value !== null && typeof value === 'object' && 'enabled' in value) {
230
+ throw new Error(
231
+ `[E_BODYPARSER_CONFIG] \`${section}.enabled\` is no longer supported — AdonisJS has no such option. ` +
232
+ `To disable this parser, give it no types: \`${section}: { types: [] }\`.`,
233
+ )
234
+ }
213
235
  }
214
236
  }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Bracket-notation form parsing — what AdonisJS gets from `qs`.
3
+ *
4
+ * `@adonisjs/bodyparser` runs `qs.parse(body, options.qs)` on every
5
+ * `application/x-www-form-urlencoded` body, so `user[name]=ada` arrives as
6
+ * `{ user: { name: 'ada' } }` and a repeated `tags[]` becomes an array. Parsing
7
+ * flat instead does not merely lose the nesting: a repeated key OVERWRITES,
8
+ * so a multi-select or a checkbox group silently submits only its last value.
9
+ *
10
+ * Reimplemented rather than depending on `qs` — this package ships no runtime
11
+ * dependency it can write in a screenful, and the limits below are easier to
12
+ * defend when they are visible.
13
+ *
14
+ * Three guards, all of which `qs` also applies:
15
+ *
16
+ * - **Prototype keys are dropped.** `__proto__[x]=1` in a form body is the
17
+ * classic prototype-pollution vector; the key never reaches an object.
18
+ * - **Depth is capped** (`qs` defaults to 5). Beyond it the remainder is kept
19
+ * as a literal key rather than allocating unbounded nesting.
20
+ * - **Parameter count is capped** (`qs` defaults to 1000), so a body of a
21
+ * million `a[0]`, `a[1]`… cannot be turned into a million-entry object.
22
+ */
23
+
24
+ /** `qs` defaults, reproduced so the behaviour is the same out of the box. */
25
+ export const DEFAULT_DEPTH = 5
26
+ export const DEFAULT_PARAMETER_LIMIT = 1000
27
+
28
+ export interface QsParseOptions {
29
+ /**
30
+ * Treat `user.name` as `user[name]`. AdonisJS forces this ON for form
31
+ * bodies (`queryStringOptions.allowDots ??= true`), so a dotted field name
32
+ * nests there and must nest here.
33
+ */
34
+ allowDots?: boolean
35
+ depth?: number
36
+ parameterLimit?: number
37
+ /** Turn `''` into `null`, as AdonisJS' `convertEmptyStringsToNull` does. */
38
+ convertEmptyStringsToNull?: boolean
39
+ /** Trim surrounding whitespace, as AdonisJS' `trimWhitespaces` does. */
40
+ trimWhitespaces?: boolean
41
+ }
42
+
43
+ /** Keys that must never be written onto an object. */
44
+ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
45
+
46
+ /**
47
+ * Split `user[address][city]` into `['user', 'address', 'city']`, and
48
+ * `tags[]` into `['tags', '']` — an empty segment meaning "append".
49
+ * Segments past `depth` are folded back into one literal key, which is what
50
+ * `qs` does rather than erroring.
51
+ */
52
+ function splitKey(key: string, depth: number, allowDots: boolean): string[] {
53
+ // `user.name` → `user[name]` before anything else, so both spellings take
54
+ // the same path. AdonisJS forces allowDots on for form bodies.
55
+ const key2 = allowDots && !key.startsWith('.') ? key.replace(/\.([^.[\]]+)/g, '[$1]') : key
56
+ const firstBracket = key2.indexOf('[')
57
+ if (firstBracket === -1) return [key2]
58
+ const root = key2.slice(0, firstBracket)
59
+ const segments: string[] = [root]
60
+ let rest = key2.slice(firstBracket)
61
+ while (segments.length <= depth) {
62
+ const match = /^\[([^\]]*)\]/.exec(rest)
63
+ if (!match) break
64
+ segments.push(match[1] ?? '')
65
+ rest = rest.slice(match[0].length)
66
+ if (rest.length === 0) return segments
67
+ }
68
+ // Anything left over (too deep, or malformed) stays literal on the last
69
+ // segment — no value is dropped, it is just not nested any further.
70
+ if (rest.length > 0) {
71
+ segments[segments.length - 1] = `${segments[segments.length - 1] ?? ''}${rest}`
72
+ }
73
+ return segments
74
+ }
75
+
76
+ function isIndex(segment: string): boolean {
77
+ return /^\d+$/.test(segment)
78
+ }
79
+
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
83
+ for (let i = 0; i < segments.length; i++) {
84
+ const segment = segments[i] ?? ''
85
+ const last = i === segments.length - 1
86
+
87
+ if (FORBIDDEN_KEYS.has(segment)) return
88
+
89
+ if (last) {
90
+ if (segment === '') {
91
+ // `tags[]` — append.
92
+ if (Array.isArray(cursor)) cursor.push(value)
93
+ return
94
+ }
95
+ if (Array.isArray(cursor)) {
96
+ if (isIndex(segment)) cursor[Number(segment)] = value
97
+ return
98
+ }
99
+ const existing = cursor[segment]
100
+ if (existing === undefined) {
101
+ cursor[segment] = value
102
+ return
103
+ }
104
+ // A key repeated WITHOUT brackets collects too, as `qs` does — losing
105
+ // the earlier value is the bug this whole file exists to fix.
106
+ if (Array.isArray(existing)) existing.push(value)
107
+ else cursor[segment] = [existing, value]
108
+ return
109
+ }
110
+
111
+ const nextSegment = segments[i + 1] ?? ''
112
+ const wantsArray = nextSegment === '' || isIndex(nextSegment)
113
+
114
+ if (Array.isArray(cursor)) {
115
+ if (!isIndex(segment)) return
116
+ const index = Number(segment)
117
+ const existing = cursor[index]
118
+ if (existing === undefined || typeof existing !== 'object' || existing === null) {
119
+ cursor[index] = wantsArray ? [] : {}
120
+ }
121
+ cursor = cursor[index] as Record<string, unknown> | unknown[]
122
+ continue
123
+ }
124
+
125
+ const existing = cursor[segment]
126
+ if (existing === undefined || typeof existing !== 'object' || existing === null) {
127
+ cursor[segment] = wantsArray ? [] : {}
128
+ }
129
+ cursor = cursor[segment] as Record<string, unknown> | unknown[]
130
+ }
131
+ }
132
+
133
+ /**
134
+ * `application/x-www-form-urlencoded` reserves `+` for space (RFC 1866 /
135
+ * WHATWG URL form spec), so the substitution happens BEFORE percent-decoding —
136
+ * otherwise a literal `+` (sent as `%2B`) would become a space too.
137
+ *
138
+ * Malformed percent-encoding makes `decodeURIComponent` throw; the
139
+ * substituted-but-undecoded value is kept so a bad body is a parse miss, not
140
+ * a 500.
141
+ */
142
+ export function decodeFormComponent(value: string): string {
143
+ const spaced = value.replace(/\+/g, ' ')
144
+ try {
145
+ return decodeURIComponent(spaced)
146
+ } catch {
147
+ return spaced
148
+ }
149
+ }
150
+
151
+ /** Parse a urlencoded body into a nested object. */
152
+ export function parseQueryString(
153
+ body: string,
154
+ options: QsParseOptions = {},
155
+ ): Record<string, unknown> {
156
+ const depth = options.depth ?? DEFAULT_DEPTH
157
+ 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) {
164
+ if (!pair) continue
165
+ if (++seen > parameterLimit) break
166
+
167
+ const eqIdx = pair.indexOf('=')
168
+ const rawKey = eqIdx === -1 ? pair : pair.slice(0, eqIdx)
169
+ const rawValue = eqIdx === -1 ? '' : pair.slice(eqIdx + 1)
170
+
171
+ const key = decodeFormComponent(rawKey)
172
+ let value: unknown = decodeFormComponent(rawValue)
173
+ if (options.trimWhitespaces && typeof value === 'string') value = value.trim()
174
+ if (options.convertEmptyStringsToNull && value === '') value = null
175
+
176
+ assign(result, splitKey(key, depth, options.allowDots ?? true), value)
177
+ }
178
+ return result
179
+ }