@c9up/ream 0.2.17 → 0.2.18

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 (84) hide show
  1. package/dist/HttpKernel.d.ts +10 -0
  2. package/dist/HttpKernel.d.ts.map +1 -1
  3. package/dist/HttpKernel.js +61 -5
  4. package/dist/HttpKernel.js.map +1 -1
  5. package/dist/Ignitor.d.ts.map +1 -1
  6. package/dist/Ignitor.js +65 -15
  7. package/dist/Ignitor.js.map +1 -1
  8. package/dist/container/Container.d.ts.map +1 -1
  9. package/dist/container/Container.js +174 -45
  10. package/dist/container/Container.js.map +1 -1
  11. package/dist/container/types.d.ts +7 -0
  12. package/dist/container/types.d.ts.map +1 -1
  13. package/dist/dev/hmr.d.ts +37 -0
  14. package/dist/dev/hmr.d.ts.map +1 -0
  15. package/dist/dev/hmr.js +72 -0
  16. package/dist/dev/hmr.js.map +1 -0
  17. package/dist/graphql/GraphQLProvider.d.ts +14 -3
  18. package/dist/graphql/GraphQLProvider.d.ts.map +1 -1
  19. package/dist/graphql/GraphQLProvider.js +14 -3
  20. package/dist/graphql/GraphQLProvider.js.map +1 -1
  21. package/dist/hot.d.ts +29 -0
  22. package/dist/hot.d.ts.map +1 -0
  23. package/dist/hot.js +135 -0
  24. package/dist/hot.js.map +1 -0
  25. package/dist/http/Exception.d.ts +2 -2
  26. package/dist/http/Exception.d.ts.map +1 -1
  27. package/dist/http/Exception.js +3 -3
  28. package/dist/http/Exception.js.map +1 -1
  29. package/dist/http/Request.d.ts +11 -0
  30. package/dist/http/Request.d.ts.map +1 -1
  31. package/dist/http/Request.js +40 -0
  32. package/dist/http/Request.js.map +1 -1
  33. package/dist/http/RequestValidator.d.ts +80 -0
  34. package/dist/http/RequestValidator.d.ts.map +1 -0
  35. package/dist/http/RequestValidator.js +93 -0
  36. package/dist/http/RequestValidator.js.map +1 -0
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +2 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/middleware/Pipeline.d.ts +2 -2
  42. package/dist/middleware/Pipeline.js +2 -2
  43. package/dist/middleware/Pipeline.js.map +1 -1
  44. package/dist/migrations/MigrationRegistry.d.ts +16 -0
  45. package/dist/migrations/MigrationRegistry.d.ts.map +1 -1
  46. package/dist/migrations/MigrationRegistry.js +24 -0
  47. package/dist/migrations/MigrationRegistry.js.map +1 -1
  48. package/dist/openapi/OpenApiGenerator.d.ts +16 -0
  49. package/dist/openapi/OpenApiGenerator.d.ts.map +1 -1
  50. package/dist/openapi/OpenApiGenerator.js +76 -4
  51. package/dist/openapi/OpenApiGenerator.js.map +1 -1
  52. package/dist/openapi/OpenApiProvider.d.ts +16 -5
  53. package/dist/openapi/OpenApiProvider.d.ts.map +1 -1
  54. package/dist/openapi/OpenApiProvider.js +21 -6
  55. package/dist/openapi/OpenApiProvider.js.map +1 -1
  56. package/dist/rpc/RpcProvider.d.ts +13 -2
  57. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  58. package/dist/rpc/RpcProvider.js +13 -2
  59. package/dist/rpc/RpcProvider.js.map +1 -1
  60. package/dist/storage/StaticProvider.d.ts +12 -2
  61. package/dist/storage/StaticProvider.d.ts.map +1 -1
  62. package/dist/storage/StaticProvider.js +12 -2
  63. package/dist/storage/StaticProvider.js.map +1 -1
  64. package/events.win32-x64-msvc.node +0 -0
  65. package/index.win32-x64-msvc.node +0 -0
  66. package/package.json +14 -2
  67. package/scheduler.win32-x64-msvc.node +0 -0
  68. package/src/HttpKernel.ts +87 -5
  69. package/src/Ignitor.ts +70 -16
  70. package/src/container/Container.ts +178 -48
  71. package/src/container/types.ts +11 -0
  72. package/src/dev/hmr.ts +83 -0
  73. package/src/graphql/GraphQLProvider.ts +14 -3
  74. package/src/hot.ts +165 -0
  75. package/src/http/Exception.ts +4 -4
  76. package/src/http/Request.ts +54 -0
  77. package/src/http/RequestValidator.ts +143 -0
  78. package/src/index.ts +8 -1
  79. package/src/middleware/Pipeline.ts +4 -4
  80. package/src/migrations/MigrationRegistry.ts +23 -0
  81. package/src/openapi/OpenApiGenerator.ts +84 -4
  82. package/src/openapi/OpenApiProvider.ts +21 -6
  83. package/src/rpc/RpcProvider.ts +13 -2
  84. package/src/storage/StaticProvider.ts +12 -2
@@ -13,6 +13,12 @@ import type { SignedUrl } from '../security/SignedUrl.js'
13
13
  import type { Dict } from '../types/helpers.js'
14
14
  import { Macroable } from '../utils/Macroable.js'
15
15
  import { getPath, omitPaths, pickPaths } from '../utils/objectPath.js'
16
+ import type {
17
+ RequestAwareValidator,
18
+ RequestValidationOptions,
19
+ ValidatableContext,
20
+ } from './RequestValidator.js'
21
+ import { RequestValidator } from './RequestValidator.js'
16
22
  import { unpackCookieValue } from './Response.js'
17
23
 
18
24
  export interface RawRequest {
@@ -789,6 +795,44 @@ export class Request extends Macroable {
789
795
  return { ...this.#merged }
790
796
  }
791
797
 
798
+ /**
799
+ * The context a validation hook is handed.
800
+ *
801
+ * The whole context when this request has one, because that is where a hook
802
+ * finds what it needs — `ctx.i18n` for a translated message. A Request built
803
+ * by hand for a test has none, and stands in for itself.
804
+ */
805
+ #validationContext(): ValidatableContext {
806
+ const ctx = this.ctx
807
+ // Handed back as it is, never spread: a context carries accessors
808
+ // (`ctx.i18n` is one), and a spread would freeze each into a plain value
809
+ // read at this instant instead of when the hook asks for it.
810
+ if (ctx !== undefined && carriesRequest(ctx)) return ctx
811
+ return { request: this }
812
+ }
813
+
814
+ /**
815
+ * Validate this request, throwing `E_VALIDATION_ERROR` on failure.
816
+ *
817
+ * The entry point that lets a schema stay ignorant of the request: the
818
+ * per-request messages provider and error reporter are filled in here, from
819
+ * hooks a package installed at boot. See {@link RequestValidator}.
820
+ */
821
+ validateUsing<T>(
822
+ validator: RequestAwareValidator<T>,
823
+ options?: RequestValidationOptions,
824
+ ): Promise<T> {
825
+ return new RequestValidator(this.#validationContext()).validateUsing(validator, options)
826
+ }
827
+
828
+ /** Validate this request, answering `[error, null]` or `[null, data]`. */
829
+ tryValidateUsing<T>(
830
+ validator: RequestAwareValidator<T>,
831
+ options?: RequestValidationOptions,
832
+ ): Promise<[unknown, null] | [null, T]> {
833
+ return new RequestValidator(this.#validationContext()).tryValidateUsing(validator, options)
834
+ }
835
+
792
836
  /**
793
837
  * The original request input, captured once and never mutated (AdonisJS
794
838
  * `request.original`) — the basis for flash "old input" on validation errors.
@@ -1174,3 +1218,13 @@ function languageMatches(entry: string, offered: string): boolean {
1174
1218
  if (primary && primary === o.split('-')[0]) return true
1175
1219
  return false
1176
1220
  }
1221
+
1222
+ /**
1223
+ * Whether an object is a context wrapped around a real request.
1224
+ *
1225
+ * `Request.ctx` is deliberately typed `object` — the request must not depend
1226
+ * on the context's shape — so this is where that opacity is paid back.
1227
+ */
1228
+ function carriesRequest(value: object): value is ValidatableContext {
1229
+ return 'request' in value && value.request instanceof Request
1230
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Per-request validation.
3
+ *
4
+ * A validator compiled when its module loaded knows nothing about the request
5
+ * about to run through it — yet the two things it most often needs are per
6
+ * request: which language to answer in, and where the errors go. The two
7
+ * static hooks below are that seam. An i18n package installs one at boot:
8
+ *
9
+ * ```ts
10
+ * RequestValidator.messagesProvider = (ctx) => ctx.i18n.createMessagesProvider()
11
+ * ```
12
+ *
13
+ * and every `request.validateUsing(...)` call picks it up, without a single
14
+ * schema mentioning translation. Nothing else in the framework reads them, so
15
+ * a validator called directly keeps whatever provider it was built with.
16
+ *
17
+ * Structural on purpose: ream never imports a validation package. Anything
18
+ * with a `validate(data, options)` fits, which is also what makes this
19
+ * testable without one.
20
+ */
21
+
22
+ import type { HttpContext } from './HttpContext.ts'
23
+
24
+ /** What a validator has to expose to be usable here. */
25
+ export interface RequestAwareValidator<T> {
26
+ validate(data: Record<string, unknown>, options?: RequestValidationOptions): Promise<T>
27
+ }
28
+
29
+ /** Options forwarded to the validator, plus the data override. */
30
+ export interface RequestValidationOptions {
31
+ /** Validate this instead of the request's own data. */
32
+ data?: Record<string, unknown>
33
+ /** Per-call messages provider — beats the static hook. */
34
+ messagesProvider?: unknown
35
+ /** Per-call error reporter factory — beats the static hook. */
36
+ errorReporter?: unknown
37
+ /** Arbitrary metadata a rule can read off the field context. */
38
+ meta?: Record<string, unknown>
39
+ }
40
+
41
+ /**
42
+ * The slice this class reads. A real {@link HttpContext} satisfies it, and so
43
+ * does a bare `{ request }` pair — which is what a Request built by hand, with
44
+ * no context around it, can still offer.
45
+ */
46
+ export interface ValidatableContext {
47
+ request: {
48
+ all(): Record<string, unknown>
49
+ params(): Readonly<Record<string, string | string[]>>
50
+ headers(): Readonly<Record<string, unknown>>
51
+ cookiesList(): Record<string, unknown>
52
+ }
53
+ }
54
+
55
+ /**
56
+ * What a hook is handed. The full context when there is one — an i18n package
57
+ * reads `ctx.i18n` off it — and the narrow shape otherwise, which is why this
58
+ * is a union rather than just `HttpContext`.
59
+ */
60
+ export type ValidationHookContext = HttpContext | ValidatableContext
61
+
62
+ export class RequestValidator {
63
+ readonly #ctx: ValidatableContext
64
+
65
+ constructor(ctx: ValidatableContext) {
66
+ this.#ctx = ctx
67
+ }
68
+
69
+ /**
70
+ * Picks the error reporter for a request. Assigned once, at boot, by
71
+ * whatever package owns error formatting.
72
+ */
73
+ static errorReporter?: (ctx: ValidationHookContext) => unknown
74
+
75
+ /**
76
+ * Picks the messages provider for a request. Assigned once, at boot, by
77
+ * whatever package owns translations.
78
+ */
79
+ static messagesProvider?: (ctx: ValidationHookContext) => unknown
80
+
81
+ /**
82
+ * Everything a rule might address, in one object.
83
+ *
84
+ * `params`, `headers` and `cookies` are nested under their own keys rather
85
+ * than merged: a form field named `params` is ordinary, and flattening them
86
+ * would let a request rename its own route parameters.
87
+ */
88
+ #requestData(): Record<string, unknown> {
89
+ const { request } = this.#ctx
90
+ return {
91
+ ...request.all(),
92
+ params: request.params(),
93
+ headers: request.headers(),
94
+ cookies: request.cookiesList(),
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Fill in the request-scoped reporter and provider, without overwriting
100
+ * anything the caller passed — an explicit option is a deliberate choice
101
+ * for this one call and must outrank a process-wide default.
102
+ */
103
+ #processOptions(options?: RequestValidationOptions): RequestValidationOptions {
104
+ const resolved: RequestValidationOptions = { ...options }
105
+ if (RequestValidator.errorReporter && resolved.errorReporter === undefined) {
106
+ const reporter = RequestValidator.errorReporter(this.#ctx)
107
+ // The validator expects a FACTORY, so a fresh reporter is built per run
108
+ // rather than one instance accumulating across requests.
109
+ resolved.errorReporter = () => reporter
110
+ }
111
+ if (RequestValidator.messagesProvider && resolved.messagesProvider === undefined) {
112
+ resolved.messagesProvider = RequestValidator.messagesProvider(this.#ctx)
113
+ }
114
+ return resolved
115
+ }
116
+
117
+ /** Validate the request, throwing `E_VALIDATION_ERROR` on failure. */
118
+ validateUsing<T>(
119
+ validator: RequestAwareValidator<T>,
120
+ options?: RequestValidationOptions,
121
+ ): Promise<T> {
122
+ const resolved = this.#processOptions(options)
123
+ return validator.validate(resolved.data ?? this.#requestData(), resolved)
124
+ }
125
+
126
+ /**
127
+ * Validate the request, returning `[error, null]` or `[null, data]`.
128
+ *
129
+ * The tuple form exists so a handler can answer a failure itself instead of
130
+ * letting the exception reach the error handler — a wizard step that
131
+ * re-renders the form, say.
132
+ */
133
+ async tryValidateUsing<T>(
134
+ validator: RequestAwareValidator<T>,
135
+ options?: RequestValidationOptions,
136
+ ): Promise<[unknown, null] | [null, T]> {
137
+ try {
138
+ return [null, await this.validateUsing(validator, options)]
139
+ } catch (error) {
140
+ return [error, null]
141
+ }
142
+ }
143
+ }
package/src/index.ts CHANGED
@@ -60,6 +60,13 @@ export { HttpContext } from './http/HttpContext.js'
60
60
  export { RedirectBuilder } from './http/RedirectBuilder.js'
61
61
  export type { RawRequest } from './http/Request.js'
62
62
  export { Request } from './http/Request.js'
63
+ export type {
64
+ RequestAwareValidator,
65
+ RequestValidationOptions,
66
+ ValidatableContext,
67
+ ValidationHookContext,
68
+ } from './http/RequestValidator.js'
69
+ export { RequestValidator } from './http/RequestValidator.js'
63
70
  export { Response } from './http/Response.js'
64
71
  export type { SseStreamOptions } from './http/SseStream.js'
65
72
  export { SseStream } from './http/SseStream.js'
@@ -378,4 +385,4 @@ export {
378
385
  StatsTracker,
379
386
  } from './scheduler/index.js'
380
387
 
381
- export const VERSION = '0.2.17'
388
+ export const VERSION = '0.2.18'
@@ -32,7 +32,7 @@ export interface RuntimeValidationResult {
32
32
  * Structural contract for a route validator. ream stays decoupled from the
33
33
  * validation engine — any object exposing a result-returning check works.
34
34
  *
35
- * Three spellings are accepted because `@c9up/rune` follows VineJS: `validate()`
35
+ * Three spellings are accepted because `@c9up/rune` offers all three: `validate()`
36
36
  * is async and throws, `validateResult()` is synchronous and never throws, and
37
37
  * `validateResultAsync()` is the async result-based form.
38
38
  *
@@ -46,7 +46,7 @@ export interface RuntimeValidator {
46
46
  validateResultAsync?(data: unknown): Promise<RuntimeValidationResult>
47
47
  }
48
48
 
49
- /** A thrown validation failure, as raised by rune/VineJS `validate()`. */
49
+ /** A thrown validation failure, as raised by rune's `validate()`. */
50
50
  interface ThrownValidationFailure {
51
51
  messages: unknown[]
52
52
  }
@@ -66,7 +66,7 @@ function isValidationResult(value: unknown): value is RuntimeValidationResult {
66
66
  * result object.
67
67
  *
68
68
  * Order matters: the async result form first (it is the only one that can run
69
- * `unique` / `exists`), then the synchronous one, then VineJS's throwing
69
+ * `unique` / `exists`), then the synchronous one, then the throwing
70
70
  * `validate()` — awaited, and with its `E_VALIDATION_ERROR` translated back
71
71
  * into a result so callers keep a single shape to handle.
72
72
  */
@@ -85,7 +85,7 @@ export async function runValidator(
85
85
  if (typeof validator.validate === 'function') {
86
86
  try {
87
87
  const outcome = await validator.validate(data)
88
- // A result-returning `validate()` (not the VineJS contract) is honoured
88
+ // A result-returning `validate()` (not the throwing contract) is honoured
89
89
  // as-is; the throwing contract resolves to the validated payload.
90
90
  return isValidationResult(outcome) ? outcome : { valid: true, errors: [], data: outcome }
91
91
  } catch (err) {
@@ -27,6 +27,29 @@ export class MigrationRegistry {
27
27
  return this
28
28
  }
29
29
 
30
+ /**
31
+ * Remove a store's runner — only while it is still the one that registered.
32
+ *
33
+ * `register` refuses a duplicate name, deliberately: two providers claiming
34
+ * one name means one of them migrates nothing while the run reports success.
35
+ * But nothing ever removed a source, so a provider that shut down left its
36
+ * name taken and its runner holding a connection it had already closed. A
37
+ * second boot in the same process — a hot reload, a test that restarts the
38
+ * app — then failed on "already registered", and the CLI would have driven a
39
+ * runner pointing at a dead pool.
40
+ *
41
+ * `source` is the ownership guard. Two applications can share a process, and
42
+ * the one shutting down must not unregister the survivor's runner that
43
+ * happens to carry the same name.
44
+ */
45
+ unregister(name: string, source?: RegisteredMigrationSource): boolean {
46
+ const current = this.#sources.get(name)
47
+ if (current === undefined) return false
48
+ if (source !== undefined && current !== source) return false
49
+ this.#sources.delete(name)
50
+ return true
51
+ }
52
+
30
53
  /** Every registered store, in registration order. */
31
54
  all(): RegisteredMigrationSource[] {
32
55
  return [...this.#sources.values()]
@@ -56,6 +56,16 @@ export class OpenApiGenerator {
56
56
  #router: Router
57
57
  #config: OpenApiConfig
58
58
  #runeSchemas: Map<string, Record<string, unknown>>
59
+ /**
60
+ * Schemas a validator produced itself, already in JSON Schema form.
61
+ *
62
+ * Kept apart from {@link #runeSchemas}, which holds rune FIELD MAPS that
63
+ * {@link #runeToJsonSchema} still has to convert. One map for both would
64
+ * mean guessing which kind a value is, and guessing wrong sends a finished
65
+ * schema back through the converter, which reads its `type` and `properties`
66
+ * keys as if they were field names.
67
+ */
68
+ #jsonSchemas = new Map<string, Record<string, unknown>>()
59
69
 
60
70
  constructor(
61
71
  router: Router,
@@ -72,6 +82,30 @@ export class OpenApiGenerator {
72
82
  this.#runeSchemas.set(name, definition)
73
83
  }
74
84
 
85
+ /**
86
+ * Fill the schema map from the validators the routes already name.
87
+ *
88
+ * `route.validate('createUser')` records a container token, and the
89
+ * validator behind it can describe itself. Without this step every one of
90
+ * those routes documented its body as a bare `{ type: 'object' }` unless the
91
+ * app ALSO called `registerSchema` by hand with a definition it had to keep
92
+ * in step with the schema — two descriptions of one payload, and nothing
93
+ * checking they agreed.
94
+ *
95
+ * Resolution happens once, here, because `generate()` is synchronous and the
96
+ * container is not. A validator that cannot be resolved, or that cannot
97
+ * describe itself, is skipped: a missing section of documentation is a
98
+ * smaller problem than a boot that fails over one.
99
+ */
100
+ async hydrateSchemas(resolve: (token: string) => Promise<unknown>): Promise<void> {
101
+ const names = new Set(this.#getRoutes().flatMap((route) => route.validators))
102
+ for (const name of names) {
103
+ if (this.#runeSchemas.has(name) || this.#jsonSchemas.has(name)) continue
104
+ const definition = await describeValidator(resolve, name)
105
+ if (definition !== undefined) this.#jsonSchemas.set(name, definition)
106
+ }
107
+ }
108
+
75
109
  /** Generate the complete OpenAPI 3.1 spec. */
76
110
  generate(): OpenApiSpec {
77
111
  const spec: OpenApiSpec = {
@@ -171,13 +205,13 @@ export class OpenApiGenerator {
171
205
 
172
206
  if (route.validators.length > 0 && ['post', 'put', 'patch'].includes(method)) {
173
207
  const [validatorName] = route.validators
174
- const schema = validatorName === undefined ? undefined : this.#runeSchemas.get(validatorName)
175
- if (schema && validatorName !== undefined) {
208
+ const body = validatorName === undefined ? undefined : this.#bodySchema(validatorName)
209
+ if (body && validatorName !== undefined) {
176
210
  operation.requestBody = {
177
211
  required: true,
178
- content: { 'application/json': { schema: this.#runeToJsonSchema(schema) } },
212
+ content: { 'application/json': { schema: body } },
179
213
  }
180
- schemas[validatorName] = this.#runeToJsonSchema(schema)
214
+ schemas[validatorName] = body
181
215
  } else {
182
216
  operation.requestBody = {
183
217
  required: true,
@@ -211,6 +245,17 @@ export class OpenApiGenerator {
211
245
  return `${route.method} ${route.path}`
212
246
  }
213
247
 
248
+ /**
249
+ * The JSON Schema for a named validator: what it described about itself
250
+ * first, then a hand-registered field map converted here.
251
+ */
252
+ #bodySchema(name: string): Record<string, unknown> | undefined {
253
+ const described = this.#jsonSchemas.get(name)
254
+ if (described !== undefined) return described
255
+ const fields = this.#runeSchemas.get(name)
256
+ return fields === undefined ? undefined : this.#runeToJsonSchema(fields)
257
+ }
258
+
214
259
  /** Convert a Rune schema (Record<string, RuleChain>) to JSON Schema. */
215
260
  #runeToJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
216
261
  const properties: Record<string, unknown> = {}
@@ -254,3 +299,38 @@ export class OpenApiGenerator {
254
299
  return this.#router.getRoutes()
255
300
  }
256
301
  }
302
+
303
+ /** A validator that can describe its own shape as JSON Schema. */
304
+ interface SelfDescribingValidator {
305
+ toJSONSchema(): Record<string, unknown>
306
+ }
307
+
308
+ function describesItself(value: unknown): value is SelfDescribingValidator {
309
+ return (
310
+ typeof value === 'object' &&
311
+ value !== null &&
312
+ 'toJSONSchema' in value &&
313
+ typeof value.toJSONSchema === 'function'
314
+ )
315
+ }
316
+
317
+ /** Resolve one validator and ask it for its schema, or answer undefined. */
318
+ async function describeValidator(
319
+ resolve: (token: string) => Promise<unknown>,
320
+ name: string,
321
+ ): Promise<Record<string, unknown> | undefined> {
322
+ let validator: unknown
323
+ try {
324
+ validator = await resolve(`validator:${name}`)
325
+ } catch {
326
+ // An unregistered validator is already a hard error at request time; the
327
+ // documentation is not the place to raise it a second time.
328
+ return undefined
329
+ }
330
+ if (!describesItself(validator)) return undefined
331
+ try {
332
+ return validator.toJSONSchema()
333
+ } catch {
334
+ return undefined
335
+ }
336
+ }
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * `OpenApiProvider` — serves an auto-generated OpenAPI 3.1 spec + Swagger UI.
3
3
  *
4
- * `boot()` mounts a global middleware that, on the first GET to `/api-docs`
4
+ * `start()` mounts a global middleware that, on the first GET to `/api-docs`
5
5
  * (JSON) or `/docs` (Swagger UI), lazily generates the spec from the router's
6
- * registered routes — so every route registered during boot/start is included
7
- * (the spec must NOT be generated eagerly at boot, before other providers have
8
- * registered their routes). Opt-out via `config.openapi.enabled = false`.
6
+ * registered routes — so every route registered while the application starts
7
+ * up is included (the spec must NOT be generated eagerly, before the other
8
+ * providers and the preloads have registered their routes). Opt-out via
9
+ * `config.openapi.enabled = false`.
9
10
  *
10
11
  * @implements Story 24.4
11
12
  */
@@ -43,7 +44,17 @@ export class OpenApiProvider extends Provider {
43
44
  this.#override = options.generator
44
45
  }
45
46
 
46
- override async boot(): Promise<void> {
47
+ /**
48
+ * Routes and server middleware go in `start`, not `boot`.
49
+ *
50
+ * Upstream documents that phase for exactly this, and the order is what makes
51
+ * it matter: providers boot, then providers START, then the preloads run —
52
+ * and the preloads are where an application writes its own routes and kernel.
53
+ * Mounted in `boot`, a framework route landed ahead of every application
54
+ * route, so an overlapping path was answered by the framework rather than by
55
+ * the app that meant to override it.
56
+ */
57
+ override async start(): Promise<void> {
47
58
  const config = this.app.config.get<OpenApiDocsConfig>('openapi') ?? {}
48
59
  if (config.enabled === false) return
49
60
 
@@ -59,10 +70,14 @@ export class OpenApiProvider extends Provider {
59
70
 
60
71
  async #buildGenerator(config: OpenApiDocsConfig): Promise<OpenApiGenerator> {
61
72
  const router = await this.app.container.make<Router>('router')
62
- return new OpenApiGenerator(router, {
73
+ const generator = new OpenApiGenerator(router, {
63
74
  title: config.title ?? 'API',
64
75
  version: config.version ?? '1.0.0',
65
76
  })
77
+ // The routes already name their validators; ask each one to describe
78
+ // itself instead of making the app repeat the shape by hand.
79
+ await generator.hydrateSchemas((token) => this.app.container.make<unknown>(token))
80
+ return generator
66
81
  }
67
82
  }
68
83
 
@@ -9,8 +9,9 @@
9
9
  * rpc.method('task.validate', handler).guard('jwt')
10
10
  * rpc.namespace('user', UserController)
11
11
  *
12
- * - `boot()` — mount `POST <config.rpc.path ?? '/rpc'>` on the core router,
12
+ * - `start()` — mount `POST <config.rpc.path ?? '/rpc'>` on the core router,
13
13
  * dispatching every request body (single or batch) to `RpcRouter.handle()`.
14
+ * In `start`, which is the phase upstream documents for routes.
14
15
  *
15
16
  * @implements Story 24.1, 24.2
16
17
  */
@@ -68,7 +69,17 @@ export class RpcProvider extends Provider {
68
69
  this.#registered = true
69
70
  }
70
71
 
71
- override async boot(): Promise<void> {
72
+ /**
73
+ * Routes and server middleware go in `start`, not `boot`.
74
+ *
75
+ * Upstream documents that phase for exactly this, and the order is what makes
76
+ * it matter: providers boot, then providers START, then the preloads run —
77
+ * and the preloads are where an application writes its own routes and kernel.
78
+ * Mounted in `boot`, a framework route landed ahead of every application
79
+ * route, so an overlapping path was answered by the framework rather than by
80
+ * the app that meant to override it.
81
+ */
82
+ override async start(): Promise<void> {
72
83
  const config = this.app.config.get<RpcConfig>('rpc')
73
84
  const path = config?.path ?? '/rpc'
74
85
  const router = await this.app.container.make<Router>('router')
@@ -2,7 +2,7 @@
2
2
  * `StaticProvider` — serves static files from a directory.
3
3
  *
4
4
  * Opt-in: does nothing unless `config.static.root` is set (or a middleware is
5
- * injected for tests). When configured, `boot()` mounts a global
5
+ * injected for tests). When configured, `start()` mounts a global
6
6
  * `StaticMiddleware` (path-traversal + symlink-escape guarded) that serves
7
7
  * matching requests and falls through (`next()`) otherwise.
8
8
  *
@@ -30,7 +30,17 @@ export class StaticProvider extends Provider {
30
30
  this.#override = options.middleware
31
31
  }
32
32
 
33
- override async boot(): Promise<void> {
33
+ /**
34
+ * Routes and server middleware go in `start`, not `boot`.
35
+ *
36
+ * Upstream documents that phase for exactly this, and the order is what makes
37
+ * it matter: providers boot, then providers START, then the preloads run —
38
+ * and the preloads are where an application writes its own routes and kernel.
39
+ * Mounted in `boot`, a framework route landed ahead of every application
40
+ * route, so an overlapping path was answered by the framework rather than by
41
+ * the app that meant to override it.
42
+ */
43
+ override async start(): Promise<void> {
34
44
  const config = this.app.config.get<StaticConfig>('static')
35
45
  const middleware = this.#override ?? (config?.root ? new StaticMiddleware(config) : undefined)
36
46
  if (!middleware) return // not configured — opt-out