@getsoda/core 0.1.0 → 0.2.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 (115) hide show
  1. package/README.md +58 -0
  2. package/dist/app/app.d.ts +17 -1
  3. package/dist/app/app.d.ts.map +1 -1
  4. package/dist/app/app.js +29 -10
  5. package/dist/app/app.js.map +1 -1
  6. package/dist/di/container.d.ts +5 -0
  7. package/dist/di/container.d.ts.map +1 -1
  8. package/dist/di/container.js +15 -1
  9. package/dist/di/container.js.map +1 -1
  10. package/dist/di/graph.d.ts +2 -2
  11. package/dist/di/graph.d.ts.map +1 -1
  12. package/dist/di/graph.js +20 -10
  13. package/dist/di/graph.js.map +1 -1
  14. package/dist/http/arguments.d.ts +20 -2
  15. package/dist/http/arguments.d.ts.map +1 -1
  16. package/dist/http/arguments.js +29 -0
  17. package/dist/http/arguments.js.map +1 -1
  18. package/dist/http/body.d.ts +28 -0
  19. package/dist/http/body.d.ts.map +1 -1
  20. package/dist/http/body.js +96 -1
  21. package/dist/http/body.js.map +1 -1
  22. package/dist/http/context-key.d.ts +33 -0
  23. package/dist/http/context-key.d.ts.map +1 -0
  24. package/dist/http/context-key.js +37 -0
  25. package/dist/http/context-key.js.map +1 -0
  26. package/dist/http/context.d.ts +37 -3
  27. package/dist/http/context.d.ts.map +1 -1
  28. package/dist/http/context.js +63 -5
  29. package/dist/http/context.js.map +1 -1
  30. package/dist/http/cookies.d.ts +49 -0
  31. package/dist/http/cookies.d.ts.map +1 -0
  32. package/dist/http/cookies.js +77 -0
  33. package/dist/http/cookies.js.map +1 -0
  34. package/dist/http/cors.d.ts +41 -0
  35. package/dist/http/cors.d.ts.map +1 -0
  36. package/dist/http/cors.js +97 -0
  37. package/dist/http/cors.js.map +1 -0
  38. package/dist/http/index.d.ts +7 -1
  39. package/dist/http/index.d.ts.map +1 -1
  40. package/dist/http/index.js +4 -1
  41. package/dist/http/index.js.map +1 -1
  42. package/dist/http/middleware.d.ts +56 -0
  43. package/dist/http/middleware.d.ts.map +1 -0
  44. package/dist/http/middleware.js +83 -0
  45. package/dist/http/middleware.js.map +1 -0
  46. package/dist/http/parsers.d.ts +3 -0
  47. package/dist/http/parsers.d.ts.map +1 -1
  48. package/dist/http/parsers.js +2 -1
  49. package/dist/http/parsers.js.map +1 -1
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/jwt/algorithms.d.ts +19 -0
  55. package/dist/jwt/algorithms.d.ts.map +1 -0
  56. package/dist/jwt/algorithms.js +60 -0
  57. package/dist/jwt/algorithms.js.map +1 -0
  58. package/dist/jwt/errors.d.ts +24 -0
  59. package/dist/jwt/errors.d.ts.map +1 -0
  60. package/dist/jwt/errors.js +14 -0
  61. package/dist/jwt/errors.js.map +1 -0
  62. package/dist/jwt/guard.d.ts +30 -0
  63. package/dist/jwt/guard.d.ts.map +1 -0
  64. package/dist/jwt/guard.js +61 -0
  65. package/dist/jwt/guard.js.map +1 -0
  66. package/dist/jwt/index.d.ts +14 -0
  67. package/dist/jwt/index.d.ts.map +1 -0
  68. package/dist/jwt/index.js +10 -0
  69. package/dist/jwt/index.js.map +1 -0
  70. package/dist/jwt/module.d.ts +36 -0
  71. package/dist/jwt/module.d.ts.map +1 -0
  72. package/dist/jwt/module.js +38 -0
  73. package/dist/jwt/module.js.map +1 -0
  74. package/dist/jwt/service.d.ts +66 -0
  75. package/dist/jwt/service.d.ts.map +1 -0
  76. package/dist/jwt/service.js +138 -0
  77. package/dist/jwt/service.js.map +1 -0
  78. package/dist/logger/console-logger.d.ts +28 -0
  79. package/dist/logger/console-logger.d.ts.map +1 -0
  80. package/dist/logger/console-logger.js +87 -0
  81. package/dist/logger/console-logger.js.map +1 -0
  82. package/dist/logger/index.d.ts +9 -0
  83. package/dist/logger/index.d.ts.map +1 -0
  84. package/dist/logger/index.js +7 -0
  85. package/dist/logger/index.js.map +1 -0
  86. package/dist/logger/logger.d.ts +55 -0
  87. package/dist/logger/logger.d.ts.map +1 -0
  88. package/dist/logger/logger.js +66 -0
  89. package/dist/logger/logger.js.map +1 -0
  90. package/dist/routing/types.d.ts +1 -1
  91. package/dist/routing/types.d.ts.map +1 -1
  92. package/package.json +6 -12
  93. package/src/app/app.ts +53 -12
  94. package/src/di/container.ts +24 -2
  95. package/src/di/graph.ts +26 -11
  96. package/src/http/arguments.ts +42 -2
  97. package/src/http/body.ts +142 -1
  98. package/src/http/context-key.ts +35 -0
  99. package/src/http/context.ts +94 -8
  100. package/src/http/cookies.ts +96 -0
  101. package/src/http/cors.ts +143 -0
  102. package/src/http/index.ts +7 -1
  103. package/src/http/middleware.ts +124 -0
  104. package/src/http/parsers.ts +2 -1
  105. package/src/index.ts +1 -0
  106. package/src/jwt/algorithms.ts +113 -0
  107. package/src/jwt/errors.ts +30 -0
  108. package/src/jwt/guard.ts +74 -0
  109. package/src/jwt/index.ts +14 -0
  110. package/src/jwt/module.ts +58 -0
  111. package/src/jwt/service.ts +216 -0
  112. package/src/logger/console-logger.ts +119 -0
  113. package/src/logger/index.ts +9 -0
  114. package/src/logger/logger.ts +96 -0
  115. package/src/routing/types.ts +1 -1
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @module
3
+ * The logger contract.
4
+ *
5
+ * `Logger` is both the type and the DI token: the application provides one
6
+ * (a `ConsoleLogger` unless told otherwise) and any class injects it.
7
+ *
8
+ * ```ts
9
+ * class UsersService {
10
+ * readonly #logger = inject(Logger).child('UsersService')
11
+ *
12
+ * create(user: User) {
13
+ * this.#logger.log('User created', { id: user.id })
14
+ * }
15
+ * }
16
+ * ```
17
+ *
18
+ * Levels are Nest's. Unlike Nest's `Logger`, it is not a static global: each
19
+ * application has its own, and tests replace it with `overrides`.
20
+ */
21
+ /** Levels, from the most to the least verbose. */
22
+ export const LOG_LEVELS = ['verbose', 'debug', 'log', 'warn', 'error', 'fatal'];
23
+ /**
24
+ * A logger. Implementations only write entries; levels and contexts are handled here.
25
+ */
26
+ export class Logger {
27
+ verbose(message, data) {
28
+ this.write({ level: 'verbose', message, data, context: undefined });
29
+ }
30
+ debug(message, data) {
31
+ this.write({ level: 'debug', message, data, context: undefined });
32
+ }
33
+ log(message, data) {
34
+ this.write({ level: 'log', message, data, context: undefined });
35
+ }
36
+ warn(message, data) {
37
+ this.write({ level: 'warn', message, data, context: undefined });
38
+ }
39
+ error(message, data) {
40
+ this.write({ level: 'error', message, data, context: undefined });
41
+ }
42
+ fatal(message, data) {
43
+ this.write({ level: 'fatal', message, data, context: undefined });
44
+ }
45
+ /** A logger writing through this one, with `context` on every entry. */
46
+ child(context) {
47
+ return new ContextLogger(this, context);
48
+ }
49
+ }
50
+ class ContextLogger extends Logger {
51
+ #parent;
52
+ #context;
53
+ constructor(parent, context) {
54
+ super();
55
+ this.#parent = parent;
56
+ this.#context = context;
57
+ }
58
+ write(entry) {
59
+ this.#parent.write({ ...entry, context: this.#context });
60
+ }
61
+ }
62
+ /** A logger that writes nothing: `createApp(AppModule, { logger: false })`. */
63
+ export class SilentLogger extends Logger {
64
+ write() { }
65
+ }
66
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.js","sourceRoot":"","sources":["../../src/logger/logger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,kDAAkD;AAClD,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,SAAS,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAU,CAAA;AAkBxF;;GAEG;AACH,MAAM,OAAgB,MAAM;IAI1B,OAAO,CAAC,OAAe,EAAE,IAAc;QACrC,IAAI,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAA;IACrE,CAAC;IAED,KAAK,CAAC,OAAe,EAAE,IAAc;QACnC,IAAI,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAA;IACnE,CAAC;IAED,GAAG,CAAC,OAAe,EAAE,IAAc;QACjC,IAAI,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAA;IACjE,CAAC;IAED,IAAI,CAAC,OAAe,EAAE,IAAc;QAClC,IAAI,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAA;IAClE,CAAC;IAED,KAAK,CAAC,OAAe,EAAE,IAAc;QACnC,IAAI,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAA;IACnE,CAAC;IAED,KAAK,CAAC,OAAe,EAAE,IAAc;QACnC,IAAI,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAA;IACnE,CAAC;IAED,wEAAwE;IACxE,KAAK,CAAC,OAAe;QACnB,OAAO,IAAI,aAAa,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;IACzC,CAAC;CACF;AAED,MAAM,aAAc,SAAQ,MAAM;IACvB,OAAO,CAAQ;IACf,QAAQ,CAAQ;IAEzB,YAAY,MAAc,EAAE,OAAe;QACzC,KAAK,EAAE,CAAA;QACP,IAAI,CAAC,OAAO,GAAG,MAAM,CAAA;QACrB,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAA;IACzB,CAAC;IAED,KAAK,CAAC,KAAe;QACnB,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,GAAG,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAA;IAC1D,CAAC;CACF;AAED,+EAA+E;AAC/E,MAAM,OAAO,YAAa,SAAQ,MAAM;IACtC,KAAK,KAAU,CAAC;CACjB"}
@@ -30,7 +30,7 @@ export type Params = Record<string, string>;
30
30
  *
31
31
  * `params` may be the very object Bun put on `req.params`: treat it as read-only.
32
32
  */
33
- export type Handler = (req: Request, params: Params) => Response | Promise<Response>;
33
+ export type Handler = (req: Bun.BunRequest, params: Params) => Response | Promise<Response>;
34
34
  /**
35
35
  * Route definition.
36
36
  *
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/routing/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH;;;GAGG;AACH,eAAO,MAAM,UAAU,QAAQ,CAAA;AAE/B;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,SAAU,CAAA;AAExC;;;;;;;GAOG;AACH,MAAM,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;AAE3C;;;;GAIG;AACH,MAAM,MAAM,OAAO,GAAG,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;AAEpF;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,eAAe;IAC9B,+CAA+C;IAC/C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CACvB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,kFAAkF;IAClF,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;IAClE,oFAAoF;IACpF,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAA;CAClC;AAED;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,UAAU,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;AAEjF;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAA;IAC9C,QAAQ,CAAC,KAAK,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CAC/D"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/routing/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH;;;GAGG;AACH,eAAO,MAAM,UAAU,QAAQ,CAAA;AAE/B;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,SAAU,CAAA;AAExC;;;;;;;GAOG;AACH,MAAM,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;AAE3C;;;;GAIG;AACH,MAAM,MAAM,OAAO,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;AAE3F;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,eAAe;IAC9B,+CAA+C;IAC/C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CACvB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,kFAAkF;IAClF,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;IAClE,oFAAoF;IACpF,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAA;CAClC;AAED;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,UAAU,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;AAEjF;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAA;IAC9C,QAAQ,CAAC,KAAK,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CAC/D"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getsoda/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Class-based web framework for Bun: modules, dependency injection and decorators like Nest, on Bun's native router",
5
5
  "keywords": [
6
6
  "bun",
@@ -28,6 +28,10 @@
28
28
  "types": "./dist/index.d.ts",
29
29
  "default": "./dist/index.js"
30
30
  },
31
+ "./jwt": {
32
+ "types": "./dist/jwt/index.d.ts",
33
+ "default": "./dist/jwt/index.js"
34
+ },
31
35
  "./package.json": "./package.json"
32
36
  },
33
37
  "types": "./dist/index.d.ts",
@@ -42,16 +46,6 @@
42
46
  },
43
47
  "scripts": {
44
48
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
45
- "typecheck": "bun check",
46
- "lint": "oxlint",
47
- "lint:fix": "oxlint --fix",
48
- "test": "bun test",
49
- "prepublishOnly": "bun run lint && bun run typecheck && bun run test && bun run build"
50
- },
51
- "devDependencies": {
52
- "@stylistic/eslint-plugin": "5.10.0",
53
- "@types/bun": "latest",
54
- "oxlint": "1.93.0",
55
- "typescript": "7.0.2"
49
+ "prepublishOnly": "cd ../.. && bun run lint && bun run typecheck && bun run test && cd packages/core && bun run build"
56
50
  }
57
51
  }
package/src/app/app.ts CHANGED
@@ -19,11 +19,14 @@
19
19
  * are being destroyed; here they cannot.
20
20
  */
21
21
 
22
- import { createContainer } from '../di/index.ts'
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
25
  import { buildRoutes } from '../http/index.ts'
26
- import type { ControllerRoutesOptions } from '../http/index.ts'
26
+ import type { ControllerRoutesOptions, Middleware } from '../http/index.ts'
27
+ import { errorResponse } from '../http/exceptions.ts'
28
+ import { applyMiddleware, resolveMiddleware } from '../http/middleware.ts'
29
+ import { ConsoleLogger, Logger, SilentLogger } from '../logger/index.ts'
27
30
  import { compileRoutes } from '../routing/index.ts'
28
31
  import type { CompiledRoutes, CompileOptions } from '../routing/index.ts'
29
32
  import { release, takeOver } from './hot.ts'
@@ -31,7 +34,18 @@ import { release, takeOver } from './hot.ts'
31
34
  /**
32
35
  * Options of `createApp`.
33
36
  */
34
- export interface AppOptions extends ContainerOptions, ControllerRoutesOptions, CompileOptions {}
37
+ export interface AppOptions extends ContainerOptions, ControllerRoutesOptions, CompileOptions {
38
+ /**
39
+ * Middleware run around every request, before routing (404 and preflights
40
+ * included), first one outermost: `[cors(), requestId]`.
41
+ */
42
+ readonly middleware?: readonly Middleware[]
43
+ /**
44
+ * Logger of the application, injectable everywhere as `inject(Logger)`.
45
+ * Defaults to a `ConsoleLogger`; `false` writes nothing.
46
+ */
47
+ readonly logger?: Logger | false
48
+ }
35
49
 
36
50
  /**
37
51
  * Where to listen. `port: 0` picks a free port.
@@ -39,6 +53,11 @@ export interface AppOptions extends ContainerOptions, ControllerRoutesOptions, C
39
53
  export interface ListenOptions {
40
54
  readonly port?: number
41
55
  readonly hostname?: string
56
+ /**
57
+ * Largest request body accepted, in bytes; bigger ones are refused before any
58
+ * handler runs. Bun's default is 128 MiB.
59
+ */
60
+ readonly maxRequestBodySize?: number
42
61
  }
43
62
 
44
63
  /** Signals `enableShutdownSignals()` listens to by default. */
@@ -80,11 +99,13 @@ class Application implements App {
80
99
  #closing: Promise<void> | undefined
81
100
  readonly #enhancers: readonly unknown[]
82
101
  readonly #signals = new Map<NodeJS.Signals, () => void>()
102
+ readonly #logger: Logger
83
103
 
84
- constructor(container: Container, routes: CompiledRoutes, enhancers: readonly unknown[]) {
104
+ constructor(container: Container, routes: CompiledRoutes, enhancers: readonly unknown[], logger: Logger) {
85
105
  this.container = container
86
106
  this.routes = routes
87
107
  this.#enhancers = enhancers
108
+ this.#logger = logger
88
109
  }
89
110
 
90
111
  get server(): Bun.Server<undefined> | undefined {
@@ -100,9 +121,9 @@ class Application implements App {
100
121
  throw new Error(`The application already listens on ${this.#server.url.href}`)
101
122
  }
102
123
 
103
- const { port = 0, hostname } = typeof options === 'number' ? { port: options } : options
124
+ const { port = 0, hostname, maxRequestBodySize } = typeof options === 'number' ? { port: options } : options
104
125
 
105
- const server = Bun.serve({ port, hostname, ...this.routes })
126
+ const server = Bun.serve({ port, hostname, maxRequestBodySize, ...this.routes })
106
127
 
107
128
  this.#server = server
108
129
 
@@ -110,6 +131,8 @@ class Application implements App {
110
131
  await takeOver(server.port, this)
111
132
  }
112
133
 
134
+ this.#logger.log(`Listening on ${server.url.href}`)
135
+
113
136
  return server
114
137
  }
115
138
 
@@ -151,7 +174,7 @@ class Application implements App {
151
174
  process.kill(process.pid, signal)
152
175
  },
153
176
  (error: unknown) => {
154
- console.error('Error during shutdown', error)
177
+ this.#logger.fatal('Shutdown failed', { error })
155
178
  process.exit(1)
156
179
  }
157
180
  )
@@ -230,16 +253,34 @@ const destroyEverything = async (enhancers: readonly unknown[], container: Conta
230
253
  * error first, as `createContainer` does.
231
254
  */
232
255
  export const createApp = async (root: ModuleImport, options: AppOptions = {}): Promise<App> => {
233
- const container = await createContainer(root, options)
234
- let enhancers: readonly unknown[] = []
256
+ const logger = options.logger === false ? new SilentLogger() : (options.logger ?? new ConsoleLogger())
257
+ const own = logger.child('Soda')
258
+ const onError =
259
+ options.onError ??
260
+ ((error: unknown, req: Request) =>
261
+ own.error('Unhandled error while handling a request', { error, method: req.method, url: req.url }))
262
+ const container = await createContainer(root, {
263
+ ...options,
264
+ providers: [...(options.providers ?? []), provide(Logger, { useValue: logger })],
265
+ })
266
+ const enhancers: unknown[] = []
235
267
 
236
268
  try {
237
- const built = buildRoutes(container, options)
269
+ // Middleware first: it wraps everything, so it is initialized first and destroyed last.
270
+ const middleware = resolveMiddleware(options.middleware ?? [], (fn) => container.runInContext(fn), (instance) => {
271
+ enhancers.push(instance)
272
+ })
273
+ const built = buildRoutes(container, { ...options, onError })
238
274
 
239
- enhancers = built.enhancers
275
+ enhancers.push(...built.enhancers)
240
276
  await initializeAll(enhancers)
241
277
 
242
- return new Application(container, compileRoutes(built.routes, options), enhancers)
278
+ const compiled = compileRoutes(built.routes, options)
279
+ const routes = applyMiddleware(middleware, compiled.routes, compiled.fetch, (error, req) =>
280
+ errorResponse(error, (unknown) => onError(unknown, req))
281
+ )
282
+
283
+ return new Application(container, routes, enhancers, own)
243
284
  } catch (error) {
244
285
  try {
245
286
  await destroyEverything(enhancers, container)
@@ -19,11 +19,13 @@
19
19
  import { isThenable } from '../utils/promise.ts'
20
20
  import { runInContext } from './context.ts'
21
21
  import type { InjectionContext } from './context.ts'
22
- import type { ModuleClass, ModuleImport } from './decorators.ts'
22
+ import { Module } from './decorators.ts'
23
+ import type { DynamicModule, ModuleClass, ModuleImport } from './decorators.ts'
23
24
  import { DiError } from './errors.ts'
24
25
  import { buildGraph } from './graph.ts'
25
26
  import type { ModuleGraph, ModuleNode, ProviderNode } from './graph.ts'
26
27
  import { destroyAll, initializeAll } from './lifecycle.ts'
28
+ import { isProviderRecord } from './provider.ts'
27
29
  import type { Provider, ProviderDefinition } from './provider.ts'
28
30
  import { tokenName } from './token.ts'
29
31
  import type { Constructor, Token } from './token.ts'
@@ -43,6 +45,25 @@ export interface ContainerOptions {
43
45
  * ```
44
46
  */
45
47
  readonly overrides?: readonly Provider[]
48
+ /**
49
+ * Providers visible in every module, as if exported by a global module:
50
+ * what the application itself provides, such as its `Logger`.
51
+ */
52
+ readonly providers?: readonly Provider[]
53
+ }
54
+
55
+ /** Module holding `ContainerOptions.providers`. */
56
+ @Module({})
57
+ class ContainerProvidersModule {}
58
+
59
+ const providersModule = (providers: readonly Provider[]): DynamicModule => {
60
+ const exports: Token<unknown>[] = []
61
+
62
+ for (const provider of providers) {
63
+ exports.push(isProviderRecord(provider) ? provider.token : provider)
64
+ }
65
+
66
+ return { module: ContainerProvidersModule, providers, exports, global: true }
46
67
  }
47
68
 
48
69
  /**
@@ -524,7 +545,8 @@ const createAll = async (graph: ModuleGraph, resolver: Resolver): Promise<Contro
524
545
  * hooks fail too, everything is rethrown as one `AggregateError`, startup error first.
525
546
  */
526
547
  export const createContainer = async (root: ModuleImport, options: ContainerOptions = {}): Promise<Container> => {
527
- const graph = await buildGraph(root, options.overrides ?? [])
548
+ const providers = options.providers ?? []
549
+ const graph = await buildGraph(root, options.overrides ?? [], providers.length > 0 ? [providersModule(providers)] : [])
528
550
  const resolver = new Resolver(graph)
529
551
 
530
552
  try {
package/src/di/graph.ts CHANGED
@@ -139,7 +139,17 @@ class GraphBuilder {
139
139
  }
140
140
  }
141
141
 
142
- async build(root: ModuleImport): Promise<ModuleGraph> {
142
+ /**
143
+ * `globals` are global modules added next to `root` (the container's own
144
+ * providers); they are created first, before anything that may inject them.
145
+ */
146
+ async build(root: ModuleImport, globals: readonly ModuleImport[]): Promise<ModuleGraph> {
147
+ const globalNodes: ModuleNode[] = []
148
+
149
+ for (let i = 0, len = globals.length; i < len; i++) {
150
+ globalNodes.push(await this.#module(globals[i], 'createContainer()', i))
151
+ }
152
+
143
153
  const rootNode = await this.#module(root, 'createContainer()', 0)
144
154
 
145
155
  // The queue grows while modules are read: breadth-first over imports.
@@ -149,8 +159,8 @@ class GraphBuilder {
149
159
 
150
160
  this.#assertReplacementsUsed()
151
161
 
152
- const modules = orderModules(rootNode)
153
- const globals: ModuleNode[] = []
162
+ const modules = orderModules([...globalNodes, rootNode])
163
+ const globalModules: ModuleNode[] = []
154
164
  const providers: ProviderNode[] = []
155
165
  const controllers: ProviderNode[] = []
156
166
 
@@ -160,14 +170,14 @@ class GraphBuilder {
160
170
  assertExportsProvided(node)
161
171
 
162
172
  if (node.global) {
163
- globals.push(node)
173
+ globalModules.push(node)
164
174
  }
165
175
 
166
176
  providers.push(...node.providers.values())
167
177
  controllers.push(...node.controllers)
168
178
  }
169
179
 
170
- return { root: rootNode, modules, globals, providers, controllers }
180
+ return { root: rootNode, modules, globals: globalModules, providers, controllers }
171
181
  }
172
182
 
173
183
  /** Node for an `imports` entry, created on first sight. */
@@ -320,8 +330,8 @@ const assertExportsProvided = (node: ModuleNode): void => {
320
330
  }
321
331
  }
322
332
 
323
- /** Modules reachable from `root`, each after the modules it imports. */
324
- const orderModules = (root: ModuleNode): ModuleNode[] => {
333
+ /** Modules reachable from `roots`, each after the modules it imports. */
334
+ const orderModules = (roots: readonly ModuleNode[]): ModuleNode[] => {
325
335
  const ordered: ModuleNode[] = []
326
336
  const visited = new Set<ModuleNode>()
327
337
 
@@ -339,15 +349,20 @@ const orderModules = (root: ModuleNode): ModuleNode[] => {
339
349
  ordered.push(node)
340
350
  }
341
351
 
342
- visit(root)
352
+ for (const root of roots) {
353
+ visit(root)
354
+ }
343
355
 
344
356
  return ordered
345
357
  }
346
358
 
347
359
  /**
348
- * Builds the module graph rooted at `root`.
360
+ * Builds the module graph rooted at `root`, with `globals` as extra global modules.
349
361
  *
350
362
  * `overrides` replace the provider of their token in every module providing it (for tests).
351
363
  */
352
- export const buildGraph = (root: ModuleImport, overrides: readonly Provider[]): Promise<ModuleGraph> =>
353
- new GraphBuilder(overrides).build(root)
364
+ export const buildGraph = (
365
+ root: ModuleImport,
366
+ overrides: readonly Provider[],
367
+ globals: readonly ModuleImport[] = []
368
+ ): Promise<ModuleGraph> => new GraphBuilder(overrides).build(root, globals)
@@ -16,18 +16,21 @@
16
16
  * called by the framework before the method instead of by the method itself.
17
17
  */
18
18
 
19
+ import type { FormFields, FormLimits } from './body.ts'
20
+ import type { ContextKey } from './context-key.ts'
19
21
  import type { Context, ParamNameOf } from './context.ts'
20
22
  import type { Parsed, ValueParser } from './parsers.ts'
23
+ import { isStandardSchema } from './schema.ts'
21
24
  import type { InferOutput, StandardSchemaV1 } from './schema.ts'
22
25
 
23
- export type ArgumentKind = 'param' | 'query' | 'header' | 'body' | 'context'
26
+ export type ArgumentKind = 'param' | 'query' | 'header' | 'cookie' | 'body' | 'context' | 'value'
24
27
 
25
28
  /**
26
29
  * One argument of a handler: where it comes from and how it is read.
27
30
  */
28
31
  export interface Argument<T, K extends ArgumentKind = ArgumentKind, N extends string = string> {
29
32
  readonly kind: K
30
- /** Parameter, query or header name; empty for `body` and `context`. */
33
+ /** Parameter, query, header or cookie name; the key description for `value`; empty for `body` and `context`. */
31
34
  readonly name: N
32
35
  readonly read: (ctx: Context) => T | Promise<T>
33
36
  }
@@ -78,6 +81,17 @@ export function header(name: string, parser?: ValueParser<unknown>): Argument<un
78
81
  }
79
82
  }
80
83
 
84
+ /** Cookie. Like `@Cookies('session')` with `cookie-parser`; `signed(signer)` checks a signed one. */
85
+ export function cookie(name: string): Argument<string | undefined, 'cookie'>
86
+ export function cookie<V extends ValueParser<unknown>>(name: string, parser: V): Argument<Parsed<V>, 'cookie'>
87
+ export function cookie(name: string, parser?: ValueParser<unknown>): Argument<unknown, 'cookie'> {
88
+ return {
89
+ kind: 'cookie',
90
+ name,
91
+ read: parser === undefined ? (ctx) => ctx.cookie(name) : (ctx) => ctx.cookie(name, parser),
92
+ }
93
+ }
94
+
81
95
  /** JSON body, validated by `schema`. Like `@Body() dto: CreateUserDto` with a validation pipe. */
82
96
  export function body(): Argument<unknown, 'body'>
83
97
  export function body<S extends StandardSchemaV1>(schema: S): Argument<InferOutput<S>, 'body'>
@@ -91,3 +105,29 @@ export function body(schema?: StandardSchemaV1): Argument<unknown, 'body'> {
91
105
 
92
106
  /** The context itself, for what no other argument covers. Like `@Req()`. */
93
107
  export const context = (): Argument<Context, 'context'> => ({ kind: 'context', name: '', read: (ctx) => ctx })
108
+
109
+ /**
110
+ * Value a guard or an interceptor attached to the request. Like a custom Nest
111
+ * parameter decorator such as `@User()`.
112
+ *
113
+ * ```ts
114
+ * @Get('me', [value(USER)])
115
+ * me(user: User) {}
116
+ * ```
117
+ */
118
+ export const value = <T>(key: ContextKey<T>): Argument<T, 'value'> => ({
119
+ kind: 'value',
120
+ name: key.description,
121
+ read: (ctx) => ctx.get(key),
122
+ })
123
+
124
+ /** Form body, validated by `schema` when given, within `limits`. Like `@Body()` on a form, or Nest's file interceptors with multer limits. */
125
+ export function form(limits?: FormLimits): Argument<FormFields, 'body'>
126
+ export function form<S extends StandardSchemaV1>(schema: S, limits?: FormLimits): Argument<InferOutput<S>, 'body'>
127
+ export function form(schemaOrLimits?: StandardSchemaV1 | FormLimits, limits?: FormLimits): Argument<unknown, 'body'> {
128
+ return {
129
+ kind: 'body',
130
+ name: '',
131
+ read: isStandardSchema(schemaOrLimits) ? (ctx) => ctx.form(schemaOrLimits, limits) : (ctx) => ctx.form(schemaOrLimits),
132
+ }
133
+ }
package/src/http/body.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * (`"constructor": "Ford"`) is legitimate and kept.
16
16
  */
17
17
 
18
- import { BadRequestException, UnsupportedMediaTypeException } from './exceptions.ts'
18
+ import { BadRequestException, PayloadTooLargeException, UnsupportedMediaTypeException } from './exceptions.ts'
19
19
 
20
20
  const SUSPECT_PROTO =
21
21
  /"(?:_|\\u005[Ff])(?:_|\\u005[Ff])(?:p|\\u0070)(?:r|\\u0072)(?:o|\\u006[Ff])(?:t|\\u0074)(?:o|\\u006[Ff])(?:_|\\u005[Ff])(?:_|\\u005[Ff])"\s*:/
@@ -79,3 +79,144 @@ export const readJsonBody = async (req: Request): Promise<unknown> => {
79
79
 
80
80
  return parseJson(text)
81
81
  }
82
+
83
+ const FORM_TYPE = /^(?:multipart\/form-data|application\/x-www-form-urlencoded)(?:\s*;|$)/i
84
+ const ARRAY_SUFFIX = '[]'
85
+
86
+ /** Parsed form data, as `Request.formData()` gives it. */
87
+ type FormBody = Awaited<ReturnType<Request['formData']>>
88
+
89
+ /** A form value: text, or a file of a multipart body. */
90
+ export type FormValue = string | File
91
+
92
+ /** Fields of a form; repeated fields and fields named `name[]` are arrays. */
93
+ export type FormFields = Record<string, FormValue | FormValue[]>
94
+
95
+ /**
96
+ * Turns form data into an object without prototype.
97
+ *
98
+ * ```ts
99
+ * 'a=1&a=2' => { a: ['1', '2'] } // repeated fields are kept, not overwritten
100
+ * 'tags[]=x' => { tags: ['x'] } // `[]` always gives an array, even with one value
101
+ * 'name=Ann' => { name: 'Ann' }
102
+ * ```
103
+ */
104
+ const toFields = (data: FormBody): FormFields => {
105
+ const fields: FormFields = Object.create(null)
106
+
107
+ for (const [rawKey, value] of data) {
108
+ const array = rawKey.endsWith(ARRAY_SUFFIX)
109
+ const key = array ? rawKey.slice(0, -ARRAY_SUFFIX.length) : rawKey
110
+ const current = fields[key]
111
+
112
+ if (current === undefined) {
113
+ fields[key] = array ? [value] : value
114
+ } else if (Array.isArray(current)) {
115
+ current.push(value)
116
+ } else {
117
+ fields[key] = [current, value]
118
+ }
119
+ }
120
+
121
+ return fields
122
+ }
123
+
124
+ /**
125
+ * Limits of a form body, beyond the server's `maxRequestBodySize`; each one
126
+ * exceeded is a `413`. Like the `limits` of multer and `@fastify/multipart`.
127
+ */
128
+ export interface FormLimits {
129
+ /** Bytes of the whole body, refused as soon as they are exceeded, before the body is read entirely. */
130
+ readonly maxBodySize?: number
131
+ /** Bytes of each file. */
132
+ readonly maxFileSize?: number
133
+ /** Number of files. */
134
+ readonly maxFiles?: number
135
+ /** Number of fields, files included. */
136
+ readonly maxFields?: number
137
+ }
138
+
139
+ /** The body, read up to `max` bytes: `413` as soon as it is larger. */
140
+ const readUpTo = async (req: Request, max: number): Promise<Uint8Array<ArrayBuffer>> => {
141
+ if (Number(req.headers.get('content-length') ?? 0) > max) {
142
+ throw new PayloadTooLargeException(`Request body is larger than ${max} bytes`)
143
+ }
144
+
145
+ if (req.body === null) {
146
+ return new Uint8Array()
147
+ }
148
+
149
+ const chunks: Uint8Array[] = []
150
+ let size = 0
151
+
152
+ for await (const chunk of req.body) {
153
+ size += chunk.byteLength
154
+
155
+ if (size > max) {
156
+ throw new PayloadTooLargeException(`Request body is larger than ${max} bytes`)
157
+ }
158
+
159
+ chunks.push(chunk)
160
+ }
161
+
162
+ return Buffer.concat(chunks)
163
+ }
164
+
165
+ /** `413` for the first limit the form exceeds. */
166
+ const checkLimits = (data: FormBody, limits: FormLimits): void => {
167
+ const { maxFileSize = Infinity, maxFiles = Infinity, maxFields = Infinity } = limits
168
+ let fields = 0
169
+ let files = 0
170
+
171
+ for (const [name, value] of data) {
172
+ fields++
173
+
174
+ if (fields > maxFields) {
175
+ throw new PayloadTooLargeException(`Form has more than ${maxFields} fields`)
176
+ }
177
+
178
+ if (typeof value !== 'string') {
179
+ files++
180
+
181
+ if (files > maxFiles) {
182
+ throw new PayloadTooLargeException(`Form has more than ${maxFiles} files`)
183
+ }
184
+
185
+ if (value.size > maxFileSize) {
186
+ throw new PayloadTooLargeException(`File "${name}" is larger than ${maxFileSize} bytes`)
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Reads a `multipart/form-data` or `application/x-www-form-urlencoded` body:
194
+ * `415` for another content type, `400` for a malformed body, `413` past a limit.
195
+ *
196
+ * Kept apart from JSON on purpose: an HTML form on another site can post a form
197
+ * without a CORS preflight, a JSON body cannot. A JSON endpoint must not accept forms.
198
+ *
199
+ * `File.type` is the type the client declared, not checked against the content.
200
+ */
201
+ export const readFormBody = async (req: Request, limits: FormLimits = {}): Promise<FormFields> => {
202
+ const type = req.headers.get('content-type')
203
+
204
+ if (type === null || !FORM_TYPE.test(type)) {
205
+ throw new UnsupportedMediaTypeException(`Expected a form body, got ${type ?? 'no content type'}`)
206
+ }
207
+
208
+ // Parsed from the bytes read so far, as only a whole body can be parsed.
209
+ const source =
210
+ limits.maxBodySize === undefined ? req : new Response(await readUpTo(req, limits.maxBodySize), { headers: { 'content-type': type } })
211
+ let data: FormBody
212
+
213
+ try {
214
+ data = await source.formData()
215
+ } catch (error) {
216
+ throw new BadRequestException('Request body is not a valid form', { cause: error })
217
+ }
218
+
219
+ checkLimits(data, limits)
220
+
221
+ return toFields(data)
222
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @module
3
+ * Typed keys for values attached to a request: what a guard found (the user,
4
+ * the tenant), read later by interceptors and the handler.
5
+ *
6
+ * Like Angular's `HttpContextToken`: the key is an object, so two keys can
7
+ * never collide, and it carries the value type. Unlike it, a default is
8
+ * optional: reading a key nobody set and that has no default is a programming
9
+ * error (a guard missing on the route), reported as such.
10
+ *
11
+ * ```ts
12
+ * export const USER = new ContextKey<User>('user')
13
+ *
14
+ * // guard
15
+ * ctx.set(USER, await this.#auth.verify(ctx.header('authorization')))
16
+ *
17
+ * // handler
18
+ * const user = ctx.get(USER)
19
+ * ```
20
+ */
21
+
22
+ /**
23
+ * Key of a value attached to a request.
24
+ */
25
+ export class ContextKey<T> {
26
+ constructor(
27
+ readonly description: string,
28
+ /** Value of the key when nothing was set. Without it, reading an unset key throws. */
29
+ readonly defaultValue?: () => T
30
+ ) {}
31
+
32
+ toString(): string {
33
+ return `ContextKey(${this.description})`
34
+ }
35
+ }