@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.
- package/README.md +80 -0
- package/dist/app/app.d.ts +15 -5
- package/dist/app/app.d.ts.map +1 -1
- package/dist/app/app.js +31 -8
- package/dist/app/app.js.map +1 -1
- package/dist/app/index.d.ts +1 -1
- package/dist/app/index.d.ts.map +1 -1
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +2 -0
- package/dist/config/config.js.map +1 -1
- package/dist/di/errors.d.ts.map +1 -1
- package/dist/di/errors.js +2 -0
- package/dist/di/errors.js.map +1 -1
- package/dist/http/arguments.d.ts +19 -6
- package/dist/http/arguments.d.ts.map +1 -1
- package/dist/http/arguments.js +7 -0
- package/dist/http/arguments.js.map +1 -1
- package/dist/http/cookies.d.ts.map +1 -1
- package/dist/http/cookies.js +2 -0
- package/dist/http/cookies.js.map +1 -1
- package/dist/http/decorators.d.ts +12 -5
- package/dist/http/decorators.d.ts.map +1 -1
- package/dist/http/decorators.js +7 -2
- package/dist/http/decorators.js.map +1 -1
- package/dist/http/errors.d.ts +3 -1
- package/dist/http/errors.d.ts.map +1 -1
- package/dist/http/errors.js +2 -0
- package/dist/http/errors.js.map +1 -1
- package/dist/http/index.d.ts +6 -4
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/index.js +3 -2
- package/dist/http/index.js.map +1 -1
- package/dist/http/parsers.d.ts +4 -0
- package/dist/http/parsers.d.ts.map +1 -1
- package/dist/http/parsers.js +9 -5
- package/dist/http/parsers.js.map +1 -1
- package/dist/http/pipeline.d.ts +4 -5
- package/dist/http/pipeline.d.ts.map +1 -1
- package/dist/http/pipeline.js +2 -2
- package/dist/http/pipeline.js.map +1 -1
- package/dist/http/routes.d.ts +27 -2
- package/dist/http/routes.d.ts.map +1 -1
- package/dist/http/routes.js +34 -20
- package/dist/http/routes.js.map +1 -1
- package/dist/http/sse.d.ts +39 -0
- package/dist/http/sse.d.ts.map +1 -0
- package/dist/http/sse.js +89 -0
- package/dist/http/sse.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/openapi/decorators.d.ts +41 -0
- package/dist/openapi/decorators.d.ts.map +1 -0
- package/dist/openapi/decorators.js +29 -0
- package/dist/openapi/decorators.js.map +1 -0
- package/dist/openapi/document.d.ts +62 -0
- package/dist/openapi/document.d.ts.map +1 -0
- package/dist/openapi/document.js +135 -0
- package/dist/openapi/document.js.map +1 -0
- package/dist/openapi/index.d.ts +11 -0
- package/dist/openapi/index.d.ts.map +1 -0
- package/dist/openapi/index.js +8 -0
- package/dist/openapi/index.js.map +1 -0
- package/dist/openapi/module.d.ts +26 -0
- package/dist/openapi/module.d.ts.map +1 -0
- package/dist/openapi/module.js +73 -0
- package/dist/openapi/module.js.map +1 -0
- package/dist/routing/errors.d.ts.map +1 -1
- package/dist/routing/errors.js +2 -0
- package/dist/routing/errors.js.map +1 -1
- package/dist/testing/index.d.ts +46 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +49 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/utils/errors.d.ts +11 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +22 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/object.d.ts +4 -0
- package/dist/utils/object.d.ts.map +1 -1
- package/dist/utils/object.js +16 -0
- package/dist/utils/object.js.map +1 -1
- package/dist/websocket/decorators.d.ts +31 -0
- package/dist/websocket/decorators.d.ts.map +1 -0
- package/dist/websocket/decorators.js +43 -0
- package/dist/websocket/decorators.js.map +1 -0
- package/dist/websocket/gateway.d.ts +61 -0
- package/dist/websocket/gateway.d.ts.map +1 -0
- package/dist/websocket/gateway.js +142 -0
- package/dist/websocket/gateway.js.map +1 -0
- package/dist/websocket/index.d.ts +7 -0
- package/dist/websocket/index.d.ts.map +1 -0
- package/dist/websocket/index.js +6 -0
- package/dist/websocket/index.js.map +1 -0
- package/package.json +12 -1
- package/src/app/app.ts +69 -18
- package/src/app/index.ts +1 -1
- package/src/config/config.ts +2 -0
- package/src/di/errors.ts +3 -0
- package/src/http/arguments.ts +31 -6
- package/src/http/cookies.ts +2 -0
- package/src/http/decorators.ts +16 -8
- package/src/http/errors.ts +7 -0
- package/src/http/index.ts +6 -4
- package/src/http/parsers.ts +18 -7
- package/src/http/pipeline.ts +6 -6
- package/src/http/routes.ts +68 -29
- package/src/http/sse.ts +120 -0
- package/src/index.ts +1 -0
- package/src/openapi/decorators.ts +50 -0
- package/src/openapi/document.ts +221 -0
- package/src/openapi/index.ts +11 -0
- package/src/openapi/module.ts +95 -0
- package/src/routing/errors.ts +3 -0
- package/src/testing/index.ts +73 -0
- package/src/utils/errors.ts +25 -0
- package/src/utils/object.ts +23 -0
- package/src/websocket/decorators.ts +55 -0
- package/src/websocket/gateway.ts +213 -0
- 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:
|
|
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<
|
|
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<
|
|
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:
|
|
96
|
-
#server: Bun.Server<
|
|
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
|
-
|
|
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<
|
|
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<
|
|
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 = (
|
|
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
|
|
261
|
-
routes.log(`Mapped {${
|
|
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
|
-
|
|
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
package/src/config/config.ts
CHANGED
|
@@ -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
|
}
|
package/src/http/arguments.ts
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
49
|
-
export function param<const N extends string>(name: N): Argument<
|
|
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
|
}
|
package/src/http/cookies.ts
CHANGED
|
@@ -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
|
|
package/src/http/decorators.ts
CHANGED
|
@@ -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.
|
package/src/http/errors.ts
CHANGED
|
@@ -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'
|
package/src/http/parsers.ts
CHANGED
|
@@ -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 ?? {}
|
|
100
|
-
|
|
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<
|
|
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.
|
package/src/http/pipeline.ts
CHANGED
|
@@ -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
|
}
|