@c9up/ream 0.1.24 → 0.1.26

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 (172) hide show
  1. package/dist/ConfigLoader.d.ts +46 -5
  2. package/dist/ConfigLoader.d.ts.map +1 -1
  3. package/dist/ConfigLoader.js +60 -9
  4. package/dist/ConfigLoader.js.map +1 -1
  5. package/dist/HttpKernel.d.ts.map +1 -1
  6. package/dist/HttpKernel.js +46 -6
  7. package/dist/HttpKernel.js.map +1 -1
  8. package/dist/Ignitor.d.ts.map +1 -1
  9. package/dist/Ignitor.js +21 -3
  10. package/dist/Ignitor.js.map +1 -1
  11. package/dist/container/Container.d.ts +23 -3
  12. package/dist/container/Container.d.ts.map +1 -1
  13. package/dist/container/Container.js +139 -60
  14. package/dist/container/Container.js.map +1 -1
  15. package/dist/env/Env.d.ts +29 -4
  16. package/dist/env/Env.d.ts.map +1 -1
  17. package/dist/env/Env.js +27 -4
  18. package/dist/env/Env.js.map +1 -1
  19. package/dist/env/Secret.d.ts +11 -0
  20. package/dist/env/Secret.d.ts.map +1 -0
  21. package/dist/env/Secret.js +36 -0
  22. package/dist/env/Secret.js.map +1 -0
  23. package/dist/env/interpolate.d.ts +26 -0
  24. package/dist/env/interpolate.d.ts.map +1 -0
  25. package/dist/env/interpolate.js +48 -0
  26. package/dist/env/interpolate.js.map +1 -0
  27. package/dist/env/loadEnvFiles.d.ts.map +1 -1
  28. package/dist/env/loadEnvFiles.js +14 -4
  29. package/dist/env/loadEnvFiles.js.map +1 -1
  30. package/dist/env/schema.d.ts +23 -6
  31. package/dist/env/schema.d.ts.map +1 -1
  32. package/dist/env/schema.js +67 -33
  33. package/dist/env/schema.js.map +1 -1
  34. package/dist/events/Emitter.d.ts +1 -1
  35. package/dist/events/Emitter.d.ts.map +1 -1
  36. package/dist/events/Emitter.js +1 -1
  37. package/dist/events/Emitter.js.map +1 -1
  38. package/dist/events/EventsProvider.d.ts +2 -2
  39. package/dist/events/EventsProvider.d.ts.map +1 -1
  40. package/dist/events/EventsProvider.js +3 -3
  41. package/dist/events/EventsProvider.js.map +1 -1
  42. package/dist/graphql/GraphQLEngine.d.ts +1 -1
  43. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  44. package/dist/graphql/GraphQLEngine.js +2 -2
  45. package/dist/graphql/GraphQLEngine.js.map +1 -1
  46. package/dist/graphql/GraphQLProvider.d.ts.map +1 -1
  47. package/dist/graphql/GraphQLProvider.js +7 -3
  48. package/dist/graphql/GraphQLProvider.js.map +1 -1
  49. package/dist/http/Exception.d.ts +63 -1
  50. package/dist/http/Exception.d.ts.map +1 -1
  51. package/dist/http/Exception.js +185 -48
  52. package/dist/http/Exception.js.map +1 -1
  53. package/dist/http/HttpContext.d.ts +56 -9
  54. package/dist/http/HttpContext.d.ts.map +1 -1
  55. package/dist/http/HttpContext.js +56 -15
  56. package/dist/http/HttpContext.js.map +1 -1
  57. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  58. package/dist/http/RedirectBuilder.js +4 -1
  59. package/dist/http/RedirectBuilder.js.map +1 -1
  60. package/dist/http/Request.d.ts +141 -10
  61. package/dist/http/Request.d.ts.map +1 -1
  62. package/dist/http/Request.js +244 -34
  63. package/dist/http/Request.js.map +1 -1
  64. package/dist/http/Response.d.ts +176 -9
  65. package/dist/http/Response.d.ts.map +1 -1
  66. package/dist/http/Response.js +431 -19
  67. package/dist/http/Response.js.map +1 -1
  68. package/dist/index.d.ts +5 -4
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +4 -3
  71. package/dist/index.js.map +1 -1
  72. package/dist/middleware/Pipeline.js +1 -1
  73. package/dist/middleware/Pipeline.js.map +1 -1
  74. package/dist/openapi/OpenApiProvider.js +4 -4
  75. package/dist/openapi/OpenApiProvider.js.map +1 -1
  76. package/dist/router/Router.d.ts +223 -31
  77. package/dist/router/Router.d.ts.map +1 -1
  78. package/dist/router/Router.js +520 -102
  79. package/dist/router/Router.js.map +1 -1
  80. package/dist/rpc/RpcProvider.d.ts +1 -0
  81. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  82. package/dist/rpc/RpcProvider.js +7 -3
  83. package/dist/rpc/RpcProvider.js.map +1 -1
  84. package/dist/rpc/RpcRouter.d.ts +2 -2
  85. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  86. package/dist/rpc/RpcRouter.js +5 -4
  87. package/dist/rpc/RpcRouter.js.map +1 -1
  88. package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
  89. package/dist/scheduler/ScheduleProvider.js +7 -15
  90. package/dist/scheduler/ScheduleProvider.js.map +1 -1
  91. package/dist/server/Server.d.ts +17 -4
  92. package/dist/server/Server.d.ts.map +1 -1
  93. package/dist/server/Server.js +41 -7
  94. package/dist/server/Server.js.map +1 -1
  95. package/dist/session/SessionMiddleware.js +3 -3
  96. package/dist/session/SessionMiddleware.js.map +1 -1
  97. package/dist/storage/StaticProvider.js +1 -1
  98. package/dist/storage/StaticProvider.js.map +1 -1
  99. package/dist/testing/RequestBuilder.d.ts +60 -0
  100. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  101. package/dist/testing/RequestBuilder.js +180 -1
  102. package/dist/testing/RequestBuilder.js.map +1 -1
  103. package/dist/testing/TestClient.d.ts +22 -1
  104. package/dist/testing/TestClient.d.ts.map +1 -1
  105. package/dist/testing/TestClient.js +40 -0
  106. package/dist/testing/TestClient.js.map +1 -1
  107. package/dist/utils/Macroable.d.ts +30 -0
  108. package/dist/utils/Macroable.d.ts.map +1 -0
  109. package/dist/utils/Macroable.js +43 -0
  110. package/dist/utils/Macroable.js.map +1 -0
  111. package/dist/utils/inflect.d.ts +17 -0
  112. package/dist/utils/inflect.d.ts.map +1 -0
  113. package/dist/utils/inflect.js +32 -0
  114. package/dist/utils/inflect.js.map +1 -0
  115. package/dist/utils/objectPath.d.ts +57 -0
  116. package/dist/utils/objectPath.d.ts.map +1 -0
  117. package/dist/utils/objectPath.js +141 -0
  118. package/dist/utils/objectPath.js.map +1 -0
  119. package/events.darwin-arm64.node +0 -0
  120. package/events.darwin-x64.node +0 -0
  121. package/events.linux-arm64-gnu.node +0 -0
  122. package/events.linux-x64-gnu.node +0 -0
  123. package/events.win32-x64-msvc.node +0 -0
  124. package/index.darwin-arm64.node +0 -0
  125. package/index.darwin-x64.node +0 -0
  126. package/index.linux-arm64-gnu.node +0 -0
  127. package/index.linux-x64-gnu.node +0 -0
  128. package/index.win32-x64-msvc.node +0 -0
  129. package/package.json +6 -1
  130. package/scheduler.darwin-arm64.node +0 -0
  131. package/scheduler.darwin-x64.node +0 -0
  132. package/scheduler.linux-arm64-gnu.node +0 -0
  133. package/scheduler.linux-x64-gnu.node +0 -0
  134. package/scheduler.win32-x64-msvc.node +0 -0
  135. package/src/ConfigLoader.ts +72 -9
  136. package/src/HttpKernel.ts +53 -15
  137. package/src/Ignitor.ts +24 -3
  138. package/src/container/Container.ts +145 -61
  139. package/src/env/Env.ts +49 -5
  140. package/src/env/Secret.ts +41 -0
  141. package/src/env/interpolate.ts +54 -0
  142. package/src/env/loadEnvFiles.ts +12 -4
  143. package/src/env/schema.ts +109 -35
  144. package/src/events/Emitter.ts +2 -2
  145. package/src/events/EventsProvider.ts +5 -5
  146. package/src/graphql/GraphQLEngine.ts +4 -4
  147. package/src/graphql/GraphQLProvider.ts +7 -3
  148. package/src/http/Exception.ts +214 -37
  149. package/src/http/HttpContext.ts +93 -20
  150. package/src/http/RedirectBuilder.ts +4 -1
  151. package/src/http/Request.ts +264 -34
  152. package/src/http/Response.ts +504 -30
  153. package/src/index.ts +11 -2
  154. package/src/middleware/Pipeline.ts +1 -1
  155. package/src/openapi/OpenApiProvider.ts +4 -4
  156. package/src/router/Router.ts +631 -113
  157. package/src/rpc/RpcProvider.ts +6 -2
  158. package/src/rpc/RpcRouter.ts +7 -6
  159. package/src/scheduler/ScheduleProvider.ts +7 -15
  160. package/src/server/Server.ts +49 -10
  161. package/src/session/SessionMiddleware.ts +4 -4
  162. package/src/storage/StaticProvider.ts +1 -1
  163. package/src/testing/RequestBuilder.ts +218 -1
  164. package/src/testing/TestClient.ts +57 -1
  165. package/src/utils/Macroable.ts +54 -0
  166. package/src/utils/inflect.ts +29 -0
  167. package/src/utils/objectPath.ts +142 -0
  168. package/dist/decorators/Lazy.d.ts +0 -19
  169. package/dist/decorators/Lazy.d.ts.map +0 -1
  170. package/dist/decorators/Lazy.js +0 -53
  171. package/dist/decorators/Lazy.js.map +0 -1
  172. package/src/decorators/Lazy.ts +0 -57
@@ -6,6 +6,8 @@
6
6
  * - Built-in E_* exceptions
7
7
  */
8
8
 
9
+ import { format } from 'node:util'
10
+ import { Macroable } from '../utils/Macroable.js'
9
11
  import type { HttpContext } from './HttpContext.js'
10
12
 
11
13
  /**
@@ -29,9 +31,13 @@ import type { HttpContext } from './HttpContext.js'
29
31
  export class Exception extends Error {
30
32
  static status = 500
31
33
  static code = 'E_UNKNOWN'
34
+ /** Optional static remediation hint, copied onto instances (AdonisJS `Exception.help`). */
35
+ static help?: string
32
36
 
33
37
  status: number
34
38
  code: string
39
+ /** Human-readable remediation hint surfaced in debug output (AdonisJS `error.help`). */
40
+ help?: string
35
41
 
36
42
  constructor(message: string, options?: { status?: number; code?: string }) {
37
43
  super(message)
@@ -40,6 +46,7 @@ export class Exception extends Error {
40
46
  const ctor = this.constructor as any as typeof Exception
41
47
  this.status = options?.status ?? ctor.status
42
48
  this.code = options?.code ?? ctor.code
49
+ if (ctor.help !== undefined) this.help = ctor.help
43
50
  }
44
51
 
45
52
  /** Override to self-handle the exception (convert to HTTP response). */
@@ -49,6 +56,42 @@ export class Exception extends Error {
49
56
  report?(error: this, ctx: HttpContext): Promise<void> | void
50
57
  }
51
58
 
59
+ /** A concrete Exception subclass produced by {@link createError}. */
60
+ export interface ExceptionConstructor {
61
+ new (...args: unknown[]): Exception
62
+ status: number
63
+ code: string
64
+ }
65
+
66
+ /**
67
+ * Build a reusable Exception subclass in one line (AdonisJS `createError`).
68
+ * `message` may contain `util.format` placeholders (`%s`, `%d`) filled by the
69
+ * constructor args:
70
+ *
71
+ * const E_RESOURCE_MISSING = createError('Resource %s not found', 'E_RESOURCE_MISSING', 404)
72
+ * throw new E_RESOURCE_MISSING('user-42') // → "Resource user-42 not found"
73
+ */
74
+ export function createError(message: string, code: string, status = 500): ExceptionConstructor {
75
+ return class extends Exception {
76
+ static override status = status
77
+ static override code = code
78
+ constructor(...args: unknown[]) {
79
+ super(format(message, ...args), { status, code })
80
+ }
81
+ }
82
+ }
83
+
84
+ /** A generic 500 runtime error (AdonisJS `RuntimeException`). */
85
+ export class RuntimeException extends Exception {
86
+ static override status = 500
87
+ static override code = 'E_RUNTIME_EXCEPTION'
88
+ }
89
+
90
+ /** Raised when a function receives invalid arguments (AdonisJS `InvalidArgumentsException`). */
91
+ export class InvalidArgumentsException extends RuntimeException {
92
+ static override code = 'E_INVALID_ARGUMENTS'
93
+ }
94
+
52
95
  // ─── Built-in exceptions ──────────────────────────────────
53
96
 
54
97
  export class E_ROUTE_NOT_FOUND extends Exception {
@@ -133,6 +176,34 @@ export class E_HTTP_EXCEPTION extends Exception {
133
176
  }
134
177
  }
135
178
 
179
+ /**
180
+ * Thrown by `response.abort()` / `response.abortIf()` (AdonisJS parity). Carries
181
+ * an arbitrary body that {@link handle} renders as-is — a string verbatim,
182
+ * anything else as JSON.
183
+ */
184
+ export class E_HTTP_REQUEST_ABORTED extends Exception {
185
+ static override status = 400
186
+ static override code = 'E_HTTP_REQUEST_ABORTED'
187
+
188
+ readonly body: unknown
189
+
190
+ constructor(body: unknown, status = 400) {
191
+ super(typeof body === 'string' ? body : 'Request aborted', {
192
+ status,
193
+ code: 'E_HTTP_REQUEST_ABORTED',
194
+ })
195
+ this.body = body
196
+ }
197
+
198
+ override handle(_error: this, ctx: HttpContext): void {
199
+ if (typeof this.body === 'string') {
200
+ ctx.response.status(this.status).send(this.body)
201
+ } else {
202
+ ctx.response.status(this.status).json(this.body)
203
+ }
204
+ }
205
+ }
206
+
136
207
  // ─── ExceptionHandler ─────────────────────────────────────
137
208
 
138
209
  /**
@@ -150,54 +221,131 @@ export class E_HTTP_EXCEPTION extends Exception {
150
221
  * 2. Else → this.handle() → content negotiation (JSON or HTML)
151
222
  * 3. Then → this.report() → logging/monitoring
152
223
  */
153
- export class ExceptionHandler {
224
+ /** Renders an HTML error page for a status (AdonisJS `StatusPageRenderer`). */
225
+ export type StatusPageRenderer = (error: unknown, ctx: HttpContext) => Promise<string> | string
226
+
227
+ /** A constructor usable in `instanceof` for {@link ExceptionHandler.ignoreExceptions}. */
228
+ export type ExceptionClass = new (...args: never[]) => Error
229
+
230
+ /** Reporting log level, keyed to `console` (AdonisJS levels: error/warn/info). */
231
+ type LogLevel = 'error' | 'warn' | 'info'
232
+
233
+ export class ExceptionHandler extends Macroable {
154
234
  protected debug: boolean
235
+ /** Render `statusPages` (browser HTML). Defaults to production only (AdonisJS). */
236
+ protected renderStatusPages: boolean = process.env.NODE_ENV === 'production'
237
+ /** Status → HTML renderer, keys may be single codes or `'500..599'` ranges. */
238
+ protected statusPages: Record<string, StatusPageRenderer> = {}
239
+ /** Master reporting switch (AdonisJS `reportErrors`). */
240
+ protected reportErrors = true
155
241
  protected ignoreStatuses: number[] = [400, 401, 404, 422]
156
242
  protected ignoreCodes: string[] = []
243
+ /** Exception classes never reported (AdonisJS `ignoreExceptions`). */
244
+ protected ignoreExceptions: ExceptionClass[] = []
245
+
246
+ #expandedStatusPages?: Record<number, StatusPageRenderer>
157
247
 
158
248
  constructor(debug = false) {
249
+ super()
159
250
  this.debug = debug
160
251
  }
161
252
 
253
+ /** Whether debug detail is exposed for this request (AdonisJS — override per-ctx). */
254
+ protected isDebuggingEnabled(_ctx: HttpContext): boolean {
255
+ return this.debug
256
+ }
257
+
258
+ /** Log level for an error by status: 5xx→error, 4xx→warn, else info (AdonisJS). */
259
+ protected getErrorLogLevel(status: number): LogLevel {
260
+ if (status >= 500) return 'error'
261
+ if (status >= 400) return 'warn'
262
+ return 'info'
263
+ }
264
+
265
+ /** Whether an error should be reported — honours every ignore list (AdonisJS `shouldReport`). */
266
+ protected shouldReport(error: unknown): boolean {
267
+ if (!this.reportErrors) return false
268
+ const { status, code } = extractErrorMeta(error)
269
+ if (this.ignoreStatuses.includes(status)) return false
270
+ if (this.ignoreCodes.includes(code)) return false
271
+ if (this.ignoreExceptions.some((exception) => error instanceof exception)) return false
272
+ return true
273
+ }
274
+
275
+ /** Expand `statusPages` range keys (`'500..599'`) into a per-code lookup (cached). */
276
+ #expandStatusPages(): Record<number, StatusPageRenderer> {
277
+ if (!this.#expandedStatusPages) {
278
+ const expanded: Record<number, StatusPageRenderer> = {}
279
+ for (const range of Object.keys(this.statusPages)) {
280
+ Object.assign(expanded, parseStatusRange(range, this.statusPages[range]))
281
+ }
282
+ this.#expandedStatusPages = expanded
283
+ }
284
+ return this.#expandedStatusPages
285
+ }
286
+
162
287
  /** Convert an exception to an HTTP response. */
163
288
  async handle(error: unknown, ctx: HttpContext): Promise<void> {
164
- // Self-handled exceptions take over entirely.
165
- if (error instanceof Exception && typeof error.handle === 'function') {
289
+ // Self-handled errors take over entirely (duck-typed, per AdonisJS — any
290
+ // thrown value exposing a `handle()` method, not just `Exception` instances).
291
+ if (isSelfHandling(error)) {
166
292
  await error.handle(error, ctx)
167
293
  return
168
294
  }
169
295
 
170
- const { status, code } = extractErrorMeta(error)
171
- const message = this.#errorMessage(error)
172
- if (wantsJson(ctx)) {
173
- this.#sendJsonError(ctx, status, code, message, error)
174
- } else {
175
- this.#sendHtmlError(ctx, status, message)
296
+ // Negotiation. DEVIATION (named): ream lists `json` FIRST, so a request
297
+ // with no/`*/*` Accept defaults to JSON (API-first). AdonisJS lists `html`
298
+ // first (full-stack default). Explicit `text/html` / `vnd.api+json` are
299
+ // honoured identically.
300
+ switch (ctx.request.accepts(['json', 'html', 'application/vnd.api+json'])) {
301
+ case 'application/vnd.api+json':
302
+ this.#sendJsonApiError(error, ctx)
303
+ return
304
+ case 'html':
305
+ await this.#sendHtmlError(error, ctx)
306
+ return
307
+ default:
308
+ this.#sendJsonError(error, ctx)
176
309
  }
177
310
  }
178
311
 
179
312
  /** Pick the user-facing message — full detail only in debug mode. */
180
- #errorMessage(error: unknown): string {
181
- if (this.debug && error instanceof Error) return error.message
313
+ #errorMessage(error: unknown, ctx: HttpContext): string {
314
+ if (this.isDebuggingEnabled(ctx) && error instanceof Error) return error.message
182
315
  if (error instanceof Exception) return error.message
183
316
  return 'An internal error occurred'
184
317
  }
185
318
 
186
- #sendJsonError(
187
- ctx: HttpContext,
188
- status: number,
189
- code: string,
190
- message: string,
191
- error: unknown,
192
- ): void {
193
- const errorPayload: Record<string, unknown> = { code, message }
194
- if (this.debug && error instanceof Error && error.stack) {
195
- errorPayload.stack = error.stack
319
+ #sendJsonError(error: unknown, ctx: HttpContext): void {
320
+ const { status, code } = extractErrorMeta(error)
321
+ const payload: Record<string, unknown> = { code, message: this.#errorMessage(error, ctx) }
322
+ if (error instanceof Exception && error.help) payload.help = error.help
323
+ if (this.isDebuggingEnabled(ctx) && error instanceof Error && error.stack) {
324
+ payload.stack = error.stack
196
325
  }
197
- ctx.response.status(status).json({ error: errorPayload })
326
+ ctx.response.status(status).json({ error: payload })
198
327
  }
199
328
 
200
- #sendHtmlError(ctx: HttpContext, status: number, message: string): void {
329
+ #sendJsonApiError(error: unknown, ctx: HttpContext): void {
330
+ const { status, code } = extractErrorMeta(error)
331
+ ctx.response
332
+ .status(status)
333
+ .type('application/vnd.api+json')
334
+ .json({ errors: [{ title: this.#errorMessage(error, ctx), code, status: String(status) }] })
335
+ }
336
+
337
+ async #sendHtmlError(error: unknown, ctx: HttpContext): Promise<void> {
338
+ const { status } = extractErrorMeta(error)
339
+ // A registered status page wins (production browser error pages).
340
+ if (this.renderStatusPages) {
341
+ const renderer = this.#expandStatusPages()[status]
342
+ if (renderer) {
343
+ const html = await renderer(error, ctx)
344
+ ctx.response.status(status).type('text/html; charset=utf-8').send(html)
345
+ return
346
+ }
347
+ }
348
+ const message = this.#errorMessage(error, ctx)
201
349
  ctx.response
202
350
  .status(status)
203
351
  .type('text/html; charset=utf-8')
@@ -209,23 +357,22 @@ export class ExceptionHandler {
209
357
 
210
358
  /** Log/report an exception. Override for custom monitoring. */
211
359
  async report(error: unknown, ctx: HttpContext): Promise<void> {
212
- if (error instanceof Exception) {
213
- if (this.ignoreStatuses.includes(error.status)) return
214
- if (this.ignoreCodes.includes(error.code)) return
215
- }
360
+ if (!this.shouldReport(error)) return
216
361
 
217
- // Self-reported exceptions
218
- if (error instanceof Exception && typeof error.report === 'function') {
362
+ // Self-reported exceptions handle their own reporting.
363
+ if (isSelfReporting(error)) {
219
364
  await error.report(error, ctx)
220
365
  return
221
366
  }
222
367
 
223
- // Default: log to stderr
368
+ // Default: log at the status-appropriate level.
369
+ const { status } = extractErrorMeta(error)
370
+ const level = this.getErrorLogLevel(status)
224
371
  const context = this.context(ctx)
225
372
  if (error instanceof Error) {
226
- console.error(`[${new Date().toISOString()}] ${error.message}`, context, error.stack)
373
+ console[level](`[${new Date().toISOString()}] ${error.message}`, context, error.stack)
227
374
  } else {
228
- console.error(`[${new Date().toISOString()}] Unknown error:`, error, context)
375
+ console[level](`[${new Date().toISOString()}] Unknown error:`, error, context)
229
376
  }
230
377
  }
231
378
 
@@ -254,15 +401,45 @@ function extractErrorMeta(error: unknown): { status: number; code: string } {
254
401
  return { status, code }
255
402
  }
256
403
 
257
- /** Content negotiation: JSON unless the client explicitly prefers HTML. */
258
- function wantsJson(ctx: HttpContext): boolean {
404
+ /** True when a thrown value self-handles via a `handle()` method (AdonisJS duck-type). */
405
+ function isSelfHandling(
406
+ error: unknown,
407
+ ): error is { handle: (error: unknown, ctx: HttpContext) => Promise<void> | void } {
259
408
  return (
260
- ctx.request.accepts(['json', 'html']) === 'json' ||
261
- (ctx.request.header('accept')?.includes('application/json') ?? false) ||
262
- !ctx.request.header('accept')?.includes('text/html')
409
+ error !== null &&
410
+ typeof error === 'object' &&
411
+ 'handle' in error &&
412
+ typeof error.handle === 'function'
263
413
  )
264
414
  }
265
415
 
416
+ /** True when a thrown value self-reports via a `report()` method. */
417
+ function isSelfReporting(
418
+ error: unknown,
419
+ ): error is { report: (error: unknown, ctx: HttpContext) => Promise<void> | void } {
420
+ return (
421
+ error !== null &&
422
+ typeof error === 'object' &&
423
+ 'report' in error &&
424
+ typeof error.report === 'function'
425
+ )
426
+ }
427
+
428
+ /** Expand a status-page key into a per-code map: `'500..599'` → {500,…,599}, `'404'` → {404}. */
429
+ function parseStatusRange(
430
+ range: string,
431
+ renderer: StatusPageRenderer,
432
+ ): Record<number, StatusPageRenderer> {
433
+ const parts = range.split('..')
434
+ const min = Number(parts[0])
435
+ const max = Number(parts[1])
436
+ if (parts.length === 1 && !Number.isNaN(min)) return { [min]: renderer }
437
+ if (Number.isNaN(min) || Number.isNaN(max)) return {}
438
+ const result: Record<number, StatusPageRenderer> = {}
439
+ for (let status = min; status <= max; status += 1) result[status] = renderer
440
+ return result
441
+ }
442
+
266
443
  function escapeHtml(str: string): string {
267
444
  return str
268
445
  .replace(/&/g, '&amp;')
@@ -10,15 +10,23 @@
10
10
  import { AsyncLocalStorage } from 'node:async_hooks'
11
11
  import type { ServiceToken } from '../container/types.js'
12
12
  import type { Emitter } from '../events/Emitter.js'
13
+ import type { CookieSigner } from '../security/CookieSigner.js'
14
+ import type { SignedUrl } from '../security/SignedUrl.js'
15
+ import type { Session } from '../session/Session.js'
16
+ import { Macroable } from '../utils/Macroable.js'
13
17
  import type { RouteUrlResolver } from './RedirectBuilder.js'
14
18
  import { RedirectBuilder } from './RedirectBuilder.js'
15
19
  import type { RawRequest } from './Request.js'
16
20
  import { Request } from './Request.js'
17
21
  import { Response } from './Response.js'
18
- import type { Session } from '../session/Session.js'
19
22
 
20
23
  export interface AuthState {
21
- authenticated: boolean
24
+ /**
25
+ * Whether the request carries an authenticated user (AdonisJS `auth.isAuthenticated`
26
+ * parity, renamed from `authenticated`). Always present — Ream defaults it to
27
+ * `false`; an auth provider (e.g. `@c9up/warden`'s Authenticator) sets it true.
28
+ */
29
+ isAuthenticated: boolean
22
30
  user?: {
23
31
  id: string
24
32
  email?: string
@@ -28,6 +36,19 @@ export interface AuthState {
28
36
  }
29
37
  roles?: string[]
30
38
  permissions?: string[]
39
+ /**
40
+ * Optional AdonisJS `Authenticator` surface — present when an auth provider
41
+ * (Warden) fills the slot with a per-request authenticator, absent for the
42
+ * bare `{ isAuthenticated: false }` default. Methods are OPTIONAL so Ream stays
43
+ * agnostic: a non-Warden host, or the default state, remains a valid AuthState.
44
+ */
45
+ authenticate?(): Promise<void>
46
+ check?(): Promise<boolean>
47
+ authenticateUsing?(guards?: string[], options?: { loginRoute?: string }): Promise<void>
48
+ getUserOrFail?(): AuthState['user']
49
+ use?(name: string): unknown
50
+ readonly authenticationAttempted?: boolean
51
+ readonly authenticatedViaGuard?: string
31
52
  }
32
53
 
33
54
  /**
@@ -60,9 +81,13 @@ export interface Authorizer {
60
81
  * exposing it under the Adonis name with `.make()` keeps the public contract
61
82
  * aligned so a future scoped resolver is a non-breaking swap.
62
83
  */
63
- export interface ContainerResolver {
64
- /** Resolve/construct a service by token (class, string, or symbol). */
65
- make<T>(token: ServiceToken): T
84
+ // File-local: the app container's minimal resolver shape. Not exported — the
85
+ // public resolver contract is the events `ContainerResolver`; each subsystem
86
+ // declares its own structurally-compatible interface to stay decoupled (same
87
+ // pattern as warden/blackhole middleware), so nothing cross-imports it.
88
+ interface ContainerResolver {
89
+ /** Resolve/construct a service by token (class, string, or symbol). Async (AdonisJS parity). */
90
+ make<T>(token: ServiceToken): Promise<T>
66
91
  }
67
92
 
68
93
  /**
@@ -80,7 +105,7 @@ export interface ContextLogger {
80
105
  }
81
106
 
82
107
  /** A logger that can spawn a request-scoped child (e.g. spectrum's `child()`). */
83
- interface ChildLoggerSource extends ContextLogger {
108
+ export interface ChildLoggerSource extends ContextLogger {
84
109
  child?(options: { correlationId?: string; module?: string }): ContextLogger
85
110
  }
86
111
 
@@ -100,17 +125,16 @@ function consoleLogger(correlationId: string): ContextLogger {
100
125
  }
101
126
  }
102
127
 
103
- /** Resolve a request-scoped logger from the container, or fall back to console. */
128
+ /**
129
+ * Build a request-scoped logger from the app's base logger, or fall back to
130
+ * console. The base logger is resolved (async) once by HttpKernel and injected
131
+ * via {@link HttpContext.setBaseLogger}; child-scoping here stays synchronous
132
+ * so `ctx.logger` remains a plain getter.
133
+ */
104
134
  function resolveRequestLogger(
105
- resolver: ContainerResolver | undefined,
135
+ base: ChildLoggerSource | undefined,
106
136
  correlationId: string,
107
137
  ): ContextLogger {
108
- let base: ChildLoggerSource | undefined
109
- try {
110
- base = resolver?.make<ChildLoggerSource>('logger')
111
- } catch {
112
- base = undefined
113
- }
114
138
  if (!base) return consoleLogger(correlationId)
115
139
  return typeof base.child === 'function' ? base.child({ correlationId }) : base
116
140
  }
@@ -134,7 +158,7 @@ export interface RouteInfo {
134
158
  /** Ambient per-request context store (AdonisJS `HttpContext` ALS accessor). */
135
159
  const httpContextStorage = new AsyncLocalStorage<HttpContext>()
136
160
 
137
- export class HttpContext {
161
+ export class HttpContext extends Macroable {
138
162
  /**
139
163
  * @internal Run `fn` with `ctx` as the ambient HttpContext, so code deep in
140
164
  * the call stack can reach it via {@link HttpContext.get}/{@link getOrFail}
@@ -161,6 +185,15 @@ export class HttpContext {
161
185
  return ctx
162
186
  }
163
187
 
188
+ /**
189
+ * Run `fn` with the ambient HttpContext temporarily cleared (AdonisJS
190
+ * `HttpContext.runOutsideContext`) — e.g. to detach background work so it
191
+ * doesn't inherit and pin the request's context.
192
+ */
193
+ static runOutsideContext<T>(fn: () => T): T {
194
+ return httpContextStorage.exit(fn)
195
+ }
196
+
164
197
  /** Unique request/correlation ID. */
165
198
  readonly id: string
166
199
 
@@ -177,7 +210,7 @@ export class HttpContext {
177
210
  readonly route: RouteInfo
178
211
 
179
212
  /** Authentication state — populated by auth middleware. */
180
- auth: AuthState = { authenticated: false }
213
+ auth: AuthState = { isAuthenticated: false }
181
214
 
182
215
  /**
183
216
  * Request locale — the primary subtag of the first `Accept-Language` entry
@@ -223,21 +256,50 @@ export class HttpContext {
223
256
  /** Lazily-built per-request logger (see {@link logger}). */
224
257
  #logger?: ContextLogger
225
258
 
259
+ /** App base logger, resolved once (async) by HttpKernel and injected. */
260
+ #baseLogger?: ChildLoggerSource
261
+
226
262
  /** Route URL resolver for redirect().toRoute(). */
227
263
  #routeUrlResolver?: RouteUrlResolver
228
264
 
229
265
  /**
230
- * Per-request logger (AdonisJS `ctx.logger`). Resolves the container `'logger'`
231
- * (e.g. `@c9up/spectrum`) child-scoped to this request's `id`, or falls back to
232
- * a console logger when none is registered. Built once, on first access.
266
+ * Per-request logger (AdonisJS `ctx.logger`) — the injected app logger (e.g.
267
+ * `@c9up/spectrum`) child-scoped to this request's `id`, or a console logger
268
+ * when none is registered. Built once, on first access.
233
269
  */
234
270
  get logger(): ContextLogger {
235
271
  if (!this.#logger) {
236
- this.#logger = resolveRequestLogger(this.containerResolver, this.id)
272
+ this.#logger = resolveRequestLogger(this.#baseLogger, this.id)
237
273
  }
238
274
  return this.#logger
239
275
  }
240
276
 
277
+ /** @internal Inject the app base logger (resolved async by HttpKernel). */
278
+ setBaseLogger(logger: ChildLoggerSource): void {
279
+ this.#baseLogger = logger
280
+ }
281
+
282
+ /** @internal Inject the APP_KEY cookie signer into request + response (from HttpKernel). */
283
+ setCookieSigner(signer: CookieSigner): void {
284
+ this.response.setCookieSigner(signer)
285
+ this.request.setCookieSigner(signer)
286
+ }
287
+
288
+ /** @internal Inject the APP_KEY signed-URL helper into the request (from HttpKernel). */
289
+ setSignedUrl(signedUrl: SignedUrl): void {
290
+ this.request.setSignedUrl(signedUrl)
291
+ }
292
+
293
+ /** Subdomains of the request host (AdonisJS `ctx.subdomains`). */
294
+ get subdomains(): string[] {
295
+ return this.request.subdomains()
296
+ }
297
+
298
+ /** Stable identifier for the matched route (`METHOD-pattern`) — AdonisJS `ctx.routeKey`. */
299
+ get routeKey(): string {
300
+ return `${this.request.method()}-${this.route.pattern}`
301
+ }
302
+
241
303
  constructor(
242
304
  id: string,
243
305
  rawRequest: RawRequest,
@@ -245,12 +307,23 @@ export class HttpContext {
245
307
  route: RouteInfo,
246
308
  containerResolver?: ContainerResolver,
247
309
  ) {
310
+ super()
248
311
  this.id = id
249
312
  this.params = params
250
313
  this.route = route
251
314
  this.containerResolver = containerResolver
252
315
  this.request = new Request(rawRequest, params)
253
316
  this.response = new Response()
317
+ // Give the response read access to the request (AdonisJS wires them too) —
318
+ // needed by `response.fresh()` for conditional-GET / ETag revalidation.
319
+ this.response.setRequest(this.request)
320
+ // And the reverse, so `request.fresh()`/`stale()` delegate to it, plus the
321
+ // matched-route info for `request.matchesRoute()`.
322
+ this.request.setResponse(this.response)
323
+ this.request.setRouteInfo({ name: route.name, pattern: route.pattern })
324
+ // APP_KEY-backed encryption / signed-URL services + the base logger are
325
+ // resolved asynchronously by HttpKernel and injected via the setters above
326
+ // (the container is async now, so a constructor can't resolve them itself).
254
327
  this.locale = parseAcceptLanguage(this.request.header('accept-language')) ?? 'en'
255
328
 
256
329
  // Wire redirect builder with request context
@@ -14,7 +14,10 @@ import type { Response } from './Response.js'
14
14
  export type RouteUrlResolver = (name: string, params?: Record<string, string>) => string
15
15
 
16
16
  function isSameOriginOrRelative(referer: string, requestUrl?: string): boolean {
17
- if (referer.startsWith('/') && !referer.startsWith('//')) return true
17
+ // A relative path is trusted only when it starts with a single "/" and has no
18
+ // backslash: browsers normalise "\" to "/", so "/\evil.com" would become the
19
+ // protocol-relative "//evil.com" that a lone `!startsWith("//")` check misses.
20
+ if (referer.startsWith('/') && !referer.startsWith('//') && !referer.includes('\\')) return true
18
21
  if (!requestUrl) return false
19
22
  try {
20
23
  const ref = new URL(referer)