@getsoda/core 0.2.2 → 0.3.0

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 (121) hide show
  1. package/README.md +80 -0
  2. package/dist/app/app.d.ts +15 -5
  3. package/dist/app/app.d.ts.map +1 -1
  4. package/dist/app/app.js +31 -8
  5. package/dist/app/app.js.map +1 -1
  6. package/dist/app/index.d.ts +1 -1
  7. package/dist/app/index.d.ts.map +1 -1
  8. package/dist/config/config.d.ts.map +1 -1
  9. package/dist/config/config.js +2 -0
  10. package/dist/config/config.js.map +1 -1
  11. package/dist/di/errors.d.ts.map +1 -1
  12. package/dist/di/errors.js +2 -0
  13. package/dist/di/errors.js.map +1 -1
  14. package/dist/http/arguments.d.ts +19 -6
  15. package/dist/http/arguments.d.ts.map +1 -1
  16. package/dist/http/arguments.js +7 -0
  17. package/dist/http/arguments.js.map +1 -1
  18. package/dist/http/cookies.d.ts.map +1 -1
  19. package/dist/http/cookies.js +2 -0
  20. package/dist/http/cookies.js.map +1 -1
  21. package/dist/http/decorators.d.ts +12 -5
  22. package/dist/http/decorators.d.ts.map +1 -1
  23. package/dist/http/decorators.js +7 -2
  24. package/dist/http/decorators.js.map +1 -1
  25. package/dist/http/errors.d.ts +3 -1
  26. package/dist/http/errors.d.ts.map +1 -1
  27. package/dist/http/errors.js +2 -0
  28. package/dist/http/errors.js.map +1 -1
  29. package/dist/http/index.d.ts +6 -4
  30. package/dist/http/index.d.ts.map +1 -1
  31. package/dist/http/index.js +3 -2
  32. package/dist/http/index.js.map +1 -1
  33. package/dist/http/parsers.d.ts +4 -0
  34. package/dist/http/parsers.d.ts.map +1 -1
  35. package/dist/http/parsers.js +9 -5
  36. package/dist/http/parsers.js.map +1 -1
  37. package/dist/http/pipeline.d.ts +4 -5
  38. package/dist/http/pipeline.d.ts.map +1 -1
  39. package/dist/http/pipeline.js +2 -2
  40. package/dist/http/pipeline.js.map +1 -1
  41. package/dist/http/routes.d.ts +27 -2
  42. package/dist/http/routes.d.ts.map +1 -1
  43. package/dist/http/routes.js +34 -20
  44. package/dist/http/routes.js.map +1 -1
  45. package/dist/http/sse.d.ts +39 -0
  46. package/dist/http/sse.d.ts.map +1 -0
  47. package/dist/http/sse.js +89 -0
  48. package/dist/http/sse.js.map +1 -0
  49. package/dist/index.d.ts +1 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +1 -0
  52. package/dist/index.js.map +1 -1
  53. package/dist/openapi/decorators.d.ts +41 -0
  54. package/dist/openapi/decorators.d.ts.map +1 -0
  55. package/dist/openapi/decorators.js +29 -0
  56. package/dist/openapi/decorators.js.map +1 -0
  57. package/dist/openapi/document.d.ts +62 -0
  58. package/dist/openapi/document.d.ts.map +1 -0
  59. package/dist/openapi/document.js +135 -0
  60. package/dist/openapi/document.js.map +1 -0
  61. package/dist/openapi/index.d.ts +11 -0
  62. package/dist/openapi/index.d.ts.map +1 -0
  63. package/dist/openapi/index.js +8 -0
  64. package/dist/openapi/index.js.map +1 -0
  65. package/dist/openapi/module.d.ts +26 -0
  66. package/dist/openapi/module.d.ts.map +1 -0
  67. package/dist/openapi/module.js +73 -0
  68. package/dist/openapi/module.js.map +1 -0
  69. package/dist/routing/errors.d.ts.map +1 -1
  70. package/dist/routing/errors.js +2 -0
  71. package/dist/routing/errors.js.map +1 -1
  72. package/dist/testing/index.d.ts +46 -0
  73. package/dist/testing/index.d.ts.map +1 -0
  74. package/dist/testing/index.js +49 -0
  75. package/dist/testing/index.js.map +1 -0
  76. package/dist/utils/errors.d.ts +11 -0
  77. package/dist/utils/errors.d.ts.map +1 -0
  78. package/dist/utils/errors.js +22 -0
  79. package/dist/utils/errors.js.map +1 -0
  80. package/dist/utils/object.d.ts +4 -0
  81. package/dist/utils/object.d.ts.map +1 -1
  82. package/dist/utils/object.js +16 -0
  83. package/dist/utils/object.js.map +1 -1
  84. package/dist/websocket/decorators.d.ts +31 -0
  85. package/dist/websocket/decorators.d.ts.map +1 -0
  86. package/dist/websocket/decorators.js +43 -0
  87. package/dist/websocket/decorators.js.map +1 -0
  88. package/dist/websocket/gateway.d.ts +61 -0
  89. package/dist/websocket/gateway.d.ts.map +1 -0
  90. package/dist/websocket/gateway.js +142 -0
  91. package/dist/websocket/gateway.js.map +1 -0
  92. package/dist/websocket/index.d.ts +7 -0
  93. package/dist/websocket/index.d.ts.map +1 -0
  94. package/dist/websocket/index.js +6 -0
  95. package/dist/websocket/index.js.map +1 -0
  96. package/package.json +12 -1
  97. package/src/app/app.ts +69 -18
  98. package/src/app/index.ts +1 -1
  99. package/src/config/config.ts +2 -0
  100. package/src/di/errors.ts +3 -0
  101. package/src/http/arguments.ts +31 -6
  102. package/src/http/cookies.ts +2 -0
  103. package/src/http/decorators.ts +16 -8
  104. package/src/http/errors.ts +7 -0
  105. package/src/http/index.ts +6 -4
  106. package/src/http/parsers.ts +18 -7
  107. package/src/http/pipeline.ts +6 -6
  108. package/src/http/routes.ts +68 -29
  109. package/src/http/sse.ts +120 -0
  110. package/src/index.ts +1 -0
  111. package/src/openapi/decorators.ts +50 -0
  112. package/src/openapi/document.ts +221 -0
  113. package/src/openapi/index.ts +11 -0
  114. package/src/openapi/module.ts +95 -0
  115. package/src/routing/errors.ts +3 -0
  116. package/src/testing/index.ts +73 -0
  117. package/src/utils/errors.ts +25 -0
  118. package/src/utils/object.ts +23 -0
  119. package/src/websocket/decorators.ts +55 -0
  120. package/src/websocket/gateway.ts +213 -0
  121. package/src/websocket/index.ts +7 -0
package/src/app/app.ts CHANGED
@@ -22,13 +22,15 @@
22
22
  import { createContainer, provide } from '../di/index.ts'
23
23
  import type { Container, ContainerOptions, ModuleImport } from '../di/index.ts'
24
24
  import { destroyAll, initializeAll } from '../di/lifecycle.ts'
25
- import { buildRoutes } from '../http/index.ts'
25
+ import { buildRoutes, ControllerError, ROUTES } from '../http/index.ts'
26
26
  import type { ControllerRoutesOptions, MappedController, Middleware } from '../http/index.ts'
27
27
  import { errorResponse } from '../http/exceptions.ts'
28
28
  import { applyMiddleware, resolveMiddleware } from '../http/middleware.ts'
29
29
  import { ConsoleLogger, Logger, SilentLogger } from '../logger/index.ts'
30
30
  import { compileRoutes } from '../routing/index.ts'
31
- import type { CompiledRoutes, CompileOptions } from '../routing/index.ts'
31
+ import type { CompiledRoutes, CompileOptions, NativeHandler } from '../routing/index.ts'
32
+ import { websocketHandler } from '../websocket/gateway.ts'
33
+ import type { BuiltGateway, GatewaySocketData } from '../websocket/gateway.ts'
32
34
  import { release, takeOver } from './hot.ts'
33
35
 
34
36
  /**
@@ -68,16 +70,16 @@ const DEFAULT_SIGNALS: readonly NodeJS.Signals[] = ['SIGTERM', 'SIGINT']
68
70
  */
69
71
  export interface App {
70
72
  readonly container: Container
71
- /** Routes for `Bun.serve()`, when serving them yourself instead of calling `listen()`. */
72
- readonly routes: CompiledRoutes
73
+ /** Routes (and WebSocket handlers) for `Bun.serve()`, when serving them yourself instead of calling `listen()`. */
74
+ readonly routes: ServeRoutes
73
75
  /** The server once `listen()` resolved. */
74
- readonly server: Bun.Server<undefined> | undefined
76
+ readonly server: Bun.Server<GatewaySocketData> | undefined
75
77
  /**
76
78
  * Starts serving. Under `bun --hot`, a new version of the application
77
79
  * listening on the same port takes the server over from the previous one,
78
80
  * which is then destroyed (its server is not stopped: it is shared).
79
81
  */
80
- listen(options?: number | ListenOptions): Promise<Bun.Server<undefined>>
82
+ listen(options?: number | ListenOptions): Promise<Bun.Server<GatewaySocketData>>
81
83
  /**
82
84
  * Graceful shutdown: refuses new requests, waits for those in flight, then
83
85
  * runs `onDestroy` hooks. Safe to call more than once, even from a hook.
@@ -92,27 +94,35 @@ export interface App {
92
94
 
93
95
  class Application implements App {
94
96
  readonly container: Container
95
- readonly routes: CompiledRoutes
96
- #server: Bun.Server<undefined> | undefined
97
+ readonly routes: ServeRoutes
98
+ #server: Bun.Server<GatewaySocketData> | undefined
97
99
  /** Set when a new version took the server over: it must not be stopped. */
98
100
  #handedOver = false
99
101
  #closing: Promise<void> | undefined
100
102
  readonly #enhancers: readonly unknown[]
101
103
  readonly #signals = new Map<NodeJS.Signals, () => void>()
102
104
  readonly #logger: Logger
103
-
104
- constructor(container: Container, routes: CompiledRoutes, enhancers: readonly unknown[], logger: Logger) {
105
+ readonly #gateways: readonly BuiltGateway[]
106
+
107
+ constructor(
108
+ container: Container,
109
+ routes: ServeRoutes,
110
+ gateways: readonly BuiltGateway[],
111
+ enhancers: readonly unknown[],
112
+ logger: Logger
113
+ ) {
105
114
  this.container = container
106
115
  this.routes = routes
116
+ this.#gateways = gateways
107
117
  this.#enhancers = enhancers
108
118
  this.#logger = logger
109
119
  }
110
120
 
111
- get server(): Bun.Server<undefined> | undefined {
121
+ get server(): Bun.Server<GatewaySocketData> | undefined {
112
122
  return this.#server
113
123
  }
114
124
 
115
- async listen(options: number | ListenOptions = {}): Promise<Bun.Server<undefined>> {
125
+ async listen(options: number | ListenOptions = {}): Promise<Bun.Server<GatewaySocketData>> {
116
126
  if (this.#closing !== undefined) {
117
127
  throw new Error('Cannot listen: the application is closed')
118
128
  }
@@ -191,6 +201,11 @@ class Application implements App {
191
201
  const server = this.#server
192
202
 
193
203
  try {
204
+ // Open sockets would keep stop() waiting; under --hot, clients reconnect to the new version.
205
+ for (const gateway of this.#gateways) {
206
+ gateway.runtime.closeAll()
207
+ }
208
+
194
209
  if (server !== undefined && !this.#handedOver) {
195
210
  await server.stop()
196
211
  }
@@ -245,8 +260,37 @@ const destroyEverything = async (enhancers: readonly unknown[], container: Conta
245
260
  }
246
261
  }
247
262
 
263
+ /**
264
+ * Routes and WebSocket handlers for `Bun.serve()`: the handshakes of gateways
265
+ * are native routes of their own, outside middleware, as an upgrade returns no response.
266
+ */
267
+ export interface ServeRoutes {
268
+ readonly routes: Record<string, NativeHandler | BuiltGateway['handle']>
269
+ readonly fetch: CompiledRoutes['fetch']
270
+ readonly websocket: Bun.WebSocketHandler<GatewaySocketData>
271
+ }
272
+
273
+ const withGateways = (compiled: CompiledRoutes, gateways: readonly BuiltGateway[]): ServeRoutes => {
274
+ const routes: ServeRoutes['routes'] = { ...compiled.routes }
275
+
276
+ for (const gateway of gateways) {
277
+ if (Object.hasOwn(routes, gateway.path)) {
278
+ throw new ControllerError('DUPLICATE_ROUTE', `The WebSocket gateway on ${gateway.path} has the path of another gateway or route`)
279
+ }
280
+
281
+ routes[gateway.path] = gateway.handle
282
+ }
283
+
284
+ return { routes, fetch: compiled.fetch, websocket: websocketHandler }
285
+ }
286
+
248
287
  /** What Nest logs at startup: the modules, then each controller and its routes. */
249
- const logStartup = (logger: Logger, container: Container, controllers: readonly MappedController[]): void => {
288
+ const logStartup = (
289
+ logger: Logger,
290
+ container: Container,
291
+ controllers: readonly MappedController[],
292
+ gateways: readonly BuiltGateway[]
293
+ ): void => {
250
294
  const modules = logger.child('Container')
251
295
  const routes = logger.child('Routes')
252
296
 
@@ -257,10 +301,14 @@ const logStartup = (logger: Logger, container: Container, controllers: readonly
257
301
  for (const { controller, path, routes: mapped } of controllers) {
258
302
  routes.log(`${controller.name} {${path}}:`)
259
303
 
260
- for (const route of mapped) {
261
- routes.log(`Mapped {${route.path}, ${route.method}} route`)
304
+ for (const { info } of mapped) {
305
+ routes.log(`Mapped {${info.path}, ${info.method}} route`)
262
306
  }
263
307
  }
308
+
309
+ for (const gateway of gateways) {
310
+ routes.log(`WebSocket gateway {${gateway.path}}, events: ${gateway.runtime.events.join(', ') || 'none'}`)
311
+ }
264
312
  }
265
313
 
266
314
  /**
@@ -280,9 +328,11 @@ export const createApp = async (root: ModuleImport, options: AppOptions = {}): P
280
328
 
281
329
  own.log('Starting application')
282
330
 
331
+ // Filled once the routes are built; read by what describes them (OpenAPI).
332
+ const registry: { controllers: readonly MappedController[] } = { controllers: [] }
283
333
  const container = await createContainer(root, {
284
334
  ...options,
285
- providers: [...(options.providers ?? []), provide(Logger, { useValue: logger })],
335
+ providers: [...(options.providers ?? []), provide(Logger, { useValue: logger }), provide(ROUTES, { useValue: registry })],
286
336
  })
287
337
  const enhancers: unknown[] = []
288
338
 
@@ -293,7 +343,8 @@ export const createApp = async (root: ModuleImport, options: AppOptions = {}): P
293
343
  })
294
344
  const built = buildRoutes(container, { ...options, onError })
295
345
 
296
- logStartup(logger, container, built.controllers)
346
+ registry.controllers = built.controllers
347
+ logStartup(logger, container, built.controllers, built.gateways)
297
348
  enhancers.push(...built.enhancers)
298
349
  await initializeAll(enhancers)
299
350
  own.log('Application started')
@@ -303,7 +354,7 @@ export const createApp = async (root: ModuleImport, options: AppOptions = {}): P
303
354
  errorResponse(error, (unknown) => onError(unknown, req))
304
355
  )
305
356
 
306
- return new Application(container, routes, enhancers, own)
357
+ return new Application(container, withGateways(routes, built.gateways), built.gateways, enhancers, own)
307
358
  } catch (error) {
308
359
  try {
309
360
  await destroyEverything(enhancers, container)
package/src/app/index.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  */
5
5
 
6
6
  export { createApp } from './app.ts'
7
- export type { App, AppOptions, ListenOptions } from './app.ts'
7
+ export type { App, AppOptions, ListenOptions, ServeRoutes } from './app.ts'
@@ -24,6 +24,7 @@
24
24
  import { InjectionToken } from '../di/token.ts'
25
25
  import { formatIssues } from '../http/schema.ts'
26
26
  import type { InferOutput, StandardSchemaV1 } from '../http/schema.ts'
27
+ import { hideFrameworkFrames } from '../utils/errors.ts'
27
28
 
28
29
  /** Environment variables, as in `process.env`. */
29
30
  export type Environment = Readonly<Record<string, string | undefined>>
@@ -37,6 +38,7 @@ export class ConfigError extends Error {
37
38
  readonly issues: readonly string[]
38
39
  ) {
39
40
  super(`Invalid configuration "${config}":\n ${issues.join('\n ')}`)
41
+ hideFrameworkFrames(this)
40
42
  }
41
43
  }
42
44
 
package/src/di/errors.ts CHANGED
@@ -3,6 +3,8 @@
3
3
  * Errors raised by the dependency injection container.
4
4
  */
5
5
 
6
+ import { hideFrameworkFrames } from '../utils/errors.ts'
7
+
6
8
  /**
7
9
  * Reason of a `DiError`.
8
10
  *
@@ -42,5 +44,6 @@ export class DiError extends Error {
42
44
  message: string
43
45
  ) {
44
46
  super(message)
47
+ hideFrameworkFrames(this)
45
48
  }
46
49
  }
@@ -18,7 +18,7 @@
18
18
 
19
19
  import type { FormFields, FormLimits } from './body.ts'
20
20
  import type { ContextKey } from './context-key.ts'
21
- import type { Context, ParamNameOf } from './context.ts'
21
+ import type { Context, ContextParams, ParamNameOf } from './context.ts'
22
22
  import type { Parsed, ValueParser } from './parsers.ts'
23
23
  import { isStandardSchema } from './schema.ts'
24
24
  import type { InferOutput, StandardSchemaV1 } from './schema.ts'
@@ -33,6 +33,10 @@ export interface Argument<T, K extends ArgumentKind = ArgumentKind, N extends st
33
33
  /** Parameter, query, header or cookie name; the key description for `value`; empty for `body` and `context`. */
34
34
  readonly name: N
35
35
  readonly read: (ctx: Context) => T | Promise<T>
36
+ /** The parser or schema the value goes through, for API descriptions such as OpenAPI. */
37
+ readonly source?: ValueParser<unknown> | StandardSchemaV1
38
+ /** Set by `form()`: the body is a form, not JSON. */
39
+ readonly multipart?: true
36
40
  }
37
41
 
38
42
  /** Arguments accepted by a route with path `P`: `param()` names must exist in it. */
@@ -40,13 +44,27 @@ export type ArgumentFor<P extends string> =
40
44
  | Argument<unknown, 'param', ParamNameOf<P>>
41
45
  | Argument<unknown, Exclude<ArgumentKind, 'param'>>
42
46
 
43
- /** Values a method receives for an argument list. */
44
- export type ArgumentValues<A extends readonly Argument<unknown>[]> = {
45
- -readonly [K in keyof A]: A[K] extends Argument<infer T> ? T : never
47
+ declare const RAW_PARAM: unique symbol
48
+
49
+ /**
50
+ * Value of `param(name)` without parser, typed by the route path:
51
+ * `string` for `:id`, `string | undefined` for `:id?`.
52
+ */
53
+ export interface RawParam {
54
+ readonly [RAW_PARAM]: true
55
+ }
56
+
57
+ /** Values a method receives for an argument list on a route with path `P`. */
58
+ export type ArgumentValues<A extends readonly Argument<unknown>[], P extends string = string> = {
59
+ -readonly [K in keyof A]: A[K] extends Argument<infer T, ArgumentKind, infer N>
60
+ ? [T] extends [RawParam]
61
+ ? ContextParams<P>[N]
62
+ : T
63
+ : never
46
64
  }
47
65
 
48
- /** Path parameter. Like `@Param('id', ParseIntPipe)`. */
49
- export function param<const N extends string>(name: N): Argument<string | undefined, 'param', N>
66
+ /** Path parameter, typed by the route path, or by `parser`. Like `@Param('id', ParseIntPipe)`. */
67
+ export function param<const N extends string>(name: N): Argument<RawParam, 'param', N>
50
68
  export function param<const N extends string, V extends ValueParser<unknown>>(
51
69
  name: N,
52
70
  parser: V
@@ -55,6 +73,7 @@ export function param(name: string, parser?: ValueParser<unknown>): Argument<unk
55
73
  return {
56
74
  kind: 'param',
57
75
  name,
76
+ source: parser,
58
77
  read: parser === undefined ? (ctx) => ctx.param(name) : (ctx) => ctx.param(name, parser),
59
78
  }
60
79
  }
@@ -66,6 +85,7 @@ export function query(name: string, parser?: ValueParser<unknown>): Argument<unk
66
85
  return {
67
86
  kind: 'query',
68
87
  name,
88
+ source: parser,
69
89
  read: parser === undefined ? (ctx) => ctx.query(name) : (ctx) => ctx.query(name, parser),
70
90
  }
71
91
  }
@@ -77,6 +97,7 @@ export function header(name: string, parser?: ValueParser<unknown>): Argument<un
77
97
  return {
78
98
  kind: 'header',
79
99
  name,
100
+ source: parser,
80
101
  read: parser === undefined ? (ctx) => ctx.header(name) : (ctx) => ctx.header(name, parser),
81
102
  }
82
103
  }
@@ -88,6 +109,7 @@ export function cookie(name: string, parser?: ValueParser<unknown>): Argument<un
88
109
  return {
89
110
  kind: 'cookie',
90
111
  name,
112
+ source: parser,
91
113
  read: parser === undefined ? (ctx) => ctx.cookie(name) : (ctx) => ctx.cookie(name, parser),
92
114
  }
93
115
  }
@@ -99,6 +121,7 @@ export function body(schema?: StandardSchemaV1): Argument<unknown, 'body'> {
99
121
  return {
100
122
  kind: 'body',
101
123
  name: '',
124
+ source: schema,
102
125
  read: schema === undefined ? (ctx) => ctx.body() : (ctx) => ctx.body(schema),
103
126
  }
104
127
  }
@@ -128,6 +151,8 @@ export function form(schemaOrLimits?: StandardSchemaV1 | FormLimits, limits?: Fo
128
151
  return {
129
152
  kind: 'body',
130
153
  name: '',
154
+ source: isStandardSchema(schemaOrLimits) ? schemaOrLimits : undefined,
155
+ multipart: true,
131
156
  read: isStandardSchema(schemaOrLimits) ? (ctx) => ctx.form(schemaOrLimits, limits) : (ctx) => ctx.form(schemaOrLimits),
132
157
  }
133
158
  }
@@ -84,6 +84,8 @@ type Result<O extends ParserOptions> = O extends { readonly optional: true } ? s
84
84
  * forged cookie is no better than none.
85
85
  */
86
86
  export const signed = <const O extends ParserOptions = {}>(signer: CookieSigner, options?: O): Parser<Result<O>> => ({
87
+ jsonSchema: { type: 'string' },
88
+ optional: options?.optional === true,
87
89
  parse: (value, name) => {
88
90
  const unsigned = value === undefined ? undefined : signer.unsign(value)
89
91
 
@@ -41,6 +41,8 @@ export interface RouteMetadata {
41
41
  readonly path: string
42
42
  /** Declared arguments; `undefined` when the method receives the context. */
43
43
  readonly args: readonly Argument<unknown>[] | undefined
44
+ /** Declared with `@Sse()`: the result is streamed as server-sent events. */
45
+ readonly sse: boolean
44
46
  }
45
47
 
46
48
  /**
@@ -81,9 +83,9 @@ export type RouteHandlerHint<P extends string> =
81
83
  * Decorator of a route with an argument list: `@Get(':id', [param('id', int())])`.
82
84
  * The method must accept the values of the arguments, in order.
83
85
  */
84
- export type ArgumentsRouteDecorator<A extends readonly Argument<unknown>[]> = <
86
+ export type ArgumentsRouteDecorator<A extends readonly Argument<unknown>[], P extends string = string> = <
85
87
  This,
86
- M extends (this: This, ...args: ArgumentValues<A>) => unknown,
88
+ M extends (this: This, ...args: ArgumentValues<A, P>) => unknown,
87
89
  >(
88
90
  method: M,
89
91
  context: ClassMethodDecoratorContext<This, M>
@@ -101,8 +103,8 @@ export type ArgumentsRouteDecorator<A extends readonly Argument<unknown>[]> = <
101
103
  */
102
104
  export interface RouteDecoratorFactory {
103
105
  <const P extends string = '/'>(path?: P): RouteDecorator<P>
104
- <const P extends string, const A extends readonly ArgumentFor<P>[]>(path: P, args: A): ArgumentsRouteDecorator<A>
105
- <const A extends readonly ArgumentFor<'/'>[]>(args: A): ArgumentsRouteDecorator<A>
106
+ <const P extends string, const A extends readonly ArgumentFor<P>[]>(path: P, args: A): ArgumentsRouteDecorator<A, P>
107
+ <const A extends readonly ArgumentFor<'/'>[]>(args: A): ArgumentsRouteDecorator<A, '/'>
106
108
  }
107
109
 
108
110
  /**
@@ -170,7 +172,7 @@ export const Controller =
170
172
  /**
171
173
  * Creates the route decorator factory of one HTTP method (any method name, extensions included).
172
174
  */
173
- const routeFactory = (method: string, decorator: string): RouteDecoratorFactory =>
175
+ const routeFactory = (method: string, decorator: string, sse = false): RouteDecoratorFactory =>
174
176
  ((pathOrArgs?: string | readonly Argument<unknown>[], args?: readonly Argument<unknown>[]) =>
175
177
  (target: object, context: MethodContext) => {
176
178
  assertPublicMethod(decorator, context)
@@ -179,7 +181,7 @@ const routeFactory = (method: string, decorator: string): RouteDecoratorFactory
179
181
  const list = typeof pathOrArgs === 'string' ? args : pathOrArgs
180
182
 
181
183
  // Decorators run bottom-up: unshift keeps the source order.
182
- metadataOf(target).routes.unshift({ method, path, args: list })
184
+ metadataOf(target).routes.unshift({ method, path, args: list, sse })
183
185
  }) as RouteDecoratorFactory
184
186
 
185
187
  /**
@@ -191,8 +193,8 @@ export interface MethodRouteDecoratorFactory {
191
193
  method: string,
192
194
  path: P,
193
195
  args: A
194
- ): ArgumentsRouteDecorator<A>
195
- <const A extends readonly ArgumentFor<'/'>[]>(method: string, args: A): ArgumentsRouteDecorator<A>
196
+ ): ArgumentsRouteDecorator<A, P>
197
+ <const A extends readonly ArgumentFor<'/'>[]>(method: string, args: A): ArgumentsRouteDecorator<A, '/'>
196
198
  }
197
199
 
198
200
  /**
@@ -222,6 +224,12 @@ export const Head = routeFactory('HEAD', 'Head')
222
224
  /** Route for every method; a route for a concrete method on the same path wins. */
223
225
  export const All = routeFactory('ALL', 'All')
224
226
 
227
+ /**
228
+ * `GET` route streaming server-sent events: the method returns an async
229
+ * iterable of `SseMessage`, such as an `async function*`. Like Nest's `@Sse()`.
230
+ */
231
+ export const Sse = routeFactory('GET', 'Sse', true)
232
+
225
233
  /**
226
234
  * Status of the responses built from the returned value (not of a returned `Response`).
227
235
  * Defaults to `201` for `POST` and `200` otherwise, as in Nest.
@@ -3,6 +3,8 @@
3
3
  * Errors raised while declaring and collecting controllers.
4
4
  */
5
5
 
6
+ import { hideFrameworkFrames } from '../utils/errors.ts'
7
+
6
8
  /**
7
9
  * Reason of a `ControllerError`.
8
10
  *
@@ -11,6 +13,8 @@
11
13
  * - `INVALID_STATUS`: `@HttpCode()` with a status a `Response` cannot have, or twice on one method.
12
14
  * - `INVALID_HEADER`: `@Header()` with a name or value `Headers` rejects.
13
15
  * - `UNKNOWN_PARAM`: `param('x')` in an argument list names no parameter of the route path.
16
+ * - `INVALID_PATH`: a WebSocket gateway path that is not static.
17
+ * - `DUPLICATE_ROUTE`: a gateway on the path of another gateway or route.
14
18
  */
15
19
  export type ControllerErrorCode =
16
20
  | 'NOT_A_CONTROLLER'
@@ -18,6 +22,8 @@ export type ControllerErrorCode =
18
22
  | 'INVALID_STATUS'
19
23
  | 'INVALID_HEADER'
20
24
  | 'UNKNOWN_PARAM'
25
+ | 'INVALID_PATH'
26
+ | 'DUPLICATE_ROUTE'
21
27
 
22
28
  /**
23
29
  * Error thrown by the controller decorators (when the class is defined) and by
@@ -31,5 +37,6 @@ export class ControllerError extends Error {
31
37
  message: string
32
38
  ) {
33
39
  super(message)
40
+ hideFrameworkFrames(this)
34
41
  }
35
42
  }
package/src/http/index.ts CHANGED
@@ -4,12 +4,14 @@
4
4
  */
5
5
 
6
6
  export { body, context, cookie, form, header, param, query, value } from './arguments.ts'
7
- export type { Argument, ArgumentFor, ArgumentKind, ArgumentValues } from './arguments.ts'
7
+ export type { Argument, ArgumentFor, ArgumentKind, ArgumentValues, RawParam } from './arguments.ts'
8
8
  export { Context } from './context.ts'
9
9
  export { CookieSigner, signed } from './cookies.ts'
10
10
  export { ContextKey } from './context-key.ts'
11
11
  export type { ContextParams, ParamNameOf } from './context.ts'
12
- export { All, Controller, Delete, Get, Head, Header, HttpCode, Options, Patch, Post, Put, Route } from './decorators.ts'
12
+ export { All, Controller, Delete, Get, Head, Header, HttpCode, Options, Patch, Post, Put, Route, Sse } from './decorators.ts'
13
+ export { formatSseMessage } from './sse.ts'
14
+ export type { SseMessage } from './sse.ts'
13
15
  export type {
14
16
  ArgumentsRouteDecorator,
15
17
  MethodMetadataDecorator,
@@ -38,8 +40,8 @@ export {
38
40
  export type { HttpExceptionBody, HttpExceptionOptions } from './exceptions.ts'
39
41
  export { boolean, int, number, uuid } from './parsers.ts'
40
42
  export type { Parsed, Parser, ParserOptions, ValueParser } from './parsers.ts'
41
- export { buildRoutes, controllerRoutes } from './routes.ts'
42
- export type { BuiltRoutes, ControllerRoutesOptions, MappedController } from './routes.ts'
43
+ export { buildRoutes, controllerRoutes, ROUTES } from './routes.ts'
44
+ export type { BuiltRoutes, ControllerRoutesOptions, MappedController, MappedRoute, RouteRegistry } from './routes.ts'
43
45
  export type { InferOutput, StandardSchemaV1 } from './schema.ts'
44
46
  export type { AnyParams, PathParams } from './types.ts'
45
47
  export { Catch, createDecorator, UseFilters, UseGuards, UseInterceptors } from './enhancers.ts'
@@ -20,6 +20,10 @@ import type { InferOutput, StandardSchemaV1 } from './schema.ts'
20
20
  */
21
21
  export interface Parser<T> {
22
22
  readonly parse: (value: string | undefined, name: string) => T
23
+ /** JSON Schema of the accepted strings, for API descriptions such as OpenAPI. */
24
+ readonly jsonSchema?: Readonly<Record<string, unknown>>
25
+ /** `true` when a missing value is accepted. */
26
+ readonly optional?: boolean
23
27
  }
24
28
 
25
29
  /** What `param()`, `query()` and `header()` accept to convert a value. */
@@ -46,8 +50,11 @@ export const failure = (name: string, expected: string): BadRequestException =>
46
50
  const createParser = <T>(
47
51
  expected: string,
48
52
  convert: (value: string) => T | undefined,
49
- options: ParserOptions
53
+ options: ParserOptions,
54
+ jsonSchema: Readonly<Record<string, unknown>>
50
55
  ): Parser<T | undefined> => ({
56
+ jsonSchema,
57
+ optional: options.optional === true,
51
58
  parse: (value, name) => {
52
59
  if (value === undefined) {
53
60
  if (options.optional === true) {
@@ -79,7 +86,8 @@ export const int = <const O extends ParserOptions = {}>(options?: O): Parser<Res
79
86
 
80
87
  return Number.isSafeInteger(number) ? number : undefined
81
88
  },
82
- options ?? {}
89
+ options ?? {},
90
+ { type: 'integer' }
83
91
  ) as Parser<Result<number, O>>
84
92
 
85
93
  /** Finite decimal number: `'1.5'`, `'-2'`, `'1e3'`. */
@@ -91,18 +99,21 @@ export const number = <const O extends ParserOptions = {}>(options?: O): Parser<
91
99
 
92
100
  return Number.isFinite(parsed) ? parsed : undefined
93
101
  },
94
- options ?? {}
102
+ options ?? {},
103
+ { type: 'number' }
95
104
  ) as Parser<Result<number, O>>
96
105
 
97
106
  /** `'true'` or `'false'`. */
98
107
  export const boolean = <const O extends ParserOptions = {}>(options?: O): Parser<Result<boolean, O>> =>
99
- createParser('boolean string', (value) => (value === 'true' ? true : value === 'false' ? false : undefined), options ?? {}) as Parser<
100
- Result<boolean, O>
101
- >
108
+ createParser('boolean string', (value) => (value === 'true' ? true : value === 'false' ? false : undefined), options ?? {}, {
109
+ type: 'boolean',
110
+ }) as Parser<Result<boolean, O>>
102
111
 
103
112
  /** UUID of any version, case-insensitive. */
104
113
  export const uuid = <const O extends ParserOptions = {}>(options?: O): Parser<Result<string, O>> =>
105
- createParser('UUID string', (value) => (UUID.test(value) ? value : undefined), options ?? {}) as Parser<Result<string, O>>
114
+ createParser('UUID string', (value) => (UUID.test(value) ? value : undefined), options ?? {}, { type: 'string', format: 'uuid' }) as Parser<
115
+ Result<string, O>
116
+ >
106
117
 
107
118
  /**
108
119
  * Applies a parser or a synchronous Standard Schema to a raw value.
@@ -21,10 +21,10 @@ import { getCaughtExceptions } from './enhancers.ts'
21
21
  import type { CallHandler, CanActivate, ExceptionFilter, FilterLike, Guard, Interceptor, InterceptorLike } from './enhancers.ts'
22
22
  import { errorResponse, ForbiddenException } from './exceptions.ts'
23
23
 
24
- type GuardStep = (ctx: Context) => unknown
25
- type InterceptorStep = (ctx: Context, next: CallHandler) => unknown
24
+ export type GuardStep = (ctx: Context) => unknown
25
+ export type InterceptorStep = (ctx: Context, next: CallHandler) => unknown
26
26
 
27
- interface FilterStep {
27
+ export interface FilterStep {
28
28
  /** Empty: catches everything. */
29
29
  readonly types: readonly ClassToken<unknown>[]
30
30
  readonly handle: (error: unknown, ctx: Context) => Response | Promise<Response>
@@ -174,7 +174,7 @@ export interface PipelineParts {
174
174
  /** In the order they are tried: method, class, global. */
175
175
  readonly filters: readonly FilterStep[]
176
176
  readonly invoke: (ctx: Context) => unknown
177
- readonly respond: (value: unknown) => Response
177
+ readonly respond: (value: unknown, ctx: Context) => Response
178
178
  readonly onError: (error: unknown, req: Request) => void
179
179
  }
180
180
 
@@ -230,11 +230,11 @@ export const createPipeline = (parts: PipelineParts): Handler => {
230
230
 
231
231
  if (isThenable(result)) {
232
232
  return Promise.resolve(result)
233
- .then(respond)
233
+ .then((value) => respond(value, ctx))
234
234
  .catch((error: unknown) => fail(error, ctx))
235
235
  }
236
236
 
237
- return respond(result)
237
+ return respond(result, ctx)
238
238
  } catch (error) {
239
239
  return fail(error, ctx)
240
240
  }