@c9up/ream 0.2.0 → 0.2.2

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 (62) hide show
  1. package/dist/Ignitor.d.ts +0 -8
  2. package/dist/Ignitor.d.ts.map +1 -1
  3. package/dist/Ignitor.js +15 -1
  4. package/dist/Ignitor.js.map +1 -1
  5. package/dist/bodyparser/MultipartFile.d.ts +22 -12
  6. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  7. package/dist/bodyparser/MultipartFile.js +42 -15
  8. package/dist/bodyparser/MultipartFile.js.map +1 -1
  9. package/dist/errors/aggregate.d.ts +24 -0
  10. package/dist/errors/aggregate.d.ts.map +1 -0
  11. package/dist/errors/aggregate.js +24 -0
  12. package/dist/errors/aggregate.js.map +1 -0
  13. package/dist/events/services/main.d.ts.map +1 -1
  14. package/dist/events/services/main.js +3 -10
  15. package/dist/events/services/main.js.map +1 -1
  16. package/dist/http/Exception.d.ts +76 -6
  17. package/dist/http/Exception.d.ts.map +1 -1
  18. package/dist/http/Exception.js +178 -85
  19. package/dist/http/Exception.js.map +1 -1
  20. package/dist/index.d.ts +10 -4
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +8 -4
  23. package/dist/index.js.map +1 -1
  24. package/dist/router/Router.js +1 -1
  25. package/dist/router/Router.js.map +1 -1
  26. package/dist/services/app.d.ts.map +1 -1
  27. package/dist/services/app.js +3 -21
  28. package/dist/services/app.js.map +1 -1
  29. package/dist/services/console.d.ts.map +1 -1
  30. package/dist/services/console.js +3 -19
  31. package/dist/services/console.js.map +1 -1
  32. package/dist/services/createServiceProxy.d.ts +19 -0
  33. package/dist/services/createServiceProxy.d.ts.map +1 -0
  34. package/dist/services/createServiceProxy.js +44 -0
  35. package/dist/services/createServiceProxy.js.map +1 -0
  36. package/dist/services/router.d.ts.map +1 -1
  37. package/dist/services/router.js +3 -12
  38. package/dist/services/router.js.map +1 -1
  39. package/dist/services/server.d.ts.map +1 -1
  40. package/dist/services/server.js +3 -22
  41. package/dist/services/server.js.map +1 -1
  42. package/dist/types/container.d.ts +64 -0
  43. package/dist/types/container.d.ts.map +1 -0
  44. package/dist/types/container.js +23 -0
  45. package/dist/types/container.js.map +1 -0
  46. package/events.win32-x64-msvc.node +0 -0
  47. package/index.win32-x64-msvc.node +0 -0
  48. package/package.json +52 -30
  49. package/scheduler.win32-x64-msvc.node +0 -0
  50. package/src/Ignitor.ts +15 -9
  51. package/src/bodyparser/MultipartFile.ts +43 -15
  52. package/src/errors/aggregate.ts +33 -0
  53. package/src/events/services/main.ts +6 -12
  54. package/src/http/Exception.ts +225 -99
  55. package/src/index.ts +15 -2
  56. package/src/router/Router.ts +1 -1
  57. package/src/services/app.ts +6 -23
  58. package/src/services/console.ts +6 -21
  59. package/src/services/createServiceProxy.ts +47 -0
  60. package/src/services/router.ts +6 -14
  61. package/src/services/server.ts +6 -24
  62. package/src/types/container.ts +66 -0
@@ -154,15 +154,16 @@ export class E_VALIDATION_ERROR extends Exception {
154
154
  static override status = 422
155
155
  static override code = 'E_VALIDATION_ERROR'
156
156
 
157
- errors: unknown[]
158
-
159
- constructor(errors: unknown[]) {
160
- super('Validation failed', { status: 422, code: 'E_VALIDATION_ERROR' })
161
- this.errors = errors
162
- }
163
-
164
- override handle(_error: this, ctx: HttpContext): void {
165
- ctx.response.status(422).json({ errors: this.errors })
157
+ /**
158
+ * Per-field failures. Named `messages` because that is what VineJS, rune and
159
+ * AdonisJS's own validation renderers read — a handler copied from an Adonis
160
+ * app reaches for `error.messages`.
161
+ */
162
+ messages: unknown[]
163
+
164
+ constructor(messages: unknown[]) {
165
+ super('Validation failure', { status: 422, code: 'E_VALIDATION_ERROR' })
166
+ this.messages = messages
166
167
  }
167
168
  }
168
169
 
@@ -233,8 +234,34 @@ export class E_HTTP_REQUEST_ABORTED extends Exception {
233
234
  * 2. Else → this.handle() → content negotiation (JSON or HTML)
234
235
  * 3. Then → this.report() → logging/monitoring
235
236
  */
237
+ /**
238
+ * A thrown value, normalised into the shape every renderer reads.
239
+ *
240
+ * AdonisJS hands this to `renderError*` / `renderValidationError*` and to the
241
+ * status-page renderers, so an app that overrides one of them receives the same
242
+ * object it would there — a plain record, never an `Error` subclass, because
243
+ * what is thrown is not always one.
244
+ */
245
+ export type HttpError = {
246
+ message: string
247
+ status: number
248
+ code: string
249
+ stack?: string
250
+ cause?: unknown
251
+ /** Per-field failures, on a validation error (VineJS / rune shape). */
252
+ messages?: unknown
253
+ /** Extra detail an exception chose to carry. */
254
+ errors?: unknown
255
+ /** Set when the thrown value handles its own response. */
256
+ handle?: (error: unknown, ctx: HttpContext) => Promise<void> | void
257
+ /** Set when the thrown value reports itself. */
258
+ report?: (error: unknown, ctx: HttpContext) => Promise<void> | void
259
+ /** A hint appended to the message, when the exception carries one. */
260
+ help?: string
261
+ }
262
+
236
263
  /** Renders an HTML error page for a status (AdonisJS `StatusPageRenderer`). */
237
- export type StatusPageRenderer = (error: unknown, ctx: HttpContext) => Promise<string> | string
264
+ export type StatusPageRenderer = (error: HttpError, ctx: HttpContext) => Promise<string> | string
238
265
 
239
266
  /** A constructor usable in `instanceof` for {@link ExceptionHandler.ignoreExceptions}. */
240
267
  export type ExceptionClass = new (...args: never[]) => Error
@@ -268,18 +295,17 @@ export class ExceptionHandler extends Macroable {
268
295
  }
269
296
 
270
297
  /** Log level for an error by status: 5xx→error, 4xx→warn, else info (AdonisJS). */
271
- protected getErrorLogLevel(status: number): LogLevel {
272
- if (status >= 500) return 'error'
273
- if (status >= 400) return 'warn'
298
+ protected getErrorLogLevel(error: HttpError): LogLevel {
299
+ if (error.status >= 500) return 'error'
300
+ if (error.status >= 400) return 'warn'
274
301
  return 'info'
275
302
  }
276
303
 
277
304
  /** Whether an error should be reported — honours every ignore list (AdonisJS `shouldReport`). */
278
- protected shouldReport(error: unknown): boolean {
305
+ protected shouldReport(error: HttpError): boolean {
279
306
  if (!this.reportErrors) return false
280
- const { status, code } = extractErrorMeta(error)
281
- if (this.ignoreStatuses.includes(status)) return false
282
- if (this.ignoreCodes.includes(code)) return false
307
+ if (this.ignoreStatuses.includes(error.status)) return false
308
+ if (this.ignoreCodes.includes(error.code)) return false
283
309
  if (this.ignoreExceptions.some((exception) => error instanceof exception)) return false
284
310
  return true
285
311
  }
@@ -297,95 +323,185 @@ export class ExceptionHandler extends Macroable {
297
323
  }
298
324
 
299
325
  /** Convert an exception to an HTTP response. */
300
- async handle(error: unknown, ctx: HttpContext): Promise<void> {
301
- // Self-handled errors take over entirely (duck-typed, per AdonisJS — any
302
- // thrown value exposing a `handle()` method, not just `Exception` instances).
303
- if (isSelfHandling(error)) {
304
- await error.handle(error, ctx)
305
- return
306
- }
307
-
308
- // Negotiation. DEVIATION (named): ream lists `json` FIRST, so a request
309
- // with no/`*/*` Accept defaults to JSON (API-first). AdonisJS lists `html`
310
- // first (full-stack default). Explicit `text/html` / `vnd.api+json` are
311
- // honoured identically.
312
- switch (ctx.request.accepts(['json', 'html', 'application/vnd.api+json'])) {
313
- case 'application/vnd.api+json':
314
- this.#sendJsonApiError(error, ctx)
315
- return
316
- case 'html':
317
- await this.#sendHtmlError(error, ctx)
318
- return
319
- default:
320
- this.#sendJsonError(error, ctx)
321
- }
326
+ /**
327
+ * Normalise any thrown value into an {@link HttpError}.
328
+ *
329
+ * Everything downstream — the renderers, the status pages, the report path —
330
+ * reads this shape, so a handler override never has to re-sniff whether it
331
+ * was handed an `Exception`, a plain object, or a string.
332
+ */
333
+ protected toHttpError(error: unknown): HttpError {
334
+ // AdonisJS returns the thrown object ITSELF, filled in — not a copy. That
335
+ // identity is load-bearing: `ignoreExceptions` matches with `instanceof`,
336
+ // and `handle`/`report` have to stay bound to their own instance. A copy
337
+ // would quietly break both.
338
+ const source: object =
339
+ typeof error === 'object' && error !== null ? error : new Error(String(error))
340
+ const { status, code } = extractErrorMeta(error)
341
+ const message =
342
+ 'message' in source && typeof source.message === 'string' && source.message.length > 0
343
+ ? source.message
344
+ : 'Internal server error'
345
+ return Object.assign(source, { status, code, message })
322
346
  }
323
347
 
324
- /** Pick the user-facing message — full detail only in debug mode. */
325
- #errorMessage(error: unknown, ctx: HttpContext): string {
326
- if (this.isDebuggingEnabled(ctx) && error instanceof Error) return error.message
327
- if (error instanceof Exception) return error.message
348
+ /**
349
+ * The message a client is allowed to see.
350
+ *
351
+ * DEVIATION (named): outside debug mode ream only echoes the message of a
352
+ * deliberate `Exception`; anything else collapses to a generic line. AdonisJS
353
+ * echoes `error.message` whatever was thrown, which leaks the text of driver
354
+ * and library errors to the client.
355
+ */
356
+ protected publicMessage(error: HttpError, ctx: HttpContext): string {
357
+ if (this.isDebuggingEnabled(ctx)) return error.message
358
+ if (error.code !== 'E_UNKNOWN') return error.message
328
359
  return 'An internal error occurred'
329
360
  }
330
361
 
331
- #sendJsonError(error: unknown, ctx: HttpContext): void {
332
- const { status, code } = extractErrorMeta(error)
333
- const payload: Record<string, unknown> = { code, message: this.#errorMessage(error, ctx) }
334
- if (error instanceof Exception && error.help) payload.help = error.help
335
- if (this.isDebuggingEnabled(ctx) && error instanceof Error && error.stack) {
336
- payload.stack = error.stack
362
+ /** Render an error as JSON. */
363
+ async renderErrorAsJSON(error: HttpError, ctx: HttpContext): Promise<void> {
364
+ const payload: Record<string, unknown> = {
365
+ code: error.code,
366
+ message: this.publicMessage(error, ctx),
337
367
  }
338
- ctx.response.status(status).json({ error: payload })
368
+ if (error.help) payload.help = error.help
369
+ if (this.isDebuggingEnabled(ctx) && error.stack) payload.stack = error.stack
370
+ ctx.response.status(error.status).json({ error: payload })
339
371
  }
340
372
 
341
- #sendJsonApiError(error: unknown, ctx: HttpContext): void {
342
- const { status, code } = extractErrorMeta(error)
373
+ /** Render an error as a JSON:API document. */
374
+ async renderErrorAsJSONAPI(error: HttpError, ctx: HttpContext): Promise<void> {
343
375
  ctx.response
344
- .status(status)
376
+ .status(error.status)
345
377
  .type('application/vnd.api+json')
346
- .json({ errors: [{ title: this.#errorMessage(error, ctx), code, status: String(status) }] })
378
+ .json({
379
+ errors: [
380
+ {
381
+ title: this.publicMessage(error, ctx),
382
+ code: error.code,
383
+ status: String(error.status),
384
+ },
385
+ ],
386
+ })
347
387
  }
348
388
 
349
- async #sendHtmlError(error: unknown, ctx: HttpContext): Promise<void> {
350
- const { status } = extractErrorMeta(error)
351
- // A registered status page wins (production browser error pages).
389
+ /** Render an error as HTML, preferring a registered status page. */
390
+ async renderErrorAsHTML(error: HttpError, ctx: HttpContext): Promise<void> {
352
391
  if (this.renderStatusPages) {
353
- const renderer = this.#expandStatusPages()[status]
392
+ const renderer = this.#expandStatusPages()[error.status]
354
393
  if (renderer) {
355
394
  const html = await renderer(error, ctx)
356
- ctx.response.status(status).type('text/html; charset=utf-8').send(html)
395
+ ctx.response.status(error.status).type('text/html; charset=utf-8').send(html)
357
396
  return
358
397
  }
359
398
  }
360
- const message = this.#errorMessage(error, ctx)
399
+ const message = this.publicMessage(error, ctx)
361
400
  ctx.response
362
- .status(status)
401
+ .status(error.status)
363
402
  .type('text/html; charset=utf-8')
364
403
  .send(
365
- `<!DOCTYPE html><html><head><title>Error ${status}</title></head>` +
366
- `<body><h1>${status}</h1><p>${escapeHtml(message)}</p></body></html>`,
404
+ `<!DOCTYPE html><html><head><title>Error ${error.status}</title></head>` +
405
+ `<body><h1>${error.status}</h1><p>${escapeHtml(message)}</p></body></html>`,
367
406
  )
368
407
  }
369
408
 
409
+ /** Render per-field validation failures as JSON. */
410
+ async renderValidationErrorAsJSON(error: HttpError, ctx: HttpContext): Promise<void> {
411
+ ctx.response.status(error.status).json({ errors: error.messages })
412
+ }
413
+
414
+ /** Render per-field validation failures as a JSON:API document. */
415
+ async renderValidationErrorAsJSONAPI(error: HttpError, ctx: HttpContext): Promise<void> {
416
+ const messages = Array.isArray(error.messages) ? error.messages : []
417
+ ctx.response
418
+ .status(error.status)
419
+ .type('application/vnd.api+json')
420
+ .json({ errors: messages.map(toJsonApiValidationError) })
421
+ }
422
+
423
+ /** Render per-field validation failures as HTML. */
424
+ async renderValidationErrorAsHTML(error: HttpError, ctx: HttpContext): Promise<void> {
425
+ const messages = Array.isArray(error.messages) ? error.messages : []
426
+ ctx.response
427
+ .status(error.status)
428
+ .type('text/html; charset=utf-8')
429
+ .send(
430
+ messages
431
+ .map((message) => {
432
+ const { field, text } = readValidationMessage(message)
433
+ return `${escapeHtml(field)} - ${escapeHtml(text)}`
434
+ })
435
+ .join('<br />'),
436
+ )
437
+ }
438
+
439
+ /**
440
+ * Pick a renderer by content negotiation.
441
+ *
442
+ * DEVIATION (named): ream lists `json` FIRST, so a request with no or `*` /`*`
443
+ * Accept defaults to JSON (API-first). AdonisJS lists `html` first
444
+ * (full-stack default). Explicit `text/html` / `vnd.api+json` are honoured
445
+ * identically.
446
+ */
447
+ async renderError(error: HttpError, ctx: HttpContext): Promise<void> {
448
+ switch (ctx.request.accepts(['json', 'html', 'application/vnd.api+json'])) {
449
+ case 'application/vnd.api+json':
450
+ return this.renderErrorAsJSONAPI(error, ctx)
451
+ case 'html':
452
+ return this.renderErrorAsHTML(error, ctx)
453
+ default:
454
+ return this.renderErrorAsJSON(error, ctx)
455
+ }
456
+ }
457
+
458
+ /** Pick a validation renderer by content negotiation. */
459
+ async renderValidationError(error: HttpError, ctx: HttpContext): Promise<void> {
460
+ switch (ctx.request.accepts(['json', 'html', 'application/vnd.api+json'])) {
461
+ case 'application/vnd.api+json':
462
+ return this.renderValidationErrorAsJSONAPI(error, ctx)
463
+ case 'html':
464
+ return this.renderValidationErrorAsHTML(error, ctx)
465
+ default:
466
+ return this.renderValidationErrorAsJSON(error, ctx)
467
+ }
468
+ }
469
+
470
+ /** Convert an exception to an HTTP response. */
471
+ async handle(error: unknown, ctx: HttpContext): Promise<void> {
472
+ const httpError = this.toHttpError(error)
473
+
474
+ // Self-handled errors take over entirely (duck-typed, per AdonisJS — any
475
+ // thrown value exposing a `handle()` method, not just `Exception` instances).
476
+ if (typeof httpError.handle === 'function') {
477
+ await httpError.handle(httpError, ctx)
478
+ return
479
+ }
480
+
481
+ // Validation failures have per-field detail and their own renderers. Keyed
482
+ // on the code plus the presence of `messages`, exactly as AdonisJS does, so
483
+ // rune's and VineJS's errors both land here.
484
+ if (httpError.code === 'E_VALIDATION_ERROR' && httpError.messages !== undefined) {
485
+ await this.renderValidationError(httpError, ctx)
486
+ return
487
+ }
488
+
489
+ await this.renderError(httpError, ctx)
490
+ }
491
+
370
492
  /** Log/report an exception. Override for custom monitoring. */
371
493
  async report(error: unknown, ctx: HttpContext): Promise<void> {
372
- if (!this.shouldReport(error)) return
494
+ const httpError = this.toHttpError(error)
495
+ if (!this.shouldReport(httpError)) return
373
496
 
374
497
  // Self-reported exceptions handle their own reporting.
375
- if (isSelfReporting(error)) {
376
- await error.report(error, ctx)
498
+ if (typeof httpError.report === 'function') {
499
+ await httpError.report(httpError, ctx)
377
500
  return
378
501
  }
379
502
 
380
- // Default: log at the status-appropriate level.
381
- const { status } = extractErrorMeta(error)
382
- const level = this.getErrorLogLevel(status)
383
- const context = this.context(ctx)
384
- if (error instanceof Error) {
385
- console[level](`[${new Date().toISOString()}] ${error.message}`, context, error.stack)
386
- } else {
387
- console[level](`[${new Date().toISOString()}] Unknown error:`, error, context)
388
- }
503
+ const level = this.getErrorLogLevel(httpError)
504
+ ctx.logger[level](httpError.message, this.context(ctx))
389
505
  }
390
506
 
391
507
  /** Provide additional context for error reports. Override for custom data. */
@@ -399,6 +515,40 @@ export class ExceptionHandler extends Macroable {
399
515
  }
400
516
  }
401
517
 
518
+ /**
519
+ * The fields a validation message carries (VineJS / rune `RuneErrorNode`).
520
+ *
521
+ * Read defensively: the messages come from whichever validator the app wired,
522
+ * and a renderer must not throw while rendering someone else's error.
523
+ */
524
+ function readValidationMessage(message: unknown): {
525
+ field: string
526
+ text: string
527
+ rule: string
528
+ meta: unknown
529
+ } {
530
+ if (message === null || typeof message !== 'object') {
531
+ return { field: '', text: String(message), rule: '', meta: undefined }
532
+ }
533
+ const field = 'field' in message && typeof message.field === 'string' ? message.field : ''
534
+ const text = 'message' in message && typeof message.message === 'string' ? message.message : ''
535
+ const rule = 'rule' in message && typeof message.rule === 'string' ? message.rule : ''
536
+ const meta = 'meta' in message ? message.meta : undefined
537
+ return { field, text, rule, meta }
538
+ }
539
+
540
+ /** One validation message, as a JSON:API error object (AdonisJS shape). */
541
+ function toJsonApiValidationError(message: unknown): Record<string, unknown> {
542
+ const { field, text, rule, meta } = readValidationMessage(message)
543
+ const error: Record<string, unknown> = {
544
+ title: text,
545
+ code: rule,
546
+ source: { pointer: field },
547
+ }
548
+ if (meta !== undefined) error.meta = meta
549
+ return error
550
+ }
551
+
402
552
  /** Extract status + code from an Exception, ReamError, or duck-typed error. */
403
553
  function extractErrorMeta(error: unknown): { status: number; code: string } {
404
554
  let status = 500
@@ -413,30 +563,6 @@ function extractErrorMeta(error: unknown): { status: number; code: string } {
413
563
  return { status, code }
414
564
  }
415
565
 
416
- /** True when a thrown value self-handles via a `handle()` method (AdonisJS duck-type). */
417
- function isSelfHandling(
418
- error: unknown,
419
- ): error is { handle: (error: unknown, ctx: HttpContext) => Promise<void> | void } {
420
- return (
421
- error !== null &&
422
- typeof error === 'object' &&
423
- 'handle' in error &&
424
- typeof error.handle === 'function'
425
- )
426
- }
427
-
428
- /** True when a thrown value self-reports via a `report()` method. */
429
- function isSelfReporting(
430
- error: unknown,
431
- ): error is { report: (error: unknown, ctx: HttpContext) => Promise<void> | void } {
432
- return (
433
- error !== null &&
434
- typeof error === 'object' &&
435
- 'report' in error &&
436
- typeof error.report === 'function'
437
- )
438
- }
439
-
440
566
  /** Expand a status-page key into a per-code map: `'500..599'` → {500,…,599}, `'404'` → {404}. */
441
567
  function parseStatusRange(
442
568
  range: string,
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ export {
40
40
  createError,
41
41
  E_FORBIDDEN,
42
42
  E_HTTP_EXCEPTION,
43
+ E_HTTP_REQUEST_ABORTED,
43
44
  E_ROUTE_NOT_FOUND,
44
45
  E_ROW_NOT_FOUND,
45
46
  E_UNAUTHORIZED,
@@ -48,6 +49,7 @@ export {
48
49
  type ExceptionClass,
49
50
  type ExceptionConstructor,
50
51
  ExceptionHandler,
52
+ type HttpError,
51
53
  InvalidArgumentsException,
52
54
  RuntimeException,
53
55
  type StatusPageRenderer,
@@ -58,6 +60,8 @@ export { RedirectBuilder } from './http/RedirectBuilder.js'
58
60
  export type { RawRequest } from './http/Request.js'
59
61
  export { Request } from './http/Request.js'
60
62
  export { Response } from './http/Response.js'
63
+ export type { SseStreamOptions } from './http/SseStream.js'
64
+ export { SseStream } from './http/SseStream.js'
61
65
  export { type GetterFn, Macroable, type MacroFn } from './utils/Macroable.js'
62
66
 
63
67
  // ─── Session ────────────────────────────────────────────────
@@ -77,6 +81,8 @@ export type {
77
81
  SessionRedisClientSource,
78
82
  } from './session/drivers/RedisDriver.js'
79
83
  export { RedisDriver as SessionRedisDriver } from './session/drivers/RedisDriver.js'
84
+ export type { ValuePath } from './session/ReadOnlyValuesStore.js'
85
+ export { ReadOnlyValuesStore } from './session/ReadOnlyValuesStore.js'
80
86
  export type {
81
87
  SessionConfig,
82
88
  SessionDriver,
@@ -139,7 +145,11 @@ export { StaticProvider } from './storage/StaticProvider.js'
139
145
 
140
146
  // Request-filter security (CSRF, XSS, rate-limit, CORS, headers, path/param)
141
147
  // lives in @c9up/blackhole. ream core keeps only the crypto primitives.
142
- export { CookieSigner } from './security/CookieSigner.js'
148
+ export {
149
+ CookieSigner,
150
+ E_INSECURE_APP_KEY,
151
+ E_MISSING_APP_KEY,
152
+ } from './security/CookieSigner.js'
143
153
  export {
144
154
  constantTimeEq,
145
155
  hasNativeCrypto,
@@ -179,6 +189,7 @@ export {
179
189
  matchers,
180
190
  OnRouteBuilder,
181
191
  RouteBuilder,
192
+ RouteResource,
182
193
  Router,
183
194
  UrlBuilder,
184
195
  } from './router/Router.js'
@@ -238,8 +249,10 @@ export { ConsoleKernel, defineConfig, Ignitor, prettyPrintError } from './Ignito
238
249
 
239
250
  // ─── Providers ──────────────────────────────────────────────
240
251
 
252
+ export { errors } from './errors/aggregate.js'
241
253
  export type { AppContext, ProviderContract } from './Provider.js'
242
254
  export { Provider } from './Provider.js'
255
+ export type { ContainerBinding, ContainerBindings } from './types/container.js'
243
256
 
244
257
  // ─── Codemods ───────────────────────────────────────────────
245
258
 
@@ -349,4 +362,4 @@ export {
349
362
  StatsTracker,
350
363
  } from './scheduler/index.js'
351
364
 
352
- export const VERSION = '0.1.7'
365
+ export const VERSION = '0.2.2'
@@ -1236,7 +1236,7 @@ export class Router extends Macroable {
1236
1236
  makeSignedUrl(name: string, params?: Record<string, string>, options?: SignedUrlOptions): string {
1237
1237
  if (!this.#signedUrl) {
1238
1238
  throw new Error(
1239
- '[E_NO_APP_KEY] makeSignedUrl() requires an APP_KEY-backed signer. Set APP_KEY (>= 16 chars) so the encryption service is registered.',
1239
+ '[E_MISSING_APP_KEY] makeSignedUrl() requires an APP_KEY-backed signer. Set APP_KEY (>= 16 chars) so the encryption service is registered.',
1240
1240
  )
1241
1241
  }
1242
1242
  const path = this.urlFor(name, params)
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { Application } from '../Application.js'
11
+ import { createServiceProxy } from './createServiceProxy.js'
11
12
 
12
13
  let instance: Application | undefined
13
14
 
@@ -25,28 +26,10 @@ export function clearApp(app: Application): void {
25
26
  if (instance === app) instance = undefined
26
27
  }
27
28
 
28
- const app: Application = new Proxy({} as Application, {
29
- get(_target, prop) {
30
- // A module loader inspects what it imports before anyone uses it: it reads
31
- // `then` to decide whether the namespace is thenable, and various symbols
32
- // for interop and formatting. Throwing on those turns a plain
33
- // `import { setX } from '.../services/x'` into a crash at import time, far
34
- // from any real use. They are not members of what this stands in for, so
35
- // answer undefined and let a genuine access be the one that reports.
36
- if (typeof prop === 'symbol' || prop === 'then') {
37
- return undefined
38
- }
39
- if (!instance) {
40
- throw new Error(
41
- 'Application accessed before initialization. ' +
42
- 'Ensure this code runs during or after the boot phase.',
43
- )
44
- }
45
- // Bind methods so private-field writes inside the class resolve
46
- // against the real instance brand instead of the Proxy.
47
- const value = Reflect.get(instance, prop, instance)
48
- return typeof value === 'function' ? value.bind(instance) : value
49
- },
50
- })
29
+ const app: Application = createServiceProxy<Application>(
30
+ () => instance,
31
+ 'Application accessed before initialization. ' +
32
+ 'Ensure this code runs during or after the boot phase.',
33
+ )
51
34
 
52
35
  export default app
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { Console } from '../console/Console.js'
12
+ import { createServiceProxy } from './createServiceProxy.js'
12
13
 
13
14
  let instance: Console | undefined
14
15
 
@@ -26,26 +27,10 @@ export function clearConsole(consoleApp: Console): void {
26
27
  if (instance === consoleApp) instance = undefined
27
28
  }
28
29
 
29
- const consoleApp: Console = new Proxy({} as Console, {
30
- get(_target, prop) {
31
- // A module loader inspects what it imports before anyone uses it: it reads
32
- // `then` to decide whether the namespace is thenable, and various symbols
33
- // for interop and formatting. Throwing on those turns a plain
34
- // `import { setX } from '.../services/x'` into a crash at import time, far
35
- // from any real use. They are not members of what this stands in for, so
36
- // answer undefined and let a genuine access be the one that reports.
37
- if (typeof prop === 'symbol' || prop === 'then') {
38
- return undefined
39
- }
40
- if (!instance) {
41
- throw new Error(
42
- 'Console accessed before initialization. ' +
43
- 'Ensure this code runs during or after the boot phase.',
44
- )
45
- }
46
- const value = Reflect.get(instance, prop, instance)
47
- return typeof value === 'function' ? value.bind(instance) : value
48
- },
49
- })
30
+ const consoleApp: Console = createServiceProxy<Console>(
31
+ () => instance,
32
+ 'Console accessed before initialization. ' +
33
+ 'Ensure this code runs during or after the boot phase.',
34
+ )
50
35
 
51
36
  export default consoleApp
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The one implementation behind every `@c9up/ream/services/*` accessor.
3
+ *
4
+ * Each accessor stands in for a singleton that only exists once the Ignitor has
5
+ * built it, so the module-level export is a proxy that forwards to whatever the
6
+ * boot phase installed. Written out per service, the proxy was copied five
7
+ * times and the `symbol`/`then` guard below reached only three of them — a fix
8
+ * that has to be applied by hand five times is a fix that will be missed.
9
+ *
10
+ * @see services/app.ts for the accessor shape this backs.
11
+ */
12
+
13
+ /**
14
+ * Build the proxy an accessor module default-exports.
15
+ *
16
+ * @param read Returns the live singleton, or `undefined` before boot.
17
+ * @param notReady Message thrown when something reads the accessor too early.
18
+ */
19
+ export function createServiceProxy<T extends object>(
20
+ read: () => T | undefined,
21
+ notReady: string,
22
+ ): T {
23
+ // The target is never read through — every trap forwards to `read()`.
24
+ const target: T = Object.create(null)
25
+ return new Proxy(target, {
26
+ get(_target, prop) {
27
+ // A module loader inspects what it imports before anyone uses it: it
28
+ // reads `then` to decide whether the namespace is thenable, and various
29
+ // symbols for interop and formatting. Throwing on those turns a plain
30
+ // `import { setX } from '.../services/x'` into a crash at import time,
31
+ // far from any real use. They are not members of what this stands in
32
+ // for, so answer undefined and let a genuine access be the one that
33
+ // reports.
34
+ if (typeof prop === 'symbol' || prop === 'then') {
35
+ return undefined
36
+ }
37
+ const instance = read()
38
+ if (!instance) {
39
+ throw new Error(notReady)
40
+ }
41
+ // Bind methods so private-field writes inside the class resolve against
42
+ // the real instance brand instead of the Proxy.
43
+ const value = Reflect.get(instance, prop, instance)
44
+ return typeof value === 'function' ? value.bind(instance) : value
45
+ },
46
+ })
47
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { Router } from '../router/Router.js'
12
+ import { createServiceProxy } from './createServiceProxy.js'
12
13
 
13
14
  let instance: Router | undefined
14
15
 
@@ -34,19 +35,10 @@ export function getRouter(): Router | undefined {
34
35
  * Router proxy — defers all property access to the underlying instance.
35
36
  * Throws if accessed before Ignitor initializes it.
36
37
  */
37
- const router: Router = new Proxy({} as Router, {
38
- get(_target, prop) {
39
- if (!instance) {
40
- throw new Error(
41
- 'Router accessed before initialization. ' +
42
- 'Ensure your route files are loaded as preloads in reamrc.ts, not at import time.',
43
- )
44
- }
45
- // Bind methods so private-field writes inside the class resolve
46
- // against the real instance brand instead of the Proxy.
47
- const value = Reflect.get(instance, prop, instance)
48
- return typeof value === 'function' ? value.bind(instance) : value
49
- },
50
- })
38
+ const router: Router = createServiceProxy<Router>(
39
+ () => instance,
40
+ 'Router accessed before initialization. ' +
41
+ 'Ensure your route files are loaded as preloads in reamrc.ts, not at import time.',
42
+ )
51
43
 
52
44
  export default router