@c9up/ream 0.1.32 → 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 (188) hide show
  1. package/dist/Application.d.ts +27 -0
  2. package/dist/Application.d.ts.map +1 -1
  3. package/dist/Application.js +29 -0
  4. package/dist/Application.js.map +1 -1
  5. package/dist/ErrorBoundary.d.ts.map +1 -1
  6. package/dist/ErrorBoundary.js +13 -1
  7. package/dist/ErrorBoundary.js.map +1 -1
  8. package/dist/HttpKernel.d.ts +8 -0
  9. package/dist/HttpKernel.d.ts.map +1 -1
  10. package/dist/HttpKernel.js +35 -12
  11. package/dist/HttpKernel.js.map +1 -1
  12. package/dist/Ignitor.d.ts +8 -0
  13. package/dist/Ignitor.d.ts.map +1 -1
  14. package/dist/Ignitor.js +39 -0
  15. package/dist/Ignitor.js.map +1 -1
  16. package/dist/bodyparser/BodyParserMiddleware.d.ts +11 -4
  17. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  18. package/dist/bodyparser/BodyParserMiddleware.js +55 -43
  19. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  20. package/dist/bodyparser/qsParse.d.ts +52 -0
  21. package/dist/bodyparser/qsParse.d.ts.map +1 -0
  22. package/dist/bodyparser/qsParse.js +161 -0
  23. package/dist/bodyparser/qsParse.js.map +1 -0
  24. package/dist/bootstrap/hyper-server.d.ts.map +1 -1
  25. package/dist/bootstrap/hyper-server.js +2 -8
  26. package/dist/bootstrap/hyper-server.js.map +1 -1
  27. package/dist/container/Container.d.ts +41 -0
  28. package/dist/container/Container.d.ts.map +1 -1
  29. package/dist/container/Container.js +193 -26
  30. package/dist/container/Container.js.map +1 -1
  31. package/dist/container/ContainerResolver.d.ts +46 -0
  32. package/dist/container/ContainerResolver.d.ts.map +1 -0
  33. package/dist/container/ContainerResolver.js +60 -0
  34. package/dist/container/ContainerResolver.js.map +1 -0
  35. package/dist/container/ContextualBindingsBuilder.d.ts +20 -0
  36. package/dist/container/ContextualBindingsBuilder.d.ts.map +1 -0
  37. package/dist/container/ContextualBindingsBuilder.js +31 -0
  38. package/dist/container/ContextualBindingsBuilder.js.map +1 -0
  39. package/dist/container/types.d.ts +9 -6
  40. package/dist/container/types.d.ts.map +1 -1
  41. package/dist/decorators/Service.d.ts +1 -5
  42. package/dist/decorators/Service.d.ts.map +1 -1
  43. package/dist/decorators/Service.js +10 -1
  44. package/dist/decorators/Service.js.map +1 -1
  45. package/dist/events/Emitter.d.ts +66 -6
  46. package/dist/events/Emitter.d.ts.map +1 -1
  47. package/dist/events/Emitter.js +165 -28
  48. package/dist/events/Emitter.js.map +1 -1
  49. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  50. package/dist/graphql/GraphQLEngine.js +5 -5
  51. package/dist/graphql/GraphQLEngine.js.map +1 -1
  52. package/dist/helpers/duration.d.ts +32 -0
  53. package/dist/helpers/duration.d.ts.map +1 -0
  54. package/dist/helpers/duration.js +74 -0
  55. package/dist/helpers/duration.js.map +1 -0
  56. package/dist/helpers/napi-loader.d.ts +10 -1
  57. package/dist/helpers/napi-loader.d.ts.map +1 -1
  58. package/dist/helpers/napi-loader.js +18 -3
  59. package/dist/helpers/napi-loader.js.map +1 -1
  60. package/dist/helpers/types.d.ts +1 -1
  61. package/dist/helpers/types.d.ts.map +1 -1
  62. package/dist/http/Exception.d.ts.map +1 -1
  63. package/dist/http/Exception.js +17 -5
  64. package/dist/http/Exception.js.map +1 -1
  65. package/dist/http/HttpContext.d.ts +16 -5
  66. package/dist/http/HttpContext.d.ts.map +1 -1
  67. package/dist/http/HttpContext.js +4 -2
  68. package/dist/http/HttpContext.js.map +1 -1
  69. package/dist/http/RedirectBuilder.d.ts +48 -2
  70. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  71. package/dist/http/RedirectBuilder.js +95 -14
  72. package/dist/http/RedirectBuilder.js.map +1 -1
  73. package/dist/http/Request.d.ts +128 -8
  74. package/dist/http/Request.d.ts.map +1 -1
  75. package/dist/http/Request.js +259 -8
  76. package/dist/http/Request.js.map +1 -1
  77. package/dist/http/Response.d.ts +74 -3
  78. package/dist/http/Response.d.ts.map +1 -1
  79. package/dist/http/Response.js +177 -4
  80. package/dist/http/Response.js.map +1 -1
  81. package/dist/index.d.ts +9 -3
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +5 -1
  84. package/dist/index.js.map +1 -1
  85. package/dist/router/Router.d.ts +36 -7
  86. package/dist/router/Router.d.ts.map +1 -1
  87. package/dist/router/Router.js +66 -22
  88. package/dist/router/Router.js.map +1 -1
  89. package/dist/security/CookieSigner.d.ts +35 -4
  90. package/dist/security/CookieSigner.d.ts.map +1 -1
  91. package/dist/security/CookieSigner.js +99 -10
  92. package/dist/security/CookieSigner.js.map +1 -1
  93. package/dist/server/Server.d.ts +2 -3
  94. package/dist/server/Server.d.ts.map +1 -1
  95. package/dist/server/Server.js +30 -13
  96. package/dist/server/Server.js.map +1 -1
  97. package/dist/session/ReadOnlyValuesStore.d.ts +24 -0
  98. package/dist/session/ReadOnlyValuesStore.d.ts.map +1 -0
  99. package/dist/session/ReadOnlyValuesStore.js +45 -0
  100. package/dist/session/ReadOnlyValuesStore.js.map +1 -0
  101. package/dist/session/Session.d.ts +181 -9
  102. package/dist/session/Session.d.ts.map +1 -1
  103. package/dist/session/Session.js +284 -33
  104. package/dist/session/Session.js.map +1 -1
  105. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  106. package/dist/session/SessionMiddleware.js +87 -33
  107. package/dist/session/SessionMiddleware.js.map +1 -1
  108. package/dist/session/drivers/DatabaseDriver.d.ts +66 -0
  109. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -0
  110. package/dist/session/drivers/DatabaseDriver.js +132 -0
  111. package/dist/session/drivers/DatabaseDriver.js.map +1 -0
  112. package/dist/session/drivers/FileDriver.d.ts +22 -0
  113. package/dist/session/drivers/FileDriver.d.ts.map +1 -0
  114. package/dist/session/drivers/FileDriver.js +66 -0
  115. package/dist/session/drivers/FileDriver.js.map +1 -0
  116. package/dist/session/drivers/MemoryDriver.d.ts +10 -2
  117. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  118. package/dist/session/drivers/MemoryDriver.js +70 -0
  119. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  120. package/dist/session/drivers/RedisDriver.d.ts +10 -2
  121. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  122. package/dist/session/drivers/RedisDriver.js +58 -0
  123. package/dist/session/drivers/RedisDriver.js.map +1 -1
  124. package/dist/session/templateTags.d.ts +42 -0
  125. package/dist/session/templateTags.d.ts.map +1 -0
  126. package/dist/session/templateTags.js +101 -0
  127. package/dist/session/templateTags.js.map +1 -0
  128. package/dist/testing/RequestBuilder.d.ts +38 -1
  129. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  130. package/dist/testing/RequestBuilder.js +40 -1
  131. package/dist/testing/RequestBuilder.js.map +1 -1
  132. package/dist/testing/TestClient.d.ts +3 -1
  133. package/dist/testing/TestClient.d.ts.map +1 -1
  134. package/dist/testing/TestClient.js +3 -1
  135. package/dist/testing/TestClient.js.map +1 -1
  136. package/dist/types/helpers.d.ts +1 -1
  137. package/dist/types/helpers.d.ts.map +1 -1
  138. package/dist/utils/Macroable.d.ts +14 -5
  139. package/dist/utils/Macroable.d.ts.map +1 -1
  140. package/dist/utils/Macroable.js +20 -18
  141. package/dist/utils/Macroable.js.map +1 -1
  142. package/events.win32-x64-msvc.node +0 -0
  143. package/index.win32-x64-msvc.node +0 -0
  144. package/package.json +1 -1
  145. package/scheduler.win32-x64-msvc.node +0 -0
  146. package/src/Application.ts +39 -0
  147. package/src/ErrorBoundary.ts +13 -1
  148. package/src/HttpKernel.ts +45 -19
  149. package/src/Ignitor.ts +39 -0
  150. package/src/bodyparser/BodyParserMiddleware.ts +66 -44
  151. package/src/bodyparser/qsParse.ts +179 -0
  152. package/src/bootstrap/hyper-server.ts +2 -9
  153. package/src/container/Container.ts +265 -25
  154. package/src/container/ContainerResolver.ts +84 -0
  155. package/src/container/ContextualBindingsBuilder.ts +41 -0
  156. package/src/container/types.ts +11 -7
  157. package/src/decorators/Service.ts +16 -3
  158. package/src/events/Emitter.ts +200 -25
  159. package/src/graphql/GraphQLEngine.ts +7 -8
  160. package/src/helpers/duration.ts +71 -0
  161. package/src/helpers/napi-loader.ts +19 -3
  162. package/src/helpers/types.ts +4 -1
  163. package/src/http/Exception.ts +17 -5
  164. package/src/http/HttpContext.ts +20 -5
  165. package/src/http/RedirectBuilder.ts +126 -11
  166. package/src/http/Request.ts +283 -13
  167. package/src/http/Response.ts +213 -6
  168. package/src/index.ts +17 -3
  169. package/src/router/Router.ts +98 -33
  170. package/src/security/CookieSigner.ts +102 -10
  171. package/src/server/Server.ts +36 -16
  172. package/src/session/ReadOnlyValuesStore.ts +52 -0
  173. package/src/session/Session.ts +364 -37
  174. package/src/session/SessionMiddleware.ts +93 -30
  175. package/src/session/drivers/DatabaseDriver.ts +184 -0
  176. package/src/session/drivers/FileDriver.ts +82 -0
  177. package/src/session/drivers/MemoryDriver.ts +75 -2
  178. package/src/session/drivers/RedisDriver.ts +77 -2
  179. package/src/session/templateTags.ts +124 -0
  180. package/src/testing/RequestBuilder.ts +76 -0
  181. package/src/testing/TestClient.ts +10 -2
  182. package/src/types/helpers.ts +4 -1
  183. package/src/utils/Macroable.ts +23 -8
  184. package/dist/utils/inflect.d.ts +0 -17
  185. package/dist/utils/inflect.d.ts.map +0 -1
  186. package/dist/utils/inflect.js +0 -32
  187. package/dist/utils/inflect.js.map +0 -1
  188. package/src/utils/inflect.ts +0 -29
@@ -28,14 +28,23 @@ function isSameOriginOrRelative(referer: string, requestUrl?: string): boolean {
28
28
  }
29
29
  }
30
30
 
31
+ /** The slice of the session the intended-URL methods use. */
32
+ export interface IntendedUrlStore {
33
+ setIntendedUrl(url: string): void
34
+ pullIntendedUrl(): string | null
35
+ }
36
+
31
37
  export class RedirectBuilder {
32
38
  #response: Response
33
39
  #status = 302
34
- #qs: Record<string, string> | null = null
40
+ #qs: Record<string, unknown> = {}
35
41
  #forwardQs = false
36
42
  #requestUrl?: string
37
43
  #requestReferer?: string
38
44
  #routeUrlResolver?: RouteUrlResolver
45
+ #session?: IntendedUrlStore
46
+ #isAjax = false
47
+ #hasRoute = true
39
48
 
40
49
  constructor(
41
50
  response: Response,
@@ -43,12 +52,77 @@ export class RedirectBuilder {
43
52
  requestUrl?: string
44
53
  requestReferer?: string
45
54
  routeUrlResolver?: RouteUrlResolver
55
+ /** The request session, for the intended-URL methods. */
56
+ session?: IntendedUrlStore
57
+ /** Whether this is an XHR — `withIntendedUrl` skips those. */
58
+ isAjax?: boolean
59
+ /** Whether a route matched — `withIntendedUrl` skips 404s. */
60
+ hasRoute?: boolean
46
61
  },
47
62
  ) {
48
63
  this.#response = response
49
64
  this.#requestUrl = options?.requestUrl
50
65
  this.#requestReferer = options?.requestReferer
51
66
  this.#routeUrlResolver = options?.routeUrlResolver
67
+ this.#session = options?.session
68
+ this.#isAjax = options?.isAjax ?? false
69
+ this.#hasRoute = options?.hasRoute ?? true
70
+ }
71
+
72
+ /**
73
+ * Remember where the user was heading, then carry on redirecting — AdonisJS'
74
+ * `withIntendedUrl` (a macro `@adonisjs/session` adds to Redirect).
75
+ *
76
+ * Only a GET, non-XHR, route-matched request is stored, exactly as upstream:
77
+ * remembering a POST would replay it as a GET after login, and remembering a
78
+ * 404 would send the user back to a dead end.
79
+ */
80
+ withIntendedUrl(method = 'GET'): this {
81
+ if (
82
+ this.#session &&
83
+ method.toUpperCase() === 'GET' &&
84
+ !this.#isAjax &&
85
+ this.#hasRoute &&
86
+ this.#requestUrl
87
+ ) {
88
+ this.#session.setIntendedUrl(this.#requestUrl)
89
+ }
90
+ return this
91
+ }
92
+
93
+ /**
94
+ * Redirect to the remembered URL, consuming it — AdonisJS' `toIntended`.
95
+ *
96
+ * The stored value is validated before use: it is written from a request URL,
97
+ * but a session store is not a trust boundary, and a redirect to an absolute
98
+ * off-site URL is an open redirect. Anything not same-origin or relative
99
+ * falls back, which is what `back()` already does with the Referer.
100
+ */
101
+ toIntended(fallback = '/'): void {
102
+ const intended = this.#session?.pullIntendedUrl() ?? null
103
+ this.toPath(this.#safeIntended(intended, fallback))
104
+ }
105
+
106
+ /** `toIntended`, falling back to a named route — AdonisJS' `toIntendedRoute`. */
107
+ toIntendedRoute(name: string, params?: Record<string, string>): void {
108
+ const intended = this.#session?.pullIntendedUrl() ?? null
109
+ const safe = intended === null ? null : this.#safeIntended(intended, null)
110
+ if (safe !== null) {
111
+ this.toPath(safe)
112
+ return
113
+ }
114
+ this.toRoute(name, params)
115
+ }
116
+
117
+ #safeIntended<T extends string | null>(intended: string | null, fallback: T): string | T {
118
+ if (!intended) return fallback
119
+ if (this.#requestUrl && !isSameOriginOrRelative(intended, this.#requestUrl)) {
120
+ return fallback
121
+ }
122
+ // A protocol-relative `//evil.com` is absolute to a browser even though it
123
+ // looks relative — reject it whether or not a request URL is known.
124
+ if (intended.startsWith('//')) return fallback
125
+ return intended
52
126
  }
53
127
 
54
128
  /** Set redirect status code. */
@@ -57,13 +131,42 @@ export class RedirectBuilder {
57
131
  return this
58
132
  }
59
133
 
60
- /** Forward current query string to the redirect target. */
61
- withQs(qs?: Record<string, string>): this {
62
- if (qs) {
63
- this.#qs = qs
64
- } else {
134
+ /**
135
+ * Query string for the redirect target, in the four shapes AdonisJS accepts:
136
+ *
137
+ * withQs() forward the request's own query string
138
+ * withQs(false) stop forwarding it
139
+ * withQs({ a: 1, b: 2 }) merge these values in
140
+ * withQs('a', 1) set one value
141
+ *
142
+ * The object and name/value forms MERGE, as upstream does — calling it twice
143
+ * adds to what is there rather than replacing it.
144
+ */
145
+ withQs(): this
146
+ withQs(forward: boolean): this
147
+ withQs(values: Record<string, unknown>): this
148
+ withQs(name: string, value: unknown): this
149
+ withQs(name?: boolean | string | Record<string, unknown>, value?: unknown): this {
150
+ if (name === undefined) {
65
151
  this.#forwardQs = true
152
+ return this
153
+ }
154
+ if (typeof name === 'boolean') {
155
+ this.#forwardQs = name
156
+ return this
66
157
  }
158
+ if (typeof name === 'string') {
159
+ this.#qs[name] = value
160
+ return this
161
+ }
162
+ Object.assign(this.#qs, name)
163
+ return this
164
+ }
165
+
166
+ /** Drop every value added with {@link withQs}, and stop forwarding. */
167
+ clearQs(): this {
168
+ this.#qs = {}
169
+ this.#forwardQs = false
67
170
  return this
68
171
  }
69
172
 
@@ -98,14 +201,26 @@ export class RedirectBuilder {
98
201
  }
99
202
  }
100
203
 
204
+ /**
205
+ * Forwarded query string first, explicit values on top — the order AdonisJS
206
+ * uses, so `withQs()` plus `withQs('page', 2)` overrides an inbound `page`
207
+ * rather than being overridden by it.
208
+ */
101
209
  #appendQs(path: string): string {
102
- let qs = ''
103
- if (this.#qs) {
104
- qs = new URLSearchParams(this.#qs).toString()
105
- } else if (this.#forwardQs && this.#requestUrl) {
210
+ const params = new URLSearchParams()
211
+ if (this.#forwardQs && this.#requestUrl) {
106
212
  const qsIdx = this.#requestUrl.indexOf('?')
107
- if (qsIdx !== -1) qs = this.#requestUrl.slice(qsIdx + 1)
213
+ if (qsIdx !== -1) {
214
+ for (const [key, value] of new URLSearchParams(this.#requestUrl.slice(qsIdx + 1))) {
215
+ params.append(key, value)
216
+ }
217
+ }
218
+ }
219
+ for (const [key, value] of Object.entries(this.#qs)) {
220
+ if (value === undefined || value === null) continue
221
+ params.set(key, String(value))
108
222
  }
223
+ const qs = params.toString()
109
224
  if (!qs) return path
110
225
  return path.includes('?') ? `${path}&${qs}` : `${path}?${qs}`
111
226
  }
@@ -12,6 +12,7 @@ import type { SignedUrl } from '../security/SignedUrl.js'
12
12
  import type { Dict } from '../types/helpers.js'
13
13
  import { Macroable } from '../utils/Macroable.js'
14
14
  import { getPath, omitPaths, pickPaths } from '../utils/objectPath.js'
15
+ import { unpackCookieValue } from './Response.js'
15
16
 
16
17
  export interface RawRequest {
17
18
  method: string
@@ -60,7 +61,7 @@ export interface RawRequest {
60
61
 
61
62
  export class Request extends Macroable {
62
63
  #raw: RawRequest
63
- #params: Dict
64
+ #params: Dict<string | string[]>
64
65
  #cookieSigner?: CookieSigner
65
66
  #signedUrl?: SignedUrl
66
67
  #allowMethodSpoofing = false
@@ -71,6 +72,8 @@ export class Request extends Macroable {
71
72
  #parsedQs: Dict<unknown> | undefined
72
73
  #merged: Dict<unknown> | undefined
73
74
  #original: Dict<unknown> | undefined
75
+ /** Set by {@link updateRawBody}; `raw()` reports it instead of the wire body. */
76
+ #rawBodyOverride: string | undefined
74
77
  #files: Map<string, import('../bodyparser/MultipartFile.js').MultipartFile[]> = new Map()
75
78
  #validated: unknown
76
79
 
@@ -91,7 +94,7 @@ export class Request extends Macroable {
91
94
  */
92
95
  csrfProtected?: boolean
93
96
 
94
- constructor(raw: RawRequest, params: Dict = {}) {
97
+ constructor(raw: RawRequest, params: Dict<string | string[]> = {}) {
95
98
  super()
96
99
  this.#raw = raw
97
100
  this.#params = params
@@ -202,6 +205,22 @@ export class Request extends Macroable {
202
205
  this.#trustProxy = enabled
203
206
  }
204
207
 
208
+ /**
209
+ * The HTTP/2 `:authority` pseudo-header, falling back to `Host` (AdonisJS
210
+ * `authority`).
211
+ *
212
+ * Deliberately does NOT consult `X-Forwarded-Host` and ignores trust-proxy,
213
+ * as Adonis does: no proxy convention forwards the original `:authority`,
214
+ * so honouring a forwarded header here would let a proxy rewrite the value
215
+ * you validate a redirect target against. Use {@link host} when you do want
216
+ * the proxy-aware value.
217
+ */
218
+ authority(): string | null {
219
+ const pseudo = this.#raw.headers[':authority']
220
+ if (pseudo) return pseudo
221
+ return this.#raw.headers.host ?? null
222
+ }
223
+
205
224
  /**
206
225
  * The request host including port (AdonisJS `host`). Honours
207
226
  * `X-Forwarded-Host` ONLY when trust-proxy is enabled; otherwise the `Host`
@@ -313,15 +332,32 @@ export class Request extends Macroable {
313
332
  * (tampered / not signed).
314
333
  */
315
334
  cookie(name: string, defaultValue?: string): string | null {
316
- const raw = this.plainCookie(name)
335
+ const raw = this.plainCookie<string>(name, undefined, { encoded: false })
317
336
  const value = raw === null ? null : this.#cookieSigner ? this.#cookieSigner.unsign(raw) : raw
318
337
  return value ?? defaultValue ?? null
319
338
  }
320
339
 
321
- /** Raw (unsigned) cookie value (AdonisJS `plainCookie`), or `defaultValue`/null when absent. */
322
- plainCookie(name: string, defaultValue?: string): string | null {
340
+ /**
341
+ * An unsigned cookie (AdonisJS `plainCookie`), unpacked.
342
+ *
343
+ * `response.plainCookie()` writes a base64url JSON envelope, so this reads
344
+ * the value BACK WITH ITS TYPE — an object stays an object, a number a
345
+ * number. A cookie set by something else, or written with `encode: false`,
346
+ * comes back as the raw string.
347
+ *
348
+ * Pass `encoded: false` to skip unpacking entirely.
349
+ */
350
+ plainCookie<T = string>(
351
+ name: string,
352
+ defaultValue?: T,
353
+ options?: { encoded?: boolean },
354
+ ): T | null {
323
355
  const cookies = this.#raw.cookies ?? this.cookies()
324
- return cookies[name] ?? defaultValue ?? null
356
+ const raw = cookies[name]
357
+ if (raw === undefined) return defaultValue ?? null
358
+ // Caller's claim about an untyped store, same contract as `input<T>`.
359
+ if (options?.encoded === false) return raw as T
360
+ return unpackCookieValue(raw) as T
325
361
  }
326
362
 
327
363
  /**
@@ -330,7 +366,7 @@ export class Request extends Macroable {
330
366
  * encryption service.
331
367
  */
332
368
  encryptedCookie(name: string, defaultValue?: string): string | null {
333
- const raw = this.plainCookie(name)
369
+ const raw = this.plainCookie<string>(name, undefined, { encoded: false })
334
370
  const value = raw === null || !this.#cookieSigner ? null : this.#cookieSigner.decrypt(raw)
335
371
  return value ?? defaultValue ?? null
336
372
  }
@@ -353,6 +389,7 @@ export class Request extends Macroable {
353
389
 
354
390
  /** Get the raw body as a string (decoded from base64 if binary). */
355
391
  raw(): string {
392
+ if (this.#rawBodyOverride !== undefined) return this.#rawBodyOverride
356
393
  if (this.#raw.bodyEncoding === 'base64') {
357
394
  return Buffer.from(this.#raw.body, 'base64').toString('utf8')
358
395
  }
@@ -381,16 +418,216 @@ export class Request extends Macroable {
381
418
 
382
419
  // ─── Route params ─────────────────────────────────────────
383
420
 
384
- /** Get a single route parameter. */
421
+ /**
422
+ * Get a single route parameter.
423
+ *
424
+ * The catch-all `*` is an ARRAY of segments (AdonisJS hands it that way), so
425
+ * it is returned joined here — `param('*')` reading `undefined` because the
426
+ * value was not a string would be worse than the path it describes.
427
+ * Use {@link params} to get the segments themselves.
428
+ */
385
429
  param(key: string, defaultValue?: string): string | undefined {
386
- return this.#params[key] ?? defaultValue
430
+ const value = this.#params[key]
431
+ if (value === undefined) return defaultValue
432
+ return Array.isArray(value) ? value.join('/') : value
387
433
  }
388
434
 
389
- /** Get all route parameters. */
390
- params(): Readonly<Dict> {
435
+ /** Get all route parameters; `*` holds its segments as an array. */
436
+ params(): Readonly<Dict<string | string[]>> {
391
437
  return this.#params
392
438
  }
393
439
 
440
+ // ─── Identity and shape ───────────────────────────────────
441
+
442
+ /**
443
+ * The request id from `x-request-id` (AdonisJS `id`).
444
+ *
445
+ * Absent when the proxy or client did not set one — it is not invented here,
446
+ * because a correlation id nobody else knows correlates nothing.
447
+ */
448
+ id(): string | undefined {
449
+ return this.header('x-request-id')
450
+ }
451
+
452
+ /** The URL split into path and raw query string (AdonisJS `parsedUrl`). */
453
+ parsedUrl(): { pathname: string; search: string; query: string } {
454
+ const query = this.#raw.query ?? ''
455
+ return {
456
+ pathname: this.#raw.path,
457
+ search: query ? `?${query}` : '',
458
+ query,
459
+ }
460
+ }
461
+
462
+ /** `X-Requested-With: xmlhttprequest` (AdonisJS `ajax`). */
463
+ ajax(): boolean {
464
+ return (this.header('x-requested-with') ?? '').toLowerCase() === 'xmlhttprequest'
465
+ }
466
+
467
+ /** Whether the client sent `X-Pjax` (AdonisJS `pjax`). */
468
+ pjax(): boolean {
469
+ return this.header('x-pjax') !== undefined
470
+ }
471
+
472
+ /**
473
+ * Whether this is a speculative prefetch or prerender rather than a real
474
+ * navigation (AdonisJS `prefetch`).
475
+ *
476
+ * Worth checking before anything with a side effect: a browser may fetch a
477
+ * link the user never clicks, and counting that as a visit — or worse, acting
478
+ * on it — attributes an intention nobody had.
479
+ */
480
+ prefetch(): boolean {
481
+ const purpose = (
482
+ this.header('sec-purpose') ??
483
+ this.header('purpose') ??
484
+ this.header('x-purpose') ??
485
+ this.header('x-moz') ??
486
+ ''
487
+ ).toLowerCase()
488
+ return purpose.includes('prefetch') || purpose.includes('prerender')
489
+ }
490
+
491
+ /**
492
+ * The full URL — `protocol://host/path` (AdonisJS `completeUrl`).
493
+ *
494
+ * Pass `true` to keep the query string.
495
+ */
496
+ completeUrl(includeQueryString = false): string {
497
+ const path = includeQueryString ? this.url(true) : this.url(false)
498
+ return `${this.protocol()}://${this.host()}${path}`
499
+ }
500
+
501
+ /**
502
+ * Where the user came from, per the `Referer` header — but only when it
503
+ * points somewhere we trust (AdonisJS `getPreviousUrl`).
504
+ *
505
+ * A referrer is attacker-controlled, so redirecting back to it unchecked is
506
+ * an open redirect. The host must be this request's own or one of
507
+ * `allowedHosts`; anything else falls back.
508
+ */
509
+ getPreviousUrl(allowedHosts: string[] = [], fallback = '/'): string {
510
+ const referer = this.header('referer') ?? this.header('referrer')
511
+ if (!referer) return fallback
512
+ let parsed: URL
513
+ try {
514
+ parsed = new URL(referer)
515
+ } catch {
516
+ return fallback
517
+ }
518
+ const trusted = new Set([this.host(), ...allowedHosts])
519
+ if (!trusted.has(parsed.host)) return fallback
520
+ return `${parsed.pathname}${parsed.search}`
521
+ }
522
+
523
+ // ─── Content negotiation ──────────────────────────────────
524
+
525
+ /** Every media type the client accepts, best first (AdonisJS `types`). */
526
+ types(): string[] {
527
+ return this.#negotiated('accept')
528
+ }
529
+
530
+ /** Every language the client accepts, best first (AdonisJS `languages`). */
531
+ languages(): string[] {
532
+ return this.#negotiated('accept-language')
533
+ }
534
+
535
+ /** Every charset the client accepts, best first (AdonisJS `charsets`). */
536
+ charsets(): string[] {
537
+ return this.#negotiated('accept-charset')
538
+ }
539
+
540
+ /**
541
+ * The best of the offered charsets per `Accept-Charset` (AdonisJS
542
+ * `charset`), or null when none matches. The plural {@link charsets} lists
543
+ * everything the client will take.
544
+ */
545
+ charset<T extends string>(charsets: T[]): T | null {
546
+ return this.#bestOf('accept-charset', charsets)
547
+ }
548
+
549
+ /** Every encoding the client accepts, best first (AdonisJS `encodings`). */
550
+ encodings(): string[] {
551
+ return this.#negotiated('accept-encoding')
552
+ }
553
+
554
+ /**
555
+ * The best of the offered encodings per `Accept-Encoding` (AdonisJS
556
+ * `encoding`), or null when none matches.
557
+ */
558
+ encoding<T extends string>(encodings: T[]): T | null {
559
+ return this.#bestOf('accept-encoding', encodings)
560
+ }
561
+
562
+ /**
563
+ * The best of the offered values for one `Accept-*` header (AdonisJS
564
+ * `charset` / `encoding`).
565
+ *
566
+ * Client preference decides: the header's q-order is walked first, and the
567
+ * order the server offered them only breaks a tie within one q-bucket —
568
+ * the same rule {@link accepts} follows. An absent header means the client
569
+ * expressed no preference, so the server's first offer wins.
570
+ */
571
+ #bestOf<T extends string>(header: string, offered: T[]): T | null {
572
+ const raw = this.#raw.headers[header]
573
+ if (!raw?.trim()) return offered[0] ?? null
574
+ const entries = parseAcceptHeader(raw)
575
+ if (entries.length === 0) return null
576
+ for (const bucket of bucketByQ(entries)) {
577
+ for (const value of offered) {
578
+ for (const entry of bucket) {
579
+ const candidate = entry.value.toLowerCase()
580
+ if (candidate === '*' || candidate === value.toLowerCase()) return value
581
+ }
582
+ }
583
+ }
584
+ return null
585
+ }
586
+
587
+ /**
588
+ * Parse one `Accept-*` header into the client's preference order.
589
+ *
590
+ * Sorted by q descending, ties broken by the order the client wrote them —
591
+ * which is what "preference" means when two entries share a q.
592
+ */
593
+ #negotiated(header: string): string[] {
594
+ const raw = this.#raw.headers[header]
595
+ if (!raw) return []
596
+ return parseAcceptHeader(raw)
597
+ .sort((a, b) => b.q - a.q || a.index - b.index)
598
+ .map((entry) => entry.value)
599
+ }
600
+
601
+ // ─── Serialization ────────────────────────────────────────
602
+
603
+ /** Every cookie, parsed (AdonisJS `cookiesList`). */
604
+ cookiesList(): Dict {
605
+ return this.cookies()
606
+ }
607
+
608
+ /**
609
+ * A JSON-safe view of the request (AdonisJS `serialize`), for logs and error
610
+ * reports. Carries no body: it may hold credentials, and a log line is the
611
+ * last place they should land.
612
+ */
613
+ serialize(): Record<string, unknown> {
614
+ return {
615
+ id: this.id(),
616
+ url: this.url(true),
617
+ method: this.method(),
618
+ protocol: this.protocol(),
619
+ host: this.host(),
620
+ headers: this.headers(),
621
+ qs: this.qs(),
622
+ params: this.params(),
623
+ }
624
+ }
625
+
626
+ /** Same as {@link serialize} — what `JSON.stringify(request)` uses. */
627
+ toJSON(): Record<string, unknown> {
628
+ return this.serialize()
629
+ }
630
+
394
631
  // ─── Query string ─────────────────────────────────────────
395
632
 
396
633
  /** Get parsed query string as an object. */
@@ -500,7 +737,7 @@ export class Request extends Macroable {
500
737
  * Aliases (`'json'` ↔ `application/json`, `'html'` ↔ `text/html`,
501
738
  * `'xml'` ↔ any subtype containing `xml`) are honoured.
502
739
  */
503
- accepts(types: string[]): string | null {
740
+ accepts<T extends string>(types: T[]): T | null {
504
741
  const header = this.#raw.headers.accept ?? '*/*'
505
742
  const entries = parseAcceptHeader(header)
506
743
  if (entries.length === 0) return null
@@ -523,7 +760,7 @@ export class Request extends Macroable {
523
760
  * primary subtag) matches the highest-q client entry, falling back to
524
761
  * `langs[0]` when nothing matches and the client header is empty/absent.
525
762
  */
526
- language(langs: string[]): string | null {
763
+ language<T extends string>(langs: T[]): T | null {
527
764
  const header = this.#raw.headers['accept-language'] ?? ''
528
765
  if (!header.trim()) return langs[0] ?? null
529
766
  const entries = parseAcceptHeader(header)
@@ -601,6 +838,39 @@ export class Request extends Macroable {
601
838
  this.#merged = undefined // reset merged cache
602
839
  }
603
840
 
841
+ /**
842
+ * Replace the request body (AdonisJS `updateBody`) — what a middleware that
843
+ * sanitizes or normalises input calls. {@link all} is recomputed from the
844
+ * query string and the new body on the next read.
845
+ *
846
+ * {@link original} keeps the first-seen input: flash old-input must show
847
+ * what the user actually sent, not what a middleware rewrote it into.
848
+ */
849
+ updateBody(body: Dict<unknown>): void {
850
+ this.all() // snapshot the original before it is replaced
851
+ this.setParsedBody(body)
852
+ }
853
+
854
+ /**
855
+ * Replace the query string data (AdonisJS `updateQs`). {@link all} is
856
+ * recomputed on the next read; the raw query string is left alone, so
857
+ * `parsedUrl()` still reports what the client sent.
858
+ */
859
+ updateQs(data: Dict<unknown>): void {
860
+ this.all()
861
+ this.#parsedQs = { ...data }
862
+ this.#merged = undefined
863
+ }
864
+
865
+ /**
866
+ * Replace the raw body (AdonisJS `updateRawBody`) — what the bodyparser
867
+ * sets when it cannot parse the body, or for multipart. Does NOT reparse:
868
+ * the parsed body is whatever {@link updateBody} last set.
869
+ */
870
+ updateRawBody(rawBody: string): void {
871
+ this.#rawBodyOverride = rawBody
872
+ }
873
+
604
874
  /**
605
875
  * @internal Store the validated + transformed payload (called by the
606
876
  * validation middleware after a route validator passes).