@raiyyan/brisk 0.0.0-stage → 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 (206) hide show
  1. package/AGENTS.md +535 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1745 -2
  4. package/dist/bin/brisk.d.ts +3 -0
  5. package/dist/bin/brisk.d.ts.map +1 -0
  6. package/dist/bin/brisk.js +13 -0
  7. package/dist/bin/brisk.js.map +1 -0
  8. package/dist/src/cli/agents.d.ts +7 -0
  9. package/dist/src/cli/agents.d.ts.map +1 -0
  10. package/dist/src/cli/agents.js +55 -0
  11. package/dist/src/cli/agents.js.map +1 -0
  12. package/dist/src/cli/cli.d.ts +5 -0
  13. package/dist/src/cli/cli.d.ts.map +1 -0
  14. package/dist/src/cli/cli.js +113 -0
  15. package/dist/src/cli/cli.js.map +1 -0
  16. package/dist/src/cli/client.d.ts +12 -0
  17. package/dist/src/cli/client.d.ts.map +1 -0
  18. package/dist/src/cli/client.js +76 -0
  19. package/dist/src/cli/client.js.map +1 -0
  20. package/dist/src/cli/init.d.ts +16 -0
  21. package/dist/src/cli/init.d.ts.map +1 -0
  22. package/dist/src/cli/init.js +149 -0
  23. package/dist/src/cli/init.js.map +1 -0
  24. package/dist/src/cli/names.d.ts +14 -0
  25. package/dist/src/cli/names.d.ts.map +1 -0
  26. package/dist/src/cli/names.js +25 -0
  27. package/dist/src/cli/names.js.map +1 -0
  28. package/dist/src/cli/render.d.ts +30 -0
  29. package/dist/src/cli/render.d.ts.map +1 -0
  30. package/dist/src/cli/render.js +101 -0
  31. package/dist/src/cli/render.js.map +1 -0
  32. package/dist/src/cli/system.d.ts +35 -0
  33. package/dist/src/cli/system.d.ts.map +1 -0
  34. package/dist/src/cli/system.js +81 -0
  35. package/dist/src/cli/system.js.map +1 -0
  36. package/dist/src/client/client.d.ts +64 -0
  37. package/dist/src/client/client.d.ts.map +1 -0
  38. package/dist/src/client/client.js +170 -0
  39. package/dist/src/client/client.js.map +1 -0
  40. package/dist/src/client/index.d.ts +8 -0
  41. package/dist/src/client/index.d.ts.map +1 -0
  42. package/dist/src/client/index.js +7 -0
  43. package/dist/src/client/index.js.map +1 -0
  44. package/dist/src/config/env.d.ts +28 -0
  45. package/dist/src/config/env.d.ts.map +1 -0
  46. package/dist/src/config/env.js +35 -0
  47. package/dist/src/config/env.js.map +1 -0
  48. package/dist/src/config/logger.d.ts +32 -0
  49. package/dist/src/config/logger.d.ts.map +1 -0
  50. package/dist/src/config/logger.js +31 -0
  51. package/dist/src/config/logger.js.map +1 -0
  52. package/dist/src/di/container.d.ts +194 -0
  53. package/dist/src/di/container.d.ts.map +1 -0
  54. package/dist/src/di/container.js +225 -0
  55. package/dist/src/di/container.js.map +1 -0
  56. package/dist/src/di/index.d.ts +8 -0
  57. package/dist/src/di/index.d.ts.map +1 -0
  58. package/dist/src/di/index.js +8 -0
  59. package/dist/src/di/index.js.map +1 -0
  60. package/dist/src/errors/http-errors.d.ts +295 -0
  61. package/dist/src/errors/http-errors.d.ts.map +1 -0
  62. package/dist/src/errors/http-errors.js +311 -0
  63. package/dist/src/errors/http-errors.js.map +1 -0
  64. package/dist/src/errors/index.d.ts +8 -0
  65. package/dist/src/errors/index.d.ts.map +1 -0
  66. package/dist/src/errors/index.js +8 -0
  67. package/dist/src/errors/index.js.map +1 -0
  68. package/dist/src/http/app.d.ts +229 -0
  69. package/dist/src/http/app.d.ts.map +1 -0
  70. package/dist/src/http/app.js +45 -0
  71. package/dist/src/http/app.js.map +1 -0
  72. package/dist/src/http/context.d.ts +66 -0
  73. package/dist/src/http/context.d.ts.map +1 -0
  74. package/dist/src/http/context.js +96 -0
  75. package/dist/src/http/context.js.map +1 -0
  76. package/dist/src/http/controller.d.ts +195 -0
  77. package/dist/src/http/controller.d.ts.map +1 -0
  78. package/dist/src/http/controller.js +290 -0
  79. package/dist/src/http/controller.js.map +1 -0
  80. package/dist/src/http/error-handler.d.ts +86 -0
  81. package/dist/src/http/error-handler.d.ts.map +1 -0
  82. package/dist/src/http/error-handler.js +147 -0
  83. package/dist/src/http/error-handler.js.map +1 -0
  84. package/dist/src/http/instance.d.ts +14 -0
  85. package/dist/src/http/instance.d.ts.map +1 -0
  86. package/dist/src/http/instance.js +377 -0
  87. package/dist/src/http/instance.js.map +1 -0
  88. package/dist/src/http/middleware.d.ts +141 -0
  89. package/dist/src/http/middleware.d.ts.map +1 -0
  90. package/dist/src/http/middleware.js +160 -0
  91. package/dist/src/http/middleware.js.map +1 -0
  92. package/dist/src/http/openapi.d.ts +34 -0
  93. package/dist/src/http/openapi.d.ts.map +1 -0
  94. package/dist/src/http/openapi.js +151 -0
  95. package/dist/src/http/openapi.js.map +1 -0
  96. package/dist/src/http/path-params.d.ts +24 -0
  97. package/dist/src/http/path-params.d.ts.map +1 -0
  98. package/dist/src/http/path-params.js +2 -0
  99. package/dist/src/http/path-params.js.map +1 -0
  100. package/dist/src/http/routes.d.ts +141 -0
  101. package/dist/src/http/routes.d.ts.map +1 -0
  102. package/dist/src/http/routes.js +2 -0
  103. package/dist/src/http/routes.js.map +1 -0
  104. package/dist/src/http/server.d.ts +23 -0
  105. package/dist/src/http/server.d.ts.map +1 -0
  106. package/dist/src/http/server.js +22 -0
  107. package/dist/src/http/server.js.map +1 -0
  108. package/dist/src/http/validation.d.ts +16 -0
  109. package/dist/src/http/validation.d.ts.map +1 -0
  110. package/dist/src/http/validation.js +51 -0
  111. package/dist/src/http/validation.js.map +1 -0
  112. package/dist/src/index.d.ts +22 -0
  113. package/dist/src/index.d.ts.map +1 -0
  114. package/dist/src/index.js +19 -0
  115. package/dist/src/index.js.map +1 -0
  116. package/dist/src/testing/index.d.ts +7 -0
  117. package/dist/src/testing/index.d.ts.map +1 -0
  118. package/dist/src/testing/index.js +7 -0
  119. package/dist/src/testing/index.js.map +1 -0
  120. package/dist/src/testing/test-client.d.ts +67 -0
  121. package/dist/src/testing/test-client.d.ts.map +1 -0
  122. package/dist/src/testing/test-client.js +78 -0
  123. package/dist/src/testing/test-client.js.map +1 -0
  124. package/package.json +89 -3
  125. package/starters/minimal/.env.example +3 -0
  126. package/starters/minimal/.oxfmtrc.json +5 -0
  127. package/starters/minimal/.oxlintrc.json +14 -0
  128. package/starters/minimal/AGENTS.md +23 -0
  129. package/starters/minimal/CLAUDE.md +2 -0
  130. package/starters/minimal/README.md +53 -0
  131. package/starters/minimal/_gitignore +3 -0
  132. package/starters/minimal/package.json +29 -0
  133. package/starters/minimal/src/app.ts +14 -0
  134. package/starters/minimal/src/env.ts +7 -0
  135. package/starters/minimal/src/features/index.ts +8 -0
  136. package/starters/minimal/src/features/notes/notes.controller.ts +38 -0
  137. package/starters/minimal/src/features/notes/notes.repository.ts +36 -0
  138. package/starters/minimal/src/features/notes/notes.schema.ts +24 -0
  139. package/starters/minimal/src/features/notes/notes.service.ts +33 -0
  140. package/starters/minimal/src/main.ts +4 -0
  141. package/starters/minimal/src/schemas.ts +26 -0
  142. package/starters/minimal/templates/feature/src/features/index.ts.controllers.insert +1 -0
  143. package/starters/minimal/templates/feature/src/features/index.ts.imports.insert +1 -0
  144. package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.controller.ts +38 -0
  145. package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.repository.ts +36 -0
  146. package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.schema.ts +24 -0
  147. package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.service.ts +33 -0
  148. package/starters/minimal/templates/feature/test/{{kebab}}.service.test.ts +20 -0
  149. package/starters/minimal/templates/feature/test/{{kebab}}.test.ts +35 -0
  150. package/starters/minimal/templates/middleware/src/middlewares/{{kebab}}.ts +12 -0
  151. package/starters/minimal/test/notes.service.test.ts +20 -0
  152. package/starters/minimal/test/notes.test.ts +35 -0
  153. package/starters/minimal/tsconfig.json +17 -0
  154. package/starters/minimal/vitest.config.ts +9 -0
  155. package/starters/sqlite-auth/.dockerignore +8 -0
  156. package/starters/sqlite-auth/.env.example +9 -0
  157. package/starters/sqlite-auth/.oxfmtrc.json +5 -0
  158. package/starters/sqlite-auth/.oxlintrc.json +14 -0
  159. package/starters/sqlite-auth/AGENTS.md +23 -0
  160. package/starters/sqlite-auth/CLAUDE.md +2 -0
  161. package/starters/sqlite-auth/Dockerfile +23 -0
  162. package/starters/sqlite-auth/README.md +93 -0
  163. package/starters/sqlite-auth/_gitignore +5 -0
  164. package/starters/sqlite-auth/compose.yaml +14 -0
  165. package/starters/sqlite-auth/package.json +35 -0
  166. package/starters/sqlite-auth/public/.gitkeep +0 -0
  167. package/starters/sqlite-auth/src/app.ts +39 -0
  168. package/starters/sqlite-auth/src/auth/tokens.ts +41 -0
  169. package/starters/sqlite-auth/src/database/clock.ts +4 -0
  170. package/starters/sqlite-auth/src/database/database.ts +29 -0
  171. package/starters/sqlite-auth/src/database/errors.ts +11 -0
  172. package/starters/sqlite-auth/src/database/migrations.ts +29 -0
  173. package/starters/sqlite-auth/src/env.ts +17 -0
  174. package/starters/sqlite-auth/src/features/auth/auth.controller.ts +28 -0
  175. package/starters/sqlite-auth/src/features/index.ts +12 -0
  176. package/starters/sqlite-auth/src/features/notes/notes.controller.ts +39 -0
  177. package/starters/sqlite-auth/src/features/notes/notes.repository.ts +47 -0
  178. package/starters/sqlite-auth/src/features/notes/notes.schema.ts +24 -0
  179. package/starters/sqlite-auth/src/features/notes/notes.service.ts +33 -0
  180. package/starters/sqlite-auth/src/features/users/admin-seed.ts +21 -0
  181. package/starters/sqlite-auth/src/features/users/users.controller.ts +42 -0
  182. package/starters/sqlite-auth/src/features/users/users.errors.ts +13 -0
  183. package/starters/sqlite-auth/src/features/users/users.repository.ts +77 -0
  184. package/starters/sqlite-auth/src/features/users/users.schema.ts +35 -0
  185. package/starters/sqlite-auth/src/features/users/users.service.ts +80 -0
  186. package/starters/sqlite-auth/src/main.ts +4 -0
  187. package/starters/sqlite-auth/src/middlewares/authenticate.ts +29 -0
  188. package/starters/sqlite-auth/src/schemas.ts +26 -0
  189. package/starters/sqlite-auth/templates/feature/src/database/migrations.ts.migrations.insert +5 -0
  190. package/starters/sqlite-auth/templates/feature/src/features/index.ts.controllers.insert +1 -0
  191. package/starters/sqlite-auth/templates/feature/src/features/index.ts.imports.insert +1 -0
  192. package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.controller.ts +39 -0
  193. package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.repository.ts +47 -0
  194. package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.schema.ts +24 -0
  195. package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.service.ts +33 -0
  196. package/starters/sqlite-auth/templates/feature/test/{{kebab}}.service.test.ts +20 -0
  197. package/starters/sqlite-auth/templates/feature/test/{{kebab}}.test.ts +41 -0
  198. package/starters/sqlite-auth/templates/middleware/src/middlewares/{{kebab}}.ts +12 -0
  199. package/starters/sqlite-auth/test/auth.test.ts +18 -0
  200. package/starters/sqlite-auth/test/helpers.ts +13 -0
  201. package/starters/sqlite-auth/test/notes.service.test.ts +20 -0
  202. package/starters/sqlite-auth/test/notes.test.ts +41 -0
  203. package/starters/sqlite-auth/test/users.service.test.ts +25 -0
  204. package/starters/sqlite-auth/test/users.test.ts +164 -0
  205. package/starters/sqlite-auth/tsconfig.json +17 -0
  206. package/starters/sqlite-auth/vitest.config.ts +15 -0
package/README.md CHANGED
@@ -1,3 +1,1746 @@
1
- # Temporary Holding Version
1
+ # brisk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A small, typed, opinionated layer over Express 5 and Zod. You keep the Express you know: plain functions, plain classes, any Express middleware. brisk removes the wiring you write by hand in every Express app: request validation, response DTOs, typed request data, error responses, environment validation, dependency injection, lifecycle and graceful shutdown, a typed client and an OpenAPI document. Choices such as the database, the auth scheme and the tooling stay yours: they live in starters and in your project's own templates.
4
+
5
+ ```ts
6
+ import { defineApp, defineController, inject, NotFoundError, z } from "@raiyyan/brisk";
7
+
8
+ const users = defineController({ prefix: "/users" }, (route) => {
9
+ const service = inject(UsersService);
10
+
11
+ return {
12
+ get: route.get("/:id", { params: z.object({ id: z.uuid() }), response: UserResponse }, ({ params }) => {
13
+ const user = service.find(params.id);
14
+ if (!user) throw new NotFoundError("User not found");
15
+ return user; // validated, typed, and stripped to UserResponse
16
+ }),
17
+ };
18
+ });
19
+
20
+ export const app = defineApp({ prefix: "/api", controllers: { users } });
21
+
22
+ await app.listen(3000);
23
+ ```
24
+
25
+ ## Contents
26
+
27
+ - [Why brisk](#why-brisk)
28
+ - [Requirements](#requirements)
29
+ - [Installation](#installation)
30
+ - [Quick start](#quick-start)
31
+ - [Starters](#starters)
32
+ - [Project structure](#project-structure)
33
+ - [Configuration](#configuration)
34
+ - [Controllers and routes](#controllers-and-routes)
35
+ - [Validation](#validation)
36
+ - [Middleware](#middleware)
37
+ - [Dependency injection](#dependency-injection)
38
+ - [Lifecycle](#lifecycle)
39
+ - [Errors](#errors)
40
+ - [The request context](#the-request-context)
41
+ - [Logging](#logging)
42
+ - [Security defaults](#security-defaults)
43
+ - [defineApp options](#defineapp-options)
44
+ - [App reference](#app-reference)
45
+ - [Typed client](#typed-client)
46
+ - [OpenAPI](#openapi)
47
+ - [Testing](#testing)
48
+ - [CLI](#cli)
49
+ - [Templates](#templates)
50
+ - [Working with the ecosystem](#working-with-the-ecosystem)
51
+ - [Production checklist](#production-checklist)
52
+ - [Performance](#performance)
53
+ - [AI coding agents](#ai-coding-agents)
54
+ - [FAQ](#faq)
55
+ - [Contributing](#contributing)
56
+ - [License](#license)
57
+
58
+ ## Why brisk
59
+
60
+ Express is easy to read and easy to start with, but every project re-implements the same plumbing: validation middleware, `try/catch` with `next(err)`, DTO mapping, `req.user` assignment, unvalidated `process.env`, and no agreed place for code. NestJS answers that with modules, decorators, provider arrays, guards, pipes and interceptors, which is a lot to learn and to fight.
61
+
62
+ brisk sits in between:
63
+
64
+ - **Express mental model.** A controller is a function that declares routes. Middlewares are functions. Express is always reachable.
65
+ - **Zod first.** Schemas validate and type `params`, `query`, `body` and `headers`, and response schemas strip what clients must not see.
66
+ - **Typed middleware output.** A middleware that returns `{ user }` gives every route that uses it a typed `ctx.user`.
67
+ - **Dependency injection without decorators.** `readonly #repo = inject(UsersRepository)` in a class field. Services and repositories are plain classes.
68
+ - **Just throw.** Error classes carry status, code and headers, and every error reaches the client in one response shape.
69
+ - **Typed client and OpenAPI from the same routes.** Named routes become `api.users.get(...)` in tests and frontends, and an OpenAPI 3.1 document.
70
+ - **Production lifecycle.** Init, ready, drain and shutdown hooks on provider classes, graceful shutdown, crash handling.
71
+ - **No build step in development.** Node runs your TypeScript directly.
72
+ - **Mechanisms, not choices.** brisk provides routing, validation, dependency injection, lifecycle, errors, the typed client, OpenAPI, the test client, and a CLI that renders templates. The database, the auth scheme, lint and format tools, Docker, CI, folder conventions and pagination shape come from a [starter](#starters) and live in your project, where you can change them.
73
+
74
+ Six ideas, nothing else:
75
+
76
+ | Idea | You write | You get |
77
+ | ------------------------ | ------------------------------------------- | ------------------------------------------------------------------- |
78
+ | `defineEnv` | a Zod shape | typed, frozen config; the app refuses to boot with a bad `.env` |
79
+ | `defineController` | routes with Zod schemas | validated, typed `params` `query` `body` `headers`; response DTOs |
80
+ | plain classes | services and repositories | business logic with no HTTP in it |
81
+ | `inject` / `defineToken` | `readonly #repo = inject(UsersRepository)` | singletons, swappable in tests, no decorators or constructor wiring |
82
+ | `defineMiddleware` | a function returning `{ user }` | `ctx.user`, typed, in every route that uses it |
83
+ | error classes | `throw new NotFoundError("User not found")` | status, code, headers and one response shape; no try/catch |
84
+
85
+ ## Requirements
86
+
87
+ - Node.js 24 or later.
88
+ - npm, pnpm or yarn.
89
+ - TypeScript 5.8 or later (including 7) with `erasableSyntaxOnly`, so Node can run your `.ts` files directly.
90
+ - ES modules (`"type": "module"`).
91
+
92
+ ## Installation
93
+
94
+ ```bash
95
+ npm install @raiyyan/brisk
96
+ ```
97
+
98
+ ```bash
99
+ pnpm add @raiyyan/brisk
100
+ ```
101
+
102
+ Zod, Express and pino come with brisk. Import Zod from brisk (`import { z } from "@raiyyan/brisk"`) so your schemas and brisk use the same Zod. If your own code imports Express (`express.static`, `express.raw`, the `Request` and `Response` types), add it yourself: `pnpm add express` and `pnpm add -D @types/express` (pnpm does not let you import a dependency's dependencies). For colored development logs also install the optional `pino-pretty`.
103
+
104
+ Without a starter, also add `typescript`, `@types/node` and a test runner such as `vitest` as dev dependencies. `brisk init` adds what its starter needs.
105
+
106
+ ## Quick start
107
+
108
+ In an empty folder, with npm:
109
+
110
+ ```bash
111
+ npm init -y
112
+ npm install @raiyyan/brisk
113
+ npx brisk init
114
+ cp .env.example .env
115
+ npx brisk g feature products
116
+ npm run typecheck
117
+ npm test
118
+ npm run dev
119
+ ```
120
+
121
+ With pnpm:
122
+
123
+ ```bash
124
+ pnpm init
125
+ pnpm add @raiyyan/brisk
126
+ pnpm exec brisk init
127
+ cp .env.example .env
128
+ pnpm brisk g feature products
129
+ pnpm typecheck
130
+ pnpm test
131
+ pnpm dev
132
+ ```
133
+
134
+ The server listens on port 3000:
135
+
136
+ ```bash
137
+ curl -X POST localhost:3000/api/products -H 'content-type: application/json' -d '{"name":"Desk"}'
138
+ curl localhost:3000/api/products
139
+ curl -X POST localhost:3000/api/products -H 'content-type: application/json' -d '{"name":""}' # 400 with field issues
140
+ ```
141
+
142
+ `brisk init` copies the `minimal` [starter](#starters) into the folder, adds its scripts and dependencies to `package.json`, installs them and writes the guide for AI coding agents. It never overwrites a file, so it is safe in an existing project. `brisk g feature products` renders the project's own `templates/feature/` (see [Templates](#templates)).
143
+
144
+ ### Adding brisk to an existing Express project
145
+
146
+ Mount your existing Express router, and put its app-wide middlewares in `use`. Routers, middlewares and routes keep working unchanged, and new features are written as brisk controllers next to them:
147
+
148
+ ```ts
149
+ export const app = defineApp({
150
+ controllers,
151
+ use: [fromExpress(cors({ origin: env.WEB_ORIGIN }))],
152
+ mount: { "/": [express.json(), legacyRouter] }, // brisk does not parse mount bodies; your routes read req.body
153
+ });
154
+ ```
155
+
156
+ brisk's routes answer first, and the legacy router gets every request they did not match.
157
+
158
+ Then migrate route by route: write the new route in a controller, delete the old one.
159
+
160
+ ## Starters
161
+
162
+ A starter is a complete, tested app that makes the choices brisk leaves to you: storage, auth, tooling, Docker. `brisk init --starter <name>` copies one into your project; from then on the code is yours. `brisk init --starter list` lists them.
163
+
164
+ | Starter | What it brings |
165
+ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
166
+ | `minimal` | The app, env and test setup, and a feature template that stores records in memory. No database |
167
+ | `sqlite-auth` | Node's built-in `node:sqlite` with migrations applied at startup, accounts with scrypt password hashes and HS256 bearer tokens, an admin created from `ADMIN_EMAIL` / `ADMIN_PASSWORD`, CORS, rate limiting, static files, and a Dockerfile and `compose.yaml` that keep the database in a volume |
168
+
169
+ Both starters:
170
+
171
+ - ship oxlint and oxfmt, with `lint`, `format` and `format:check` scripts, and `dev`, `start`, `typecheck`, `test` and `brisk` scripts;
172
+ - require Node 24 or later (`engines`);
173
+ - include a `notes` feature generated from their own `templates/feature/`, as a working example; delete it when you no longer need it (its folder, its test files and its line in `src/features/index.ts`; in `sqlite-auth` also its migration, before anything ships);
174
+ - keep shared schema helpers in `src/schemas.ts`: `isoDate()` (a timestamp sent as an ISO string), `PageQuery` and `page(item)` (paged lists). These are starter code, not brisk exports: change the page shape there.
175
+
176
+ The sources are in [starters/](https://github.com/MohammadRaiyyan/brisk/tree/main/starters).
177
+
178
+ ## Project structure
179
+
180
+ The `minimal` starter, after `brisk g feature products`:
181
+
182
+ ```
183
+ src/
184
+ env.ts defineEnv: typed configuration
185
+ app.ts export const app = defineApp({ prefix: "/api", ... }); export type Api = typeof app
186
+ main.ts await app.listen(env.PORT)
187
+ schemas.ts isoDate(), PageQuery, page(item): shared schema helpers
188
+ middlewares/<name>.ts defineMiddleware (brisk g middleware)
189
+ features/
190
+ index.ts export const controllers = { products: productsController } (keep the @brisk markers)
191
+ products/
192
+ products.schema.ts Zod schemas: what clients send and may see
193
+ products.repository.ts data access only
194
+ products.service.ts business rules; throws error classes
195
+ products.controller.ts defineController: HTTP only, delegates to the service
196
+ templates/
197
+ feature/ what `brisk g feature <name>` renders
198
+ middleware/ what `brisk g middleware <name>` renders
199
+ test/
200
+ products.test.ts HTTP tests with createTestClient
201
+ products.service.test.ts service tests without HTTP
202
+ .env.example
203
+ .oxlintrc.json, .oxfmtrc.json lint and format settings (they ignore templates/)
204
+ tsconfig.json lets Node run .ts directly
205
+ vitest.config.ts test environment variables
206
+ AGENTS.md, CLAUDE.md guide for AI coding agents (brisk agents)
207
+ ```
208
+
209
+ `src/app.ts` declares the app and `src/main.ts` starts it. Declaring runs nothing, so tests, `brisk client` and `brisk openapi` import the same `app` without opening a port or a database:
210
+
211
+ ```ts
212
+ // src/app.ts
213
+ import { createLogger, defineApp } from "@raiyyan/brisk";
214
+ import { env } from "./env.ts";
215
+ import { controllers } from "./features/index.ts";
216
+
217
+ export const app = defineApp({
218
+ prefix: "/api",
219
+ controllers,
220
+ logger: createLogger({ level: env.NODE_ENV === "test" ? "silent" : env.LOG_LEVEL }),
221
+ openapi: { title: "API", version: "0.1.0" },
222
+ errors: { exposeInternal: env.NODE_ENV === "development" },
223
+ });
224
+
225
+ /** The API's type, for a frontend's typed client. */
226
+ export type Api = typeof app;
227
+ ```
228
+
229
+ ```ts
230
+ // src/main.ts
231
+ import { app } from "./app.ts";
232
+ import { env } from "./env.ts";
233
+
234
+ await app.listen(env.PORT);
235
+ ```
236
+
237
+ Each layer of a feature has one job:
238
+
239
+ - **schema**: Zod schemas for request bodies, params, queries and responses.
240
+ - **repository**: reads and writes data. No HTTP, no business rules.
241
+ - **service**: business rules. Takes and returns plain values, throws error classes (`NotFoundError`, `ConflictError`, ...). Knows nothing about Express.
242
+ - **controller**: maps HTTP to service calls. Reads top to bottom.
243
+
244
+ ```ts
245
+ // products.schema.ts
246
+ import { z } from "@raiyyan/brisk";
247
+
248
+ export const CreateProductBody = z.object({ name: z.string().trim().min(1) });
249
+ export const ProductIdParams = z.object({ id: z.uuid() });
250
+ export const ProductResponse = z.object({ id: z.string(), name: z.string() });
251
+ export type CreateProductBody = z.infer<typeof CreateProductBody>;
252
+ ```
253
+
254
+ ```ts
255
+ // products.repository.ts
256
+ export interface Product {
257
+ id: string;
258
+ name: string;
259
+ }
260
+
261
+ export class ProductsRepository {
262
+ readonly #rows = new Map<string, Product>();
263
+
264
+ async findById(id: string): Promise<Product | undefined> {
265
+ return this.#rows.get(id);
266
+ }
267
+
268
+ async insert(row: Product): Promise<Product> {
269
+ this.#rows.set(row.id, row);
270
+ return row;
271
+ }
272
+ }
273
+ ```
274
+
275
+ ```ts
276
+ // products.service.ts
277
+ import { randomUUID } from "node:crypto";
278
+ import { inject, NotFoundError } from "@raiyyan/brisk";
279
+ import { ProductsRepository, type Product } from "./products.repository.ts";
280
+ import type { CreateProductBody } from "./products.schema.ts";
281
+
282
+ export class ProductsService {
283
+ readonly #repository = inject(ProductsRepository);
284
+
285
+ async get(id: string): Promise<Product> {
286
+ const row = await this.#repository.findById(id);
287
+ if (!row) throw new NotFoundError("Product not found");
288
+ return row;
289
+ }
290
+
291
+ create(input: CreateProductBody): Promise<Product> {
292
+ return this.#repository.insert({ id: randomUUID(), ...input });
293
+ }
294
+ }
295
+ ```
296
+
297
+ ```ts
298
+ // products.controller.ts
299
+ import { defineController, inject } from "@raiyyan/brisk";
300
+ import { CreateProductBody, ProductIdParams, ProductResponse } from "./products.schema.ts";
301
+ import { ProductsService } from "./products.service.ts";
302
+
303
+ export const productsController = defineController({ prefix: "/products" }, (route) => {
304
+ const products = inject(ProductsService);
305
+
306
+ return {
307
+ get: route.get("/:id", { params: ProductIdParams, response: ProductResponse }, ({ params }) =>
308
+ products.get(params.id),
309
+ ),
310
+ create: route.post("/", { body: CreateProductBody, response: ProductResponse }, ({ body }) =>
311
+ products.create(body),
312
+ ),
313
+ };
314
+ });
315
+ ```
316
+
317
+ ## Configuration
318
+
319
+ ```ts
320
+ // src/env.ts
321
+ import { defineEnv, z } from "@raiyyan/brisk";
322
+
323
+ export const env = defineEnv({
324
+ NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
325
+ PORT: z.coerce.number().int().positive().default(3000),
326
+ DATABASE_URL: z.url(),
327
+ API_TOKEN: z.string().min(16),
328
+ });
329
+
330
+ env.PORT; // number
331
+ ```
332
+
333
+ `defineEnv(shape, options?)`:
334
+
335
+ - Loads `.env` into `process.env` when the file exists, then validates `process.env` against the shape.
336
+ - Returns a frozen, fully typed object.
337
+ - On failure it prints every invalid variable and exits with code 1, so the app never starts half-configured. Under `NODE_ENV=test` it throws instead.
338
+ - Environment values are strings: use `z.coerce.number()`, `z.stringbool()` or `z.enum([...])`. Never `z.coerce.boolean()`: it calls `Boolean(value)`, so `FEATURE_X=false` becomes `true`. `z.stringbool()` reads `true/false`, `1/0`, `yes/no` and `on/off`, and rejects anything else.
339
+
340
+ | Option | Type | Default | Description |
341
+ | -------- | ------------------------------------- | -------- | ------------------------------------------------------------- |
342
+ | `file` | `string` | `".env"` | Dotenv file to load when present |
343
+ | `source` | `Record<string, string \| undefined>` | none | Validate this object instead of `process.env`; failures throw |
344
+
345
+ ## Controllers and routes
346
+
347
+ ```ts
348
+ import { defineController, inject } from "@raiyyan/brisk";
349
+
350
+ export const ordersController = defineController({ prefix: "/orders", use: [authenticate] }, (route) => {
351
+ const orders = inject(OrdersService);
352
+
353
+ return {
354
+ list: route.get("/", { query: ListOrdersQuery, response: OrderResponse.array() }, ({ query, user }) =>
355
+ orders.list(user.id, query),
356
+ ),
357
+ get: route.get("/:id", ({ params }) => orders.get(params.id)), // params typed as { id: string }
358
+ create: route.post("/", { body: CreateOrderBody, response: OrderResponse }, ({ body, user }) =>
359
+ orders.create(user.id, body),
360
+ ),
361
+ admin: route.group("/admin", { use: [requireRole("admin")] }, (admin) => ({
362
+ remove: admin.delete("/:id", ({ params }) => orders.remove(params.id)),
363
+ })),
364
+ };
365
+ });
366
+ ```
367
+
368
+ `defineController(options, setup)`:
369
+
370
+ | Option | Type | Description |
371
+ | ----------- | ----------------------- | ---------------------------------------------------------------------------------------------------- |
372
+ | `prefix` | `string` | Mount path, joined to the app's `prefix` |
373
+ | `use` | `Middleware[]` | Middlewares for every route; their outputs are typed on each handler's ctx |
374
+ | `providers` | `(Provider \| Class)[]` | Providers the feature brings, such as its WebSocket gateway: created at startup with the app (below) |
375
+
376
+ A controller's `providers` are created at startup like the app's own, so removing the controller removes them. They come before the app's providers, so a value the app or a test provides for the same token wins.
377
+
378
+ `setup` runs once per app instance, when the instance is built (`listen`, `start`, `create` or `createTestClient`), and can call `inject()`. It returns the controller's routes as an object of names. The names are how the [typed client](#typed-client) calls the routes (`api.orders.create(...)`, `api.orders.admin.remove(...)`) and become OpenAPI operation ids (`orders.create`).
379
+
380
+ - Route methods (`route.get`, `route.post`, `route.put`, `route.patch`, `route.delete`) take `(path, handler)` or `(path, options, handler)` and return the route.
381
+ - `route.group(path, setup)` or `route.group(path, { use }, setup)` nests routes under a sub-path with extra middlewares, and returns what its `setup` returns, so a group's routes nest under the group's name. Groups can nest.
382
+ - When `setup` returns an object, it must include every route it declares; a missing one is an error naming the route, thrown when an instance is built.
383
+ - A `setup` that returns nothing still serves its routes, but the controller is absent from the typed client and its routes have no operation id.
384
+
385
+ | Route option | Type | Description |
386
+ | ------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------ |
387
+ | `params` | Zod schema | Validates path params; the parsed value is `ctx.params` |
388
+ | `query` | Zod schema | Validates the query string; `ctx.query` |
389
+ | `body` | Zod schema | Validates the request body; `ctx.body` |
390
+ | `headers` | Zod schema | Validates request headers (lower-case names); `ctx.headers` |
391
+ | `response` | Zod schema | The handler's result is parsed through it: its return type is checked and unlisted fields are stripped |
392
+ | `use` | `Middleware[]` | Route middlewares, run after the controller's and the group's |
393
+ | `status` | 2xx or 3xx | Success status; anything else is a type error, and an error when an instance is built. Throw an error class for failures |
394
+
395
+ Without a `params` schema, path params are typed from the full path, controller prefix and groups included:
396
+
397
+ | Path | `ctx.params` |
398
+ | --------------------------------------- | ------------------------------- |
399
+ | `"/members/:id"` under `"/orgs/:orgId"` | `{ orgId: string; id: string }` |
400
+ | `"/files/*path"` | `{ path: string[] }` |
401
+ | `"/posts{/:slug}"` | `{ slug?: string }` |
402
+
403
+ Express matches routes in the order they are declared, across all controllers in the order they are listed. Declare wildcard and optional-segment routes after the more specific routes they would otherwise catch. Two routes with the same method and path (paths that differ only in parameter names, such as `/:id` and `/:userId`, count as the same) are an error naming both, thrown when an instance is built.
404
+
405
+ ### What a handler returns
406
+
407
+ The return value is the response:
408
+
409
+ | Return value | Response |
410
+ | ------------------------- | ---------------------------------------------------------------- |
411
+ | `undefined` (or nothing) | `204 No Content` (or `status`) |
412
+ | a Node `Readable` | streamed to the client |
413
+ | a `Buffer` / `Uint8Array` | sent as bytes (`application/octet-stream` unless you set a type) |
414
+ | anything else | JSON, parsed through `response` when set |
415
+
416
+ Success status defaults to `201` for POST and `200` otherwise; `ctx.res.status(code)` in the handler overrides the default (for 202, or 200 vs 201 on an upsert), and the route's `status` option overrides both. Handlers can also write to `ctx.res` directly (files, SSE, redirects); once the response has started, brisk sends nothing more.
417
+
418
+ If a stream fails, or a handler throws after it started writing, brisk destroys the connection instead of ending the response, so the client sees a failed download rather than a truncated file that looks complete. A client that disconnects mid-stream is not a server error: it is logged on the request line with `aborted: true`.
419
+
420
+ ```ts
421
+ import { createReadStream } from "node:fs";
422
+
423
+ return {
424
+ report: route.get("/report.csv", ({ res }) => {
425
+ res.type("text/csv").attachment("report.csv");
426
+ return createReadStream("report.csv");
427
+ }),
428
+ logo: route.get("/logo", ({ res }) => res.sendFile("/srv/assets/logo.png")),
429
+ };
430
+ ```
431
+
432
+ Handlers and setup may be sync or async. A thrown error, sync or async, becomes the error response; never call `next()`.
433
+
434
+ ## Validation
435
+
436
+ Every input schema is a Zod schema. Invalid input stops the request before your handler runs and produces a `400`:
437
+
438
+ ```json
439
+ {
440
+ "error": {
441
+ "code": "VALIDATION_FAILED",
442
+ "message": "Invalid request body",
443
+ "details": {
444
+ "location": "body",
445
+ "issues": [{ "path": "email", "message": "Invalid email address", "code": "invalid_format" }]
446
+ },
447
+ "requestId": "3f0c9a1e-..."
448
+ }
449
+ }
450
+ ```
451
+
452
+ - `location` is `params`, `query`, `body` or `headers`. `path` is a dot path (`items.0.qty`), ready to map onto form fields.
453
+ - Path params and query values arrive as strings: use `z.coerce.number()`, `z.stringbool()` (not `z.coerce.boolean()`, which turns `"false"` into `true`) or `z.enum([...])`, and `.default(...)` for optional query values.
454
+ - A repeated query key (`?tag=a&tag=b`) arrives as an array, a single one as a string. Accept both with `z.union([z.string(), z.array(z.string())]).transform((v) => [v].flat())`.
455
+ - Async refinements work (`z.string().refine(async (email) => !(await users.exists(email)))`); schemas without them stay on the synchronous fast path.
456
+ - JSON bodies are parsed for `application/json` and every `application/*+json` type, such as `application/merge-patch+json`.
457
+ - Unknown keys in a body are stripped, so extra fields never reach your code. The same applies to `params`: a `params` schema on a route under `/:orgId` must list `orgId` too, or it is stripped.
458
+ - `UpdateBody = CreateBody.partial()` keeps the create schema's `.default(...)` values, so a PATCH that omits a field resets it to the default. Build update schemas without defaults.
459
+ - For a dynamic success status, call `ctx.res.status(202)` in the handler. A route's `status` option fixes the status and wins over it.
460
+ - A `ZodError` thrown anywhere in your code (for example a service calling `schema.parse(...)`) becomes the same 400 with `location: "input"`.
461
+ - A handler result that does not match its `response` schema is a server bug and becomes a 500, never a 400.
462
+
463
+ ```ts
464
+ export const ListOrdersQuery = z.object({
465
+ status: z.enum(["open", "paid"]).optional(),
466
+ page: z.coerce.number().int().min(1).default(1),
467
+ limit: z.coerce.number().int().min(1).max(100).default(20),
468
+ });
469
+ ```
470
+
471
+ ## Middleware
472
+
473
+ ```ts
474
+ import {
475
+ defineMiddleware,
476
+ ForbiddenError,
477
+ inject,
478
+ UnauthorizedError,
479
+ type SecuritySchemes,
480
+ } from "@raiyyan/brisk";
481
+
482
+ const bearer = { bearer: { type: "http", scheme: "bearer", bearerFormat: "JWT" } } satisfies SecuritySchemes;
483
+
484
+ export const authenticate = defineMiddleware({ security: bearer }, async ({ req, log }) => {
485
+ const user = await inject(Sessions).verify(req.get("authorization"));
486
+ if (!user) throw new UnauthorizedError("Invalid token", { challenge: "Bearer" });
487
+ return { user, log: log.child({ userId: user.id }) };
488
+ });
489
+
490
+ export const requireRole = (role: "admin" | "member") =>
491
+ defineMiddleware({ security: bearer }, async (ctx) => {
492
+ const { user } = await authenticate.run(ctx);
493
+ if (user.role !== role) throw new ForbiddenError(`Requires role: ${role}`);
494
+ return { user };
495
+ });
496
+ ```
497
+
498
+ `defineMiddleware(fn)` or `defineMiddleware(options, fn)`:
499
+
500
+ - **Return an object** to add typed fields to the handler's ctx (`return { user }` gives `ctx.user`).
501
+ - **Return nothing** for a pure check.
502
+ - **Throw an error class** to stop the request.
503
+ - **Each middleware runs at most once per request.** `other.run(ctx)` reuses another middleware's result, so `requireRole` above does not authenticate twice. A handler can call `run(ctx)` too.
504
+ - **Reserved names:** a middleware may not return `req`, `res`, `requestId`, `params`, `query`, `body` or `headers`; the compiler rejects them, because validated input would silently replace them. Returning `log` (such as `log.child({ userId })`) replaces the request logger for the rest of the request.
505
+ - **Sync or async**, and `inject()` works inside.
506
+ - Order: controller `use`, then group `use`, then route `use`, then the route's validation. Unauthenticated requests are rejected before their input is validated. The JSON body is parsed earlier, for every request (up to `http.bodyLimit`), so malformed JSON gets 400 even without credentials.
507
+
508
+ | Option | Type | Description |
509
+ | ---------- | ----------------- | ------------------------------------------------------------------------------------------------------ |
510
+ | `params` | Zod schema | Path params the middleware reads: validated before it runs (400 `VALIDATION_FAILED`), typed on its ctx |
511
+ | `query` | Zod schema | Query fields the middleware reads, validated and typed the same way |
512
+ | `security` | `SecuritySchemes` | The OpenAPI security schemes it enforces, such as `{ bearer: { type: "http", scheme: "bearer" } }` |
513
+
514
+ ### Middleware that needs input
515
+
516
+ A permission check often depends on the request, such as the project a route belongs to. Declare what the middleware reads, and it gets validated, typed input before it runs; async refinements work:
517
+
518
+ ```ts
519
+ export const requireProjectRole = (role: ProjectRole) =>
520
+ defineMiddleware({ params: z.object({ projectId: z.uuid() }), security: bearer }, async (ctx) => {
521
+ const { user } = await authenticate.run(ctx);
522
+ const projectRole = await inject(ProjectAccess).require(user.id, ctx.params.projectId, role);
523
+ return { projectRole };
524
+ });
525
+ ```
526
+
527
+ `ProjectAccess` is a plain service, so the WebSocket gateway and GraphQL resolvers use the same rules ([shared access rules](#shared-access-rules)).
528
+
529
+ ### Security schemes
530
+
531
+ Every route behind a middleware with `security` lists those schemes under `security` in the [OpenAPI](#openapi) document, and the document lists them under `components.securitySchemes`. Routes without such a middleware stay public. `middleware.security` reads the schemes back.
532
+
533
+ ### Express middleware on one route
534
+
535
+ App-wide Express middleware (`cors()`, `helmet()`, rate limiters) goes in the app's [`use`](#use-mount-and-a-routes-use). For Express middleware that only some routes need, such as multer, wrap it with `fromExpress` and put it in `use`; it runs in order with the others, so it runs after authentication:
536
+
537
+ ```ts
538
+ import { fromExpress } from "@raiyyan/brisk";
539
+ import multer from "multer";
540
+
541
+ const upload = fromExpress(multer({ dest: "uploads/", limits: { fileSize: 5_000_000 } }).single("file"));
542
+
543
+ return {
544
+ attach: route.post("/:taskId/attachments", { use: [authenticate, upload] }, ({ req, params }) =>
545
+ attachments.save(params.taskId, req.file),
546
+ ),
547
+ };
548
+ ```
549
+
550
+ - An error the Express middleware passes to `next` goes to the app's error handler: an `http-errors` `status` and `expose` are honored, and `errors.mappers` apply.
551
+ - If the Express middleware ends the response itself, the route stops there.
552
+ - The route path is declared once, in the controller.
553
+
554
+ ## Dependency injection
555
+
556
+ ```ts
557
+ import { defineApp, defineToken, inject, injectLazy, provide } from "@raiyyan/brisk";
558
+
559
+ export const Clock = defineToken<() => Date>("Clock", () => () => new Date());
560
+
561
+ export class UsersRepository {
562
+ readonly #db = inject(Database);
563
+ }
564
+
565
+ export class UsersService {
566
+ readonly #users = inject(UsersRepository);
567
+ readonly #mailer = injectLazy(Mailer); // created on first call: this.#mailer().send(...)
568
+ readonly #now = inject(Clock);
569
+ }
570
+
571
+ export const app = defineApp({ controllers, providers: [provide(Clock, () => new Date("2026-01-01"))] });
572
+ ```
573
+
574
+ - **A class is its own token.** It needs a no-argument constructor, takes its dependencies with `inject()` in field initializers, and is created once per app, on first use.
575
+ - **`defineToken<T>(name, factory?)`** names anything that is not a class: config, clients, functions, interface implementations. Without a factory it must be given a value with `provide`.
576
+ - **`provide(token, value)`** fixes a value for a token in `defineApp({ providers })`, in `defineController({ providers })`, in the `providers` of `listen`, `start`, `create` or `createTestClient`, or in `new Container([...])`. Later entries win. The order is: controllers' providers, then the app's, then those given to `listen`, `start`, `create` or `createTestClient`. Those last ones are added to the app's own, not swapped in for them: a value for the same token wins, and every listed class is still created.
577
+ - **Classes listed in `providers`** are created at boot even when nothing injects them, so WebSocket gateways, consumers and jobs get their lifecycle hooks. Listing a class is not required to make it injectable (every class is); it means "start this at boot".
578
+ - **`injectLazy(token)`** returns `() => T` and creates the dependency on first call: for heavy clients, or to break a circular dependency.
579
+ - **Where `inject()` works:** wherever the app runs your code: class fields, token factories, controller setup, every request (brisk routes, middlewares, the app's `use` and every `mount`, so GraphQL resolvers and tRPC procedures too), lifecycle hooks, and timers and promises started from any of those.
580
+ - **Where it does not:** callbacks a library fires from its own events, outside any request (a gRPC handler, a queue client's message callback, a `ws` message listener). Resolve the dependency in a class field and use the field there. Outside the app entirely (a script, a REPL), use the instance's container: `instance.container.get(...)`. brisk never guesses which app you mean, so code behaves the same with one app in the process or several.
581
+ - **`HttpServer`** exists only once the instance listens: inject it in `onReady`. Injecting it in a class field or `onInit` throws.
582
+ - **Circular dependencies** are reported as `A -> B -> A`. Break them with `injectLazy`.
583
+ - **Unit tests without HTTP:** build a container and call `get`. The `Logger` token has a silent default, so services that inject it need no logger provider there:
584
+
585
+ ```ts
586
+ import { Container, provide } from "@raiyyan/brisk";
587
+
588
+ const container = new Container([provide(Clock, () => new Date("2030-01-01"))]);
589
+ const users = container.get(UsersService);
590
+ ```
591
+
592
+ ## Lifecycle
593
+
594
+ A provider class joins the lifecycle by implementing any of these interfaces. Each method may be sync or async, and `inject()` works inside.
595
+
596
+ | Interface | Method | When |
597
+ | ------------ | ------------ | -------------------------------------------------------------------------------------------------------- |
598
+ | `OnInit` | `onInit` | Before the server listens, dependencies first: connect databases, open pools |
599
+ | `OnReady` | `onReady` | Once the server accepts requests: attach WebSockets (inject `HttpServer` here), start jobs and consumers |
600
+ | `OnDrain` | `onDrain` | The moment shutdown starts: close WebSockets and SSE, stop consumers and timers |
601
+ | `OnShutdown` | `onShutdown` | After the last in-flight request, dependents first: release clients and pools |
602
+
603
+ ```ts
604
+ import { inject, Logger, type OnInit, type OnShutdown } from "@raiyyan/brisk";
605
+
606
+ export class Database implements OnInit, OnShutdown {
607
+ readonly #log = inject(Logger);
608
+ #pool: Pool | undefined;
609
+
610
+ async onInit() {
611
+ this.#pool = await connect(env.DATABASE_URL);
612
+ this.#log.info("database connected");
613
+ }
614
+
615
+ async onShutdown() {
616
+ await this.#pool?.end();
617
+ }
618
+ }
619
+ ```
620
+
621
+ Startup and shutdown work always lives in a provider class. `defineApp({ hooks })` takes only per-request hooks: `onResponse(ctx, { status, durationMs, aborted })` and `onError(error, ctx)`. Logic that can reject a request is a middleware in the app's `use`. `onResponse` runs once for every request, including ones the client abandoned (`aborted: true`). `onError` sees client errors too; an error tracker usually wants `if (error instanceof HttpError && error.status < 500) return;`.
622
+
623
+ ```
624
+ boot providers' onInit (dependencies first) -> listen -> providers' onReady
625
+ request security headers -> request id -> /health and /openapi.json -> app use
626
+ -> brisk route: JSON body -> middlewares -> validation -> handler -> response
627
+ -> 405 / OPTIONS -> mount -> 404
628
+ (any error -> error response -> hooks.onError; hooks.onResponse when the response closes)
629
+ shutdown SIGINT/SIGTERM -> /health answers 503 -> stop accepting connections -> providers' onDrain (reverse)
630
+ -> in-flight requests finish, with Connection: close (sockets still open after shutdownTimeoutMs are destroyed)
631
+ -> providers' onShutdown (reverse) -> exit 0
632
+ (a different second signal is ignored; the same signal again exits 1 at once;
633
+ still running shutdownTimeoutMs + 10 s after the signal -> exit 1)
634
+ crash unhandled rejection or uncaught exception -> logged as fatal -> shutdown -> exit 1
635
+ ```
636
+
637
+ A failing `onDrain` or `onShutdown` is logged and shutdown continues. `app.start()` runs the same lifecycle without an HTTP server.
638
+
639
+ Each `listen`, `start` or `create` builds a new, independent instance with its own container, so one app can run in many tests at once.
640
+
641
+ If startup fails (an `onInit` or `onReady` throws, the port is taken), brisk logs it at `fatal` as `startup failed`, with `provider` and `hook` fields naming the class and hook that threw. It then runs the drain and shutdown hooks for what already started, so pools and clients are released, and rethrows the error.
642
+
643
+ ## Errors
644
+
645
+ Throw from anywhere: handlers, services, repositories, middlewares. Never catch an error just to build a response.
646
+
647
+ ```ts
648
+ throw new NotFoundError("User not found");
649
+ throw new ConflictError("Email already registered", { code: "EMAIL_TAKEN", details: { email } });
650
+ throw new TooManyRequestsError("Slow down", { retryAfter: 30 }); // sends Retry-After: 30
651
+ throw new UnauthorizedError("Login required", { challenge: "Bearer" }); // sends WWW-Authenticate
652
+ throw new BadGatewayError("Payment provider failed", { cause: error }); // cause is logged, never sent
653
+ ```
654
+
655
+ | Class | Status | Notes |
656
+ | --------------------------- | ------ | ---------------------------------------------------------------- |
657
+ | `BadRequestError` | 400 | |
658
+ | `ValidationError` | 400 | `(location, issues)`; `ValidationError.fromZod(error, location)` |
659
+ | `UnauthorizedError` | 401 | `challenge` option sends `WWW-Authenticate` |
660
+ | `ForbiddenError` | 403 | |
661
+ | `NotFoundError` | 404 | |
662
+ | `MethodNotAllowedError` | 405 | `(allowed, message?, options?)` sends `Allow` |
663
+ | `NotAcceptableError` | 406 | |
664
+ | `RequestTimeoutError` | 408 | |
665
+ | `ConflictError` | 409 | |
666
+ | `GoneError` | 410 | |
667
+ | `PreconditionFailedError` | 412 | a stale `If-Match` in optimistic concurrency |
668
+ | `PayloadTooLargeError` | 413 | |
669
+ | `UnsupportedMediaTypeError` | 415 | |
670
+ | `UnprocessableEntityError` | 422 | |
671
+ | `TooManyRequestsError` | 429 | `retryAfter` option (seconds) sends `Retry-After` |
672
+ | `InternalServerError` | 500 | its message is sent: keep it generic, put details in `cause` |
673
+ | `NotImplementedError` | 501 | |
674
+ | `BadGatewayError` | 502 | an upstream service answered badly |
675
+ | `ServiceUnavailableError` | 503 | `retryAfter` option sends `Retry-After` |
676
+ | `GatewayTimeoutError` | 504 | an upstream service did not answer in time |
677
+ | `HttpError` | any | the base class: `new HttpError(status, message, options)` |
678
+
679
+ Every class takes `(message?, options?)`:
680
+
681
+ | Option | Description |
682
+ | --------- | --------------------------------------------------------------------- |
683
+ | `code` | Machine-readable code. Default: derived from the status (`NOT_FOUND`) |
684
+ | `details` | Extra data sent to the client as `error.details` |
685
+ | `cause` | The underlying error: logged with its stack, never sent |
686
+ | `headers` | Response headers sent with the error |
687
+
688
+ Every error response has one shape: `{ "error": { "code", "message", "details", "requestId" } }`.
689
+
690
+ ### Domain errors
691
+
692
+ ```ts
693
+ import { ConflictError, ValidationError } from "@raiyyan/brisk";
694
+
695
+ export class EmailTakenError extends ConflictError {
696
+ constructor(email: string) {
697
+ super("Email already registered", { code: "EMAIL_TAKEN", details: { email } });
698
+ }
699
+ }
700
+
701
+ throw new ValidationError("body", [{ path: "slug", message: "Already used", code: "taken" }]);
702
+ ```
703
+
704
+ ### Handled automatically
705
+
706
+ | Situation | Response |
707
+ | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
708
+ | Invalid params, query, body or headers | 400 `VALIDATION_FAILED` with field issues |
709
+ | `ZodError` thrown in your code | 400 `VALIDATION_FAILED`, `location: "input"` |
710
+ | Malformed JSON | 400 `INVALID_JSON` |
711
+ | Body over `http.bodyLimit` | 413 `PAYLOAD_TOO_LARGE` |
712
+ | Unsupported body charset or encoding | 415 `UNSUPPORTED_MEDIA_TYPE` |
713
+ | A body in a format nothing parsed, on a route with a `body` schema | 415 `UNSUPPORTED_MEDIA_TYPE` (after the route's middlewares, so auth fails first) |
714
+ | An Express middleware error following `http-errors` (`status` + `expose`) | that status; the message only when `expose` is true |
715
+ | Known path, wrong method (across all controllers) | 405 with `Allow`; `OPTIONS` answers 204 with `Allow` |
716
+ | Unknown path | 404 `ROUTE_NOT_FOUND` |
717
+ | Handler result breaks its `response` schema | 500: a server bug |
718
+ | Error after the response started (a failing stream) | connection destroyed, so the client never mistakes it for a complete body |
719
+ | Anything else thrown | 500 `INTERNAL_SERVER_ERROR` with a generic message; full error logged |
720
+ | Unhandled rejection or uncaught exception | logged as fatal, graceful shutdown, exit 1 |
721
+
722
+ 5xx errors are logged at `error` with stack and cause; 4xx at `debug` (the request log line already carries the status).
723
+
724
+ ### Error mappers and formatters
725
+
726
+ ```ts
727
+ export const app = defineApp({
728
+ controllers,
729
+ errors: {
730
+ // Translate library errors in one place instead of try/catch in services. The first match wins.
731
+ mappers: [
732
+ (error) =>
733
+ error instanceof DatabaseError && error.code === "23505"
734
+ ? new ConflictError("Already exists")
735
+ : undefined,
736
+ ],
737
+ // Change the error body, for example to RFC 9457 problem details.
738
+ format: (error, ctx) => ({
739
+ type: `https://errors.example.com/${error.code}`,
740
+ title: error.message,
741
+ status: error.status,
742
+ instance: ctx.req.originalUrl,
743
+ }),
744
+ },
745
+ hooks: { onError: (error, ctx) => reportToTracker(error, ctx.requestId) },
746
+ });
747
+ ```
748
+
749
+ `defaultErrorFormatter` is exported for formatters that only add fields. A mapper or `onError` hook that throws is logged and never breaks the response.
750
+
751
+ ORMs often wrap the driver's error (Drizzle keeps it in `cause`), so a mapper should walk the `cause` chain. This one turns a Postgres unique violation into 409 `ALREADY_EXISTS`; the `sqlite-auth` starter has the SQLite version in `src/database/errors.ts`:
752
+
753
+ ```ts
754
+ import { ConflictError, type ErrorMapper } from "@raiyyan/brisk";
755
+
756
+ export const uniqueViolation: ErrorMapper = (error) => {
757
+ for (let current: unknown = error; current instanceof Error; current = current.cause) {
758
+ if ("code" in current && current.code === "23505") {
759
+ return new ConflictError("Already exists", { code: "ALREADY_EXISTS", cause: error });
760
+ }
761
+ }
762
+ return undefined;
763
+ };
764
+ ```
765
+
766
+ ### Errors outside brisk's routes
767
+
768
+ Code that answers outside a brisk route, such as a GraphQL error mask or a WebSocket close, can translate an error the way a route would with the `ErrorTranslator` token. It returns `{ status, code, message, details }` with `errors.mappers` applied, and hides the message of unexpected errors unless `errors.exposeInternal` is on:
769
+
770
+ ```ts
771
+ import { ErrorTranslator, inject, NotFoundError } from "@raiyyan/brisk";
772
+
773
+ const translate = inject(ErrorTranslator); // inside the app: a request, a class field, a lifecycle hook
774
+ translate(new NotFoundError("Project not found")); // { status: 404, code: "NOT_FOUND", message: "Project not found", details: undefined }
775
+ translate(new Error("password=hunter2")); // { status: 500, code: "INTERNAL_SERVER_ERROR", message: "Internal server error", ... }
776
+ ```
777
+
778
+ Inside a request (a mount, a GraphQL resolver or `maskError`) call `inject(ErrorTranslator)` directly. In callbacks a library fires on its own, such as a `ws` message listener, resolve it in a class field. Outside the app, use `instance.container.get(ErrorTranslator)`.
779
+
780
+ ## The request context
781
+
782
+ Every handler and middleware receives a context:
783
+
784
+ | Field | Description |
785
+ | ----------- | ----------------------------------------------------------------------------------------- |
786
+ | `req` | The Express request |
787
+ | `res` | The Express response; write to it directly to stream or send files |
788
+ | `requestId` | The caller's `x-request-id` when it is safe, otherwise a new UUID; echoed in the response |
789
+ | `log` | A logger for this request: every line carries the request id |
790
+
791
+ Handlers also get every middleware's output and the validated `params`, `query`, `body` and `headers`. Destructure what you need (`({ params, user, log }) => ...`) rather than copying the whole context.
792
+
793
+ Code that only has the Express `req` (Express middlewares and mounts) uses `getContext(req)`:
794
+
795
+ ```ts
796
+ import { getContext } from "@raiyyan/brisk";
797
+
798
+ app.get("/events", (req, res) => {
799
+ getContext(req).log.info("stream opened");
800
+ });
801
+ ```
802
+
803
+ ## Logging
804
+
805
+ ```ts
806
+ import { createLogger, defineApp } from "@raiyyan/brisk";
807
+
808
+ export const app = defineApp({
809
+ controllers,
810
+ logger: createLogger({ level: env.LOG_LEVEL, pretty: env.NODE_ENV === "development" }),
811
+ });
812
+ ```
813
+
814
+ - `createLogger({ level?, pretty? })` returns a [pino](https://getpino.io) logger. `level` defaults to `"info"`. `pretty: true` needs the optional `pino-pretty` package; use JSON lines in production.
815
+ - Pass any pino logger as `logger`.
816
+ - The app logger is injectable: `readonly #log = inject(Logger)`.
817
+ - `ctx.log` carries the request id on every line.
818
+ - Each request is logged once at `info` as `request` with `method`, `url`, `status` and `durationMs` (plus `aborted: true` when the client left early). Set the level to `warn` or higher to turn it off.
819
+ - `url` is the path without the query string: query strings carry reset tokens, OAuth codes and API keys often enough that brisk never logs them. Log what you need from `ctx.req.query` in `onResponse`.
820
+
821
+ ## Security defaults
822
+
823
+ - **Security headers** on every response: `x-content-type-options: nosniff`, `x-frame-options: DENY`, `referrer-policy: no-referrer`, `cross-origin-resource-policy: same-origin`, `content-security-policy: default-src 'none'; frame-ancestors 'none'`. No `x-powered-by`.
824
+ - **Override them** with `http.securityHeaders`: `false` turns them off; an object overrides individual headers, and `false` as a value removes one:
825
+
826
+ ```ts
827
+ export const app = defineApp({
828
+ controllers,
829
+ http: { securityHeaders: { "content-security-policy": "default-src 'self'", "x-frame-options": false } },
830
+ });
831
+ ```
832
+
833
+ - **Request ids** from callers are accepted only when short and plain (letters, digits, `_ . : -`, up to 128 characters), so they cannot forge log lines.
834
+ - **Body limit** of `1mb` for JSON (`http.bodyLimit`).
835
+ - **Flat query strings:** query values are strings or arrays of strings, never nested objects. If you need nested query objects, enable them with `http: { settings: { "query parser": "extended" } }`.
836
+ - **No mass assignment:** unknown body fields are stripped by your schemas.
837
+ - **`trust proxy`** stays off unless you set it in `http.settings` (`{ "trust proxy": 1 }`). Set it only behind a proxy you control, otherwise clients can spoof `req.ip`.
838
+ - **Internal error messages** never reach clients unless `errors.exposeInternal` is on (development only).
839
+ - CORS and rate limiting are plain Express middlewares in the app's `use`.
840
+
841
+ ## defineApp options
842
+
843
+ `defineApp(options)` declares the app. Every option is optional.
844
+
845
+ | Option | Type | Default | Description |
846
+ | ------------- | ------------------------------------------------ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
847
+ | `prefix` | `string` | `"/"` | Path every controller is mounted under, such as `/api` |
848
+ | `controllers` | `{ [name]: Controller }` | `{}` | Controllers by name; the names are the typed client's top level |
849
+ | `providers` | `(Provider \| Class)[]` | `[]` | `provide(token, value)` entries, and classes to create at startup |
850
+ | `logger` | `Logger` | `createLogger()` | The app logger; injectable as `Logger` |
851
+ | `use` | `Middleware[]` | `[]` | Middlewares for every request after `/health` and `/openapi.json`, before brisk's routes and mounts ([use and mount](#use-mount-and-a-routes-use)) |
852
+ | `mount` | `{ [path]: RequestHandler \| RequestHandler[] }` | `{}` | Express handlers that answer what brisk's routes did not: static files, GraphQL, webhooks |
853
+ | `hooks` | `AppHooks` | `{}` | `onResponse`, `onError` (startup and shutdown belong in providers) |
854
+ | `openapi` | `OpenApiOptions` | not served | Serve an [OpenAPI](#openapi) document |
855
+ | `errors` | `ErrorOptions` | `{}` | How errors become responses (below) |
856
+ | `http` | `HttpOptions` | `{}` | HTTP-level settings (below) |
857
+
858
+ `errors`:
859
+
860
+ | Option | Type | Default | Description |
861
+ | ---------------- | ---------------- | ----------------------- | ------------------------------------------------------------ |
862
+ | `mappers` | `ErrorMapper[]` | `[]` | `(error) => HttpError \| undefined`; the first match wins |
863
+ | `format` | `ErrorFormatter` | `defaultErrorFormatter` | `(error, ctx) => body` for error responses |
864
+ | `exposeInternal` | `boolean` | `false` | Send the real message of unexpected errors. Development only |
865
+
866
+ `http`:
867
+
868
+ | Option | Type | Default | Description |
869
+ | ------------------- | -------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
870
+ | `bodyLimit` | `string` | `"1mb"` | JSON body size limit |
871
+ | `settings` | `Record<string, unknown>` | `{}` | Express app settings, applied with `app.set(name, value)` after brisk's defaults: `{ "trust proxy": 1 }`, `{ etag: false }`, `{ "query parser": "extended" }` |
872
+ | `securityHeaders` | `boolean \| Record<string, string \| false>` | `true` | Strict API headers; an object overrides individual headers, `false` removes one |
873
+ | `healthPath` | `string \| false` | `"/health"` | Health route, outside the prefix: `{ "status": "ok" }`, or 503 `{ "status": "shutting down" }` once shutdown begins; `false` disables it |
874
+ | `shutdownTimeoutMs` | `number` | `10000` | How long shutdown waits for in-flight requests before closing remaining connections |
875
+
876
+ ## App reference
877
+
878
+ `defineApp` returns an `App`. It runs nothing until one of these builds an instance:
879
+
880
+ | Member | Description |
881
+ | ------------------------ | ---------------------------------------------------------------------------------------------- |
882
+ | `listen(port, options?)` | Builds an instance, runs `onInit`, listens, runs `onReady`; resolves with the instance |
883
+ | `start(options?)` | The same without an HTTP server, for workers and schedulers |
884
+ | `create(options?)` | Builds an instance without starting it, for platforms that bring their own server (serverless) |
885
+ | `manifest` | The named routes as data, for the [typed client](#typed-client) (`brisk client` writes it) |
886
+ | `openapi()` | The [OpenAPI](#openapi) document (`brisk openapi` writes it) |
887
+
888
+ Each call builds a new, independent instance with its own container. Configuration errors (a controller that leaves out a declared route, an invalid `status`) are thrown then, not at `defineApp`.
889
+
890
+ | Option | On | Default | Description |
891
+ | --------------- | --------------------------- | ----------------- | ---------------------------------------------------------------------------------------- |
892
+ | `providers` | `listen`, `start`, `create` | `[]` | Added after the app's own providers: a value for the same token wins, typically a fake |
893
+ | `manageProcess` | `listen`, `start` | `true` | SIGINT/SIGTERM shut down gracefully and exit 0; crashes are logged, shut down and exit 1 |
894
+ | `host` | `listen` | all interfaces | Interface to bind, such as `0.0.0.0` in a container or `127.0.0.1` for local only |
895
+ | `server` | `listen` | a new HTTP server | Your own `http.Server` or `https.Server`; brisk serves its requests |
896
+
897
+ The instance (`AppInstance`):
898
+
899
+ | Member | Description |
900
+ | ------------ | ------------------------------------------------------------------------------------ |
901
+ | `express` | The Express app |
902
+ | `server` | The listening Node server, or `undefined` when the instance did not listen |
903
+ | `container` | The dependency container: `instance.container.get(UsersService)` |
904
+ | `logger` | The app logger |
905
+ | `manifest` | The named routes as data |
906
+ | `openapi()` | The OpenAPI document |
907
+ | `init()` | Runs the `onInit` hooks once; `listen` and `start` call it. Call it after `create()` |
908
+ | `shutdown()` | Graceful shutdown; safe to call more than once |
909
+
910
+ ```ts
911
+ const instance = await app.listen(env.PORT);
912
+ instance.server?.keepAliveTimeout; // the Node server
913
+ await instance.shutdown();
914
+ ```
915
+
916
+ ## Typed client
917
+
918
+ `@raiyyan/brisk/client` calls an app's named routes, typed from their schemas. It imports no server code, so it is safe in a browser bundle.
919
+
920
+ ```ts
921
+ import { ApiError, createClient } from "@raiyyan/brisk/client";
922
+ ```
923
+
924
+ `createClient` takes the app itself, or a route manifest plus the app's type:
925
+
926
+ | Form | Where |
927
+ | -------------------------------------- | ---------------------------------------------------------------------------------------------- |
928
+ | `createClient(app, options)` | Node: tests, scripts, server-to-server calls. `app` is the definition or a running instance |
929
+ | `createClient<Api>(manifest, options)` | Browsers: `manifest` is generated by `brisk client`, and `Api` is `typeof app` (`import type`) |
930
+
931
+ The manifest is the routes as data (`{ users: { get: ["GET", "/api/users/:id"] } }`). Generate it into the frontend and run the command again after changing routes:
932
+
933
+ ```bash
934
+ npx brisk client --out web/src/api.manifest.ts
935
+ ```
936
+
937
+ ```ts
938
+ // web/src/api.ts
939
+ import { createClient } from "@raiyyan/brisk/client";
940
+ import type { Api } from "../../server/src/app.ts"; // export type Api = typeof app
941
+ import { manifest } from "./api.manifest.ts";
942
+
943
+ export const api = createClient<Api>(manifest, {
944
+ baseUrl: import.meta.env.VITE_API_URL,
945
+ headers: () => ({ authorization: `Bearer ${session.token}` }),
946
+ });
947
+ ```
948
+
949
+ `import type` brings in types only, so no server code reaches the bundle.
950
+
951
+ | Option | Type | Description |
952
+ | --------- | ----------------------------------- | ----------------------------------------------------------- |
953
+ | `baseUrl` | `string` | Origin of the API, such as `https://api.example.com` |
954
+ | `headers` | object, or a function returning one | Headers for every request; a function is called per request |
955
+ | `fetch` | `typeof fetch` | The fetch to use. Default: the global `fetch` |
956
+
957
+ ### Calling routes
958
+
959
+ Each named route is a function at `api.<controller>.<route>`, with groups nested under their names:
960
+
961
+ ```ts
962
+ const user = await api.users.get({ params: { id } });
963
+ const page = await api.users.list({ query: { search: "ada", limit: 20 } });
964
+ const created = await api.users.create({
965
+ body: { email: "ada@example.com", name: "Ada", password: "correct-horse-battery" },
966
+ headers: { authorization: `Bearer ${token}` },
967
+ });
968
+ await api.users.remove({ params: { id } }, { signal: AbortSignal.timeout(5000) });
969
+ ```
970
+
971
+ - The input has `params`, `query`, `body` and `headers`, each typed by the route's schema as clients send it (the schema's input type). A field is absent when the route has nothing for it, and optional when everything in it is optional.
972
+ - `params` and `query` fields also accept the schema's output type when a URL can carry it: a `z.stringbool()` field takes `true` as well as `"true"`, and a `z.coerce.number()` field takes `2`.
973
+ - Every call accepts extra headers, such as `authorization` for a middleware that reads it.
974
+ - The second argument is passed to `fetch`: `signal`, `credentials`, and so on.
975
+ - Array query values become repeated keys (`?tag=a&tag=b`); a `*splat` param takes an array of segments.
976
+ - A `FormData`, `Blob`, `URLSearchParams`, `ArrayBuffer` (or typed array) or `ReadableStream` body is sent as it is, so `fetch` sets the content type and multipart boundary. Any other body, strings included, is sent as JSON.
977
+
978
+ A call resolves to:
979
+
980
+ | The route | The call resolves to |
981
+ | ------------------------- | ------------------------------------------------------------------------------------- |
982
+ | has a `response` schema | the schema's output |
983
+ | has none | the handler's return value after JSON: `Date` becomes `string`, functions are dropped |
984
+ | returns a stream or bytes | the raw `Response`, to read as you need |
985
+ | returns nothing | `undefined` (204) |
986
+
987
+ A non-2xx response rejects with `ApiError`:
988
+
989
+ | Field | Description |
990
+ | ----------- | --------------------------------------------------- |
991
+ | `status` | HTTP status |
992
+ | `code` | `error.code` from the body, or `HTTP_ERROR` |
993
+ | `message` | `error.message` from the body, or a generic message |
994
+ | `details` | `error.details` from the body |
995
+ | `requestId` | `error.requestId` from the body |
996
+ | `body` | The parsed body, whatever its shape |
997
+
998
+ ```ts
999
+ try {
1000
+ await api.users.create({ body, headers });
1001
+ } catch (error) {
1002
+ if (error instanceof ApiError && error.code === "EMAIL_TAKEN") return showEmailTaken();
1003
+ throw error;
1004
+ }
1005
+ ```
1006
+
1007
+ ### Raw responses
1008
+
1009
+ Every call also has `.raw(input, init?)`, which sends the same request and resolves to the `Response` whatever its status, without throwing. Use it for downloads, uploads and anything that is not JSON. The client's `headers` are still applied, and a string body is sent as it is. Its `body` accepts the route's body type or any `RawBody`:
1010
+
1011
+ ```ts
1012
+ const form = new FormData();
1013
+ form.append("file", file);
1014
+ const res = await api.tasks.attach.raw({ params: { taskId }, body: form });
1015
+ if (!res.ok) throw new Error(`Upload failed with ${res.status}`);
1016
+
1017
+ const report = await api.reports.download.raw({ params: { id } });
1018
+ const blob = await report.blob();
1019
+ ```
1020
+
1021
+ Types from `@raiyyan/brisk/client`: `ApiOf<T>` (an app's routes), `Client<T>` (the client for them), `Call<I, O>` (one route as a function, with `.raw`), `RawBody` (a body `.raw` sends as it is), `Jsonify<T>` (a type after JSON), `Manifest`.
1022
+
1023
+ ## OpenAPI
1024
+
1025
+ brisk builds an OpenAPI 3.1 document from the routes' Zod schemas. Pass `openapi` to serve it:
1026
+
1027
+ ```ts
1028
+ export const app = defineApp({
1029
+ controllers,
1030
+ openapi: { title: "Shop API", version: "1.0.0" },
1031
+ });
1032
+ ```
1033
+
1034
+ | Option | Type | Default | Description |
1035
+ | ------------------ | ------------------------- | ------------------ | -------------------------------------------------------------------- |
1036
+ | `title`, `version` | `string` | required | The document's `info` |
1037
+ | `description` | `string` | none | The document's `info.description` |
1038
+ | `servers` | `{ url, description? }[]` | none (same origin) | Base URLs of the API |
1039
+ | `path` | `string \| false` | `"/openapi.json"` | Where it is served, outside the app `prefix`; `false` serves nothing |
1040
+
1041
+ - `app.openapi()` returns the document, with or without the option (title `API`, version `0.0.0` without it).
1042
+ - `brisk openapi --out openapi.json` writes it to a file, for client generators and API gateways.
1043
+ - The `operationId` is the route's name, `controller.route` (`users.get`, `orders.admin.remove`), and the controller's name is the tag. Routes of a controller whose setup returns nothing have no `operationId`.
1044
+ - `params`, `query` and `headers` schemas become parameters, and the `body` schema the request body, as clients send them. The `response` schema is documented under the route's success status as clients receive it. Every operation lists the error body as its `default` response.
1045
+ - Express paths are converted: `:id` and `*path` become `{id}` and `{path}`, and an optional group (`/posts{/:slug}`) gives two paths.
1046
+ - A route behind a middleware declared with `security` lists those schemes (several middlewares: all of them, together), and the document lists every scheme under `components.securitySchemes`. Routes without one stay public. See [security schemes](#security-schemes).
1047
+ - A schema JSON Schema cannot express, such as `z.date()` or a transform, is documented as "any". For dates sent as strings, end the schema in the string it produces:
1048
+
1049
+ ```ts
1050
+ createdAt: z
1051
+ .date()
1052
+ .transform((date) => date.toISOString())
1053
+ .pipe(z.iso.datetime()), // documented as { type: "string", format: "date-time" }
1054
+ ```
1055
+
1056
+ The typed client then types the field as `string` too. The starters' `isoDate()` in `src/schemas.ts` does this and also accepts an ISO string.
1057
+
1058
+ ## Testing
1059
+
1060
+ ```ts
1061
+ import { provide } from "@raiyyan/brisk";
1062
+ import { createTestClient, readError } from "@raiyyan/brisk/testing";
1063
+ import { afterAll, expect, it } from "vitest";
1064
+ import { app } from "../src/app.ts";
1065
+ import { Clock } from "../src/database/clock.ts";
1066
+
1067
+ const api = await createTestClient(app, { providers: [provide(Clock, () => new Date("2026-01-01"))] });
1068
+ afterAll(() => api.close());
1069
+
1070
+ it("creates a product", async () => {
1071
+ const product = await api.call.products.create({ body: { name: "Desk" } }); // typed
1072
+ expect(await api.call.products.get({ params: { id: product.id } })).toEqual(product);
1073
+ });
1074
+
1075
+ it("rejects an invalid product", async () => {
1076
+ const res = await api.post("/api/products", { body: { name: "" } }); // raw
1077
+ expect(res.status).toBe(400);
1078
+ expect(readError(res)).toMatchObject({ code: "VALIDATION_FAILED", details: { location: "body" } });
1079
+ });
1080
+ ```
1081
+
1082
+ - `createTestClient(app, { providers? })` starts a fresh instance on a random port with its full lifecycle. It returns the [typed client](#typed-client) as `call`, raw `get`, `post`, `put`, `patch` and `delete(path, { body?, headers? })`, `baseUrl`, `instance` (the running `AppInstance`, such as `api.instance.container.get(UsersService)`), and `close()` (a full graceful shutdown).
1083
+ - Each test client gets its own instance, so test files run in parallel without sharing state.
1084
+ - Use `call` for behavior (`await api.call.users.create({ body })` is typed and rejects with `ApiError` on non-2xx), and the raw methods for status codes, headers and malformed input.
1085
+ - A raw `body` is sent as JSON, except a string or `Uint8Array`, which is sent as is (webhook payloads, forms). `content-type` defaults to `application/json` when there is a body; set it in `headers` to override.
1086
+ - Raw responses are `{ status, headers, body }`; `body` is parsed JSON, text, or `undefined`, typed `unknown`. Parse it with a Zod schema or assert with `toMatchObject`.
1087
+ - `readError(res)` returns the typed `error` object and fails the test if the body is not an error response.
1088
+ - Swap dependencies with `providers`: they are added after the app's own, so a value for the same token wins, and the app's listed classes still start. Unit-test services without HTTP with `new Container([...]).get(Service)`.
1089
+ - To test `304 Not Modified`, use `node:http`, or send `cache-control: max-age=0` with the request. `fetch`, and so the test client's raw methods, adds `Cache-Control: no-cache` to a request that carries `If-None-Match` and no `Cache-Control`, which correctly disables 304.
1090
+
1091
+ ## CLI
1092
+
1093
+ The package installs a `brisk` command. Run it from the app's root.
1094
+
1095
+ | Command | What it does |
1096
+ | ---------------------------- | ------------------------------------------------------------------------------------------------- |
1097
+ | `brisk init` | Copies a [starter](#starters) into the current folder, installs its packages, runs `brisk agents` |
1098
+ | `brisk g <kind> <name>` | Renders the project's `templates/<kind>/` for `<name>` (see [Templates](#templates)) |
1099
+ | `brisk client --out <file>` | Writes the route manifest for a frontend's [typed client](#typed-client) |
1100
+ | `brisk openapi --out <file>` | Writes the [OpenAPI](#openapi) document as JSON |
1101
+ | `brisk agents` | Adds or refreshes the brisk guide for AI coding agents in `AGENTS.md` and `CLAUDE.md` |
1102
+ | `brisk help`, `--version` | Lists commands; prints the installed version |
1103
+
1104
+ ### brisk init
1105
+
1106
+ ```bash
1107
+ npx brisk init [--starter <name>] [--pm pnpm|npm|yarn] [--no-install]
1108
+ ```
1109
+
1110
+ | Flag | Effect |
1111
+ | ------------------------ | ------------------------------------------------------------------------------------------------------- |
1112
+ | `--starter <name>` | The starter to copy. Default: `minimal`. `--starter list` lists the starters |
1113
+ | `--pm <pnpm\|npm\|yarn>` | The package manager. Default: the one running the CLI, else the one whose lockfile is present, else npm |
1114
+ | `--no-install` | Print the install command instead of running it |
1115
+
1116
+ What it does:
1117
+
1118
+ - Copies the starter's files. A file that already exists is kept, never overwritten, and listed as `kept`.
1119
+ - Skips `node_modules`, `dist`, `coverage`, `.env`, database and log files, and `AGENTS.md` / `CLAUDE.md` (written next by `brisk agents`).
1120
+ - Merges the starter's `package.json` into yours: `type`, `engines`, scripts and dependencies are added where yours has none; your own values win. `@raiyyan/brisk` is pinned to the version that is running. The `test` script `npm init` writes (which only fails) is replaced.
1121
+ - Writes the agent guide (`brisk agents`).
1122
+ - Installs the packages. If the install fails, the files stay in place and the CLI says what to run again; with pnpm it also points to `pnpm approve-builds`, for packages whose build scripts pnpm blocked.
1123
+
1124
+ ### brisk g
1125
+
1126
+ ```bash
1127
+ npx brisk g feature products
1128
+ npx brisk g middleware tenant
1129
+ ```
1130
+
1131
+ `brisk g <kind> <name>` renders the project's own `templates/<kind>/` folder; `g` is short for `generate`. The starters ship `feature` and `middleware`; any folder you add under `templates/` is a new kind. Names may be `kebab-case`, `camelCase`, `PascalCase` or `snake_case`.
1132
+
1133
+ ### brisk client and brisk openapi
1134
+
1135
+ Both import `src/app.ts` (or the module given with `--app <file>`), read its `app` export, and write `--out`. Nothing listens or starts. The module runs, so its environment must be valid, as for starting the app.
1136
+
1137
+ ## Templates
1138
+
1139
+ Templates are files in your project, not in brisk: `brisk g <kind> <name>` copies `templates/<kind>/` into the project root, rendering each file's path and contents. Edit them when your conventions change, and every later feature follows.
1140
+
1141
+ | Token | `brisk g feature blog-posts` gives |
1142
+ | ----------------- | ---------------------------------- |
1143
+ | `{{kebab}}` | `blog-posts` |
1144
+ | `{{camel}}` | `blogPosts` |
1145
+ | `{{pascal}}` | `BlogPosts` |
1146
+ | `{{snake}}` | `blog_posts` |
1147
+ | `{{entity}}` | `BlogPost` (singular) |
1148
+ | `{{entityCamel}}` | `blogPost` |
1149
+
1150
+ - **New files:** every file in the folder is written to the same relative path, with tokens replaced in both the path and the contents.
1151
+ - **Additions to existing files:** a file named `<target>.<marker>.insert` is not copied. Its text is inserted into the existing `<target>`, right before the line that contains `@brisk:<marker>`. This is how a feature registers its controller (`// @brisk:imports` and `// @brisk:controllers` in `src/features/index.ts`) and, in `sqlite-auth`, its table (`// @brisk:migrations` in `src/database/migrations.ts`). Keep the marker lines.
1152
+ - **All or nothing:** everything is checked before anything is written. An unknown token, a file that already exists, or a missing target or marker stops the command with nothing changed.
1153
+ - **Formatting:** when the project has `oxfmt`, `prettier` or `biome` installed, the written files are formatted with it, so they match your style.
1154
+
1155
+ A worked example: a `job` kind that writes a scheduled job and lists it in `src/jobs/index.ts`.
1156
+
1157
+ ```
1158
+ templates/job/
1159
+ src/jobs/{{kebab}}.job.ts
1160
+ src/jobs/index.ts.imports.insert
1161
+ src/jobs/index.ts.jobs.insert
1162
+ ```
1163
+
1164
+ ```ts
1165
+ // templates/job/src/jobs/{{kebab}}.job.ts
1166
+ import type { OnDrain, OnReady } from "@raiyyan/brisk";
1167
+
1168
+ export class {{pascal}}Job implements OnReady, OnDrain {
1169
+ #timer: NodeJS.Timeout | undefined;
1170
+
1171
+ onReady() {
1172
+ this.#timer = setInterval(() => {
1173
+ // the job's work
1174
+ }, 60_000);
1175
+ }
1176
+
1177
+ onDrain() {
1178
+ clearInterval(this.#timer);
1179
+ }
1180
+ }
1181
+ ```
1182
+
1183
+ ```ts
1184
+ // templates/job/src/jobs/index.ts.imports.insert
1185
+ import { {{pascal}}Job } from "./{{kebab}}.job.ts";
1186
+ ```
1187
+
1188
+ ```ts
1189
+ // templates/job/src/jobs/index.ts.jobs.insert
1190
+ {{pascal}}Job,
1191
+ ```
1192
+
1193
+ ```ts
1194
+ // src/jobs/index.ts, written once by hand; list it in defineApp({ providers: [...jobs] })
1195
+ // @brisk:imports
1196
+
1197
+ export const jobs = [
1198
+ // @brisk:jobs
1199
+ ];
1200
+ ```
1201
+
1202
+ `brisk g job nightly-digest` then writes `src/jobs/nightly-digest.job.ts` and adds `NightlyDigestJob` to the list.
1203
+
1204
+ Template files are not valid TypeScript until rendered, so keep `templates/` out of lint, format, typecheck and test runs; the starters' configs already do.
1205
+
1206
+ ## Working with the ecosystem
1207
+
1208
+ ### Not in your way
1209
+
1210
+ brisk owns only its own routes. Everything else is plain Express on a plain Node server, so any Express middleware or Node library works as it would without brisk:
1211
+
1212
+ - `use` with `fromExpress(handler)`: any Express middleware for every request, before brisk's routes.
1213
+ - `mount`: Express handlers that are not brisk routes (static files, GraphQL, tRPC, webhooks, a legacy router).
1214
+ - `http.settings`: Express app settings (`trust proxy`, `etag`, `query parser`).
1215
+ - `fromExpress(handler)`: any Express middleware in one route's `use`, in order with brisk's middlewares.
1216
+ - `instance.express`: the Express app (adapters, serverless with `app.create()`).
1217
+ - `instance.server` and the `HttpServer` token: the Node server (WebSockets, timeouts).
1218
+ - `ListenOptions.server`: your own server (HTTPS, tuned timeouts).
1219
+ - Provider lifecycle hooks: run anything alongside HTTP.
1220
+ - `app.start()`: the same app without HTTP.
1221
+ - `ctx.res`: write a raw response from any handler.
1222
+
1223
+ ### use, mount and a route's use
1224
+
1225
+ - **The app's `use`** runs for every request: brisk routes, mounts and 404s. `/health` and `/openapi.json` are answered before it, so a rate limiter or an auth check there never blocks a probe. `fromExpress(...)` entries run as plain Express middleware, so CORS preflights are answered before routing. A `defineMiddleware` entry runs once per request, stops the request by throwing, and adds its `security` to every OpenAPI operation.
1226
+ - **`mount`** holds Express handlers that are not brisk routes. They answer only what brisk's routes did not, so a mount never shadows a route and `mount: { "/": spaFallback }` works as a fallback. Paths are absolute (the `prefix` does not apply) and must start with `/`; Express strips the path from `req.url`. brisk does not parse their bodies: add `express.json()` or `express.raw()` to the mount that needs one. Mounts get the request context (`getContext(req)`), `inject()` and the same error responses, and the startup log lists them as `MOUNT /path`.
1227
+ - **A route's `use`** (controller, group or route) holds middlewares for those routes only. Their output is typed on the handler's `ctx`.
1228
+
1229
+ Controllers are declared before the app, so the output of a `defineMiddleware` in the app's `use` is not typed on handler `ctx`. To type it, list the middleware in the controller's `use` as well (it still runs once per request), or read it with `await middleware.run(ctx)`.
1230
+
1231
+ brisk parses JSON only on its own routes. A form body parsed in the app's `use` reaches a brisk route's `body` schema.
1232
+
1233
+ ### Express middleware, static files, webhooks
1234
+
1235
+ ```ts
1236
+ import cors from "cors";
1237
+ import express from "express";
1238
+
1239
+ export const app = defineApp({
1240
+ controllers,
1241
+ use: [
1242
+ fromExpress(cors({ origin: env.WEB_ORIGIN })),
1243
+ fromExpress(express.urlencoded({ extended: false })), // form bodies reach brisk `body` schemas
1244
+ ],
1245
+ mount: {
1246
+ "/static": express.static("public"),
1247
+ "/webhooks/stripe": [express.raw({ type: "application/json" }), stripeWebhook], // raw bytes for signatures
1248
+ },
1249
+ });
1250
+ ```
1251
+
1252
+ ### Uploads
1253
+
1254
+ Parse multipart bodies on the route that takes them, after authentication, with `fromExpress`:
1255
+
1256
+ ```ts
1257
+ import { BadRequestError, defineController, fromExpress, inject } from "@raiyyan/brisk";
1258
+ import multer from "multer";
1259
+
1260
+ const upload = fromExpress(multer({ dest: "uploads/", limits: { fileSize: 5_000_000 } }).single("file"));
1261
+
1262
+ export const attachmentsController = defineController(
1263
+ { prefix: "/tasks/:taskId/attachments", use: [authenticate] },
1264
+ (route) => {
1265
+ const attachments = inject(AttachmentsService);
1266
+
1267
+ return {
1268
+ create: route.post("/", { use: [upload] }, ({ req, params }) => {
1269
+ if (!req.file) throw new BadRequestError("Send the file in the `file` field");
1270
+ return attachments.save(params.taskId, req.file);
1271
+ }),
1272
+ };
1273
+ },
1274
+ );
1275
+ ```
1276
+
1277
+ An unauthenticated upload is rejected before multer reads a byte. Errors multer passes on reach `errors.mappers`, so a mapper can turn its file-size error into `PayloadTooLargeError`. Clients send the file with [`.raw`](#raw-responses) and a `FormData` body. Files written to local disk exist only on the instance that received them; use object storage when you run more than one.
1278
+
1279
+ ### Server-Sent Events
1280
+
1281
+ ```ts
1282
+ return {
1283
+ events: route.get("/events", ({ res, requestId }) => {
1284
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
1285
+ res.write(`data: ${requestId}\n\n`);
1286
+ }),
1287
+ };
1288
+ ```
1289
+
1290
+ End long-lived streams from a provider's `onDrain` so shutdown does not wait for them.
1291
+
1292
+ ### WebSockets with ws
1293
+
1294
+ ```ts
1295
+ import { HttpServer, inject, type OnDrain, type OnReady } from "@raiyyan/brisk";
1296
+ import { WebSocketServer } from "ws";
1297
+
1298
+ export class ChatGateway implements OnReady, OnDrain {
1299
+ #wss: WebSocketServer | undefined;
1300
+
1301
+ onReady() {
1302
+ this.#wss = new WebSocketServer({ server: inject(HttpServer) });
1303
+ this.#wss.on("connection", (socket) => socket.on("message", (data) => socket.send(data)));
1304
+ }
1305
+
1306
+ onDrain() {
1307
+ for (const client of this.#wss?.clients ?? []) client.close(1001, "server shutting down");
1308
+ this.#wss?.close();
1309
+ }
1310
+ }
1311
+
1312
+ export const app = defineApp({ controllers, providers: [ChatGateway] });
1313
+ ```
1314
+
1315
+ ### Socket.IO
1316
+
1317
+ ```ts
1318
+ import { HttpServer, inject, type OnDrain, type OnReady } from "@raiyyan/brisk";
1319
+ import { Server } from "socket.io";
1320
+
1321
+ export class Realtime implements OnReady, OnDrain {
1322
+ #io: Server | undefined;
1323
+
1324
+ onReady() {
1325
+ this.#io = new Server(inject(HttpServer));
1326
+ }
1327
+
1328
+ async onDrain() {
1329
+ await this.#io?.close();
1330
+ }
1331
+ }
1332
+
1333
+ export const app = defineApp({ controllers, providers: [Realtime] });
1334
+ ```
1335
+
1336
+ ### Shared access rules
1337
+
1338
+ Put authorization rules in a service, and call it from every entry point: the HTTP middleware, the WebSocket gateway and the GraphQL resolvers. Each one then enforces the same rules instead of its own copy.
1339
+
1340
+ ```ts
1341
+ // src/features/projects/project-access.ts
1342
+ import { ForbiddenError, inject, NotFoundError } from "@raiyyan/brisk";
1343
+
1344
+ export type ProjectRole = "viewer" | "editor" | "owner";
1345
+ const RANK: Record<ProjectRole, number> = { viewer: 0, editor: 1, owner: 2 };
1346
+
1347
+ /** Who may do what in a project. */
1348
+ export class ProjectAccess {
1349
+ readonly #members = inject(MembersRepository);
1350
+
1351
+ /** The user's role in the project, or `undefined` when they are not a member. */
1352
+ async roleIn(userId: string, projectId: string): Promise<ProjectRole | undefined> {
1353
+ return (await this.#members.find(projectId, userId))?.role;
1354
+ }
1355
+
1356
+ /** The user's role, or an error: 404 for a non-member, so project ids do not leak; 403 for too low a role. */
1357
+ async require(userId: string, projectId: string, role: ProjectRole): Promise<ProjectRole> {
1358
+ const actual = await this.roleIn(userId, projectId);
1359
+ if (!actual) throw new NotFoundError("Project not found");
1360
+ if (RANK[actual] < RANK[role]) throw new ForbiddenError(`Requires the ${role} role`);
1361
+ return actual;
1362
+ }
1363
+ }
1364
+ ```
1365
+
1366
+ HTTP routes use it through a middleware that declares the param it reads ([middleware that needs input](#middleware-that-needs-input)):
1367
+
1368
+ ```ts
1369
+ export const tasksController = defineController({ prefix: "/projects/:projectId/tasks" }, (route) => {
1370
+ const tasks = inject(TasksService);
1371
+
1372
+ return {
1373
+ list: route.get("/", { use: [requireProjectRole("viewer")] }, ({ params }) =>
1374
+ tasks.list(params.projectId),
1375
+ ),
1376
+ create: route.post(
1377
+ "/",
1378
+ { use: [requireProjectRole("editor")], body: CreateTaskBody },
1379
+ ({ params, body }) => tasks.create(params.projectId, body),
1380
+ ),
1381
+ };
1382
+ });
1383
+ ```
1384
+
1385
+ A WebSocket gateway resolves it in a class field, because `ws` callbacks run outside the app:
1386
+
1387
+ ```ts
1388
+ import type { IncomingMessage } from "node:http";
1389
+ import { HttpServer, inject, type OnDrain, type OnReady } from "@raiyyan/brisk";
1390
+ import { WebSocketServer, type WebSocket } from "ws";
1391
+
1392
+ export class ProjectsGateway implements OnReady, OnDrain {
1393
+ readonly #access = inject(ProjectAccess);
1394
+ readonly #sessions = inject(Sessions);
1395
+ #wss: WebSocketServer | undefined;
1396
+
1397
+ onReady() {
1398
+ this.#wss = new WebSocketServer({ server: inject(HttpServer), path: "/ws" });
1399
+ this.#wss.on("connection", (socket, req) => void this.#admit(socket, req));
1400
+ }
1401
+
1402
+ async #admit(socket: WebSocket, req: IncomingMessage) {
1403
+ const user = await this.#sessions.verify(req.headers.authorization);
1404
+ const projectId = new URL(req.url ?? "/", "http://localhost").searchParams.get("projectId");
1405
+ if (!user || !projectId || !(await this.#access.roleIn(user.id, projectId))) {
1406
+ socket.close(4403, "forbidden");
1407
+ return;
1408
+ }
1409
+ // subscribe the socket to the project's events
1410
+ }
1411
+
1412
+ onDrain() {
1413
+ for (const client of this.#wss?.clients ?? []) client.close(1001, "server shutting down");
1414
+ this.#wss?.close();
1415
+ }
1416
+ }
1417
+ ```
1418
+
1419
+ GraphQL resolvers call `inject(ProjectAccess).require(...)` (next section).
1420
+
1421
+ ### GraphQL with Yoga
1422
+
1423
+ ```ts
1424
+ // src/graphql.ts
1425
+ import { ErrorTranslator, inject, UnauthorizedError } from "@raiyyan/brisk";
1426
+ import type { Request, Response } from "express";
1427
+ import { GraphQLError } from "graphql";
1428
+ import { createSchema, createYoga } from "graphql-yoga";
1429
+
1430
+ interface GraphQLContext {
1431
+ user: User | undefined;
1432
+ }
1433
+
1434
+ export const yoga = createYoga<{ req: Request; res: Response }, GraphQLContext>({
1435
+ schema: createSchema<GraphQLContext>({
1436
+ typeDefs: "type Query { project(id: ID!): Project } type Project { id: ID! name: String! }",
1437
+ resolvers: {
1438
+ Query: {
1439
+ project: async (_parent, args: { id: string }, { user }) => {
1440
+ if (!user) throw new UnauthorizedError("Sign in first");
1441
+ await inject(ProjectAccess).require(user.id, args.id, "viewer");
1442
+ return inject(ProjectsService).get(args.id);
1443
+ },
1444
+ },
1445
+ },
1446
+ }),
1447
+ context: async ({ req }) => ({ user: await inject(Sessions).verify(req.get("authorization")) }),
1448
+ maskError: (error) => {
1449
+ if (error instanceof GraphQLError && !error.originalError) return error; // query syntax and validation errors
1450
+ const cause = error instanceof GraphQLError ? error.originalError : error;
1451
+ const { status, code, message } = inject(ErrorTranslator)(cause);
1452
+ return new GraphQLError(message, { extensions: { code, status } });
1453
+ },
1454
+ });
1455
+ ```
1456
+
1457
+ ```ts
1458
+ // src/app.ts
1459
+ export const app = defineApp({
1460
+ controllers,
1461
+ mount: { [yoga.graphqlEndpoint]: (req, res) => yoga(req, res) },
1462
+ http: { securityHeaders: { "content-security-policy": false } }, // GraphiQL loads scripts; or set a GraphiQL-compatible policy
1463
+ });
1464
+ ```
1465
+
1466
+ - Resolvers, the context factory and `maskError` call `inject()` directly: they run inside the request.
1467
+ - Mount Yoga through a function, as above: Yoga's own type is not an Express `RequestHandler`.
1468
+ - `maskError` gives GraphQL clients the same status, code and message a route would send: `errors.mappers` apply and internal messages stay hidden.
1469
+
1470
+ ### GraphQL with Apollo Server
1471
+
1472
+ ```ts
1473
+ import { ApolloServer } from "@apollo/server";
1474
+ import { expressMiddleware } from "@as-integrations/express5";
1475
+ import express from "express";
1476
+
1477
+ const apollo = new ApolloServer({ typeDefs, resolvers });
1478
+ await apollo.start();
1479
+
1480
+ export const app = defineApp({
1481
+ controllers,
1482
+ mount: { "/graphql": [express.json(), expressMiddleware(apollo)] },
1483
+ });
1484
+ ```
1485
+
1486
+ brisk does not parse mount bodies, so the Apollo mount adds `express.json()` itself.
1487
+
1488
+ ### tRPC
1489
+
1490
+ ```ts
1491
+ import { initTRPC } from "@trpc/server";
1492
+ import { createExpressMiddleware } from "@trpc/server/adapters/express";
1493
+
1494
+ const t = initTRPC.create();
1495
+ const router = t.router({
1496
+ user: t.procedure
1497
+ .input(z.object({ id: z.uuid() }))
1498
+ .query(({ input }) => inject(UsersService).get(input.id)),
1499
+ });
1500
+
1501
+ export const app = defineApp({
1502
+ controllers,
1503
+ mount: { "/trpc": createExpressMiddleware({ router }) },
1504
+ });
1505
+ ```
1506
+
1507
+ tRPC v11 accepts Zod schemas directly, so your brisk schemas work as procedure inputs.
1508
+
1509
+ ### gRPC
1510
+
1511
+ gRPC (`@grpc/grpc-js`) runs on its own port, as in any Node app; a provider starts and stops it. This recipe is not covered by brisk's tests.
1512
+
1513
+ ```ts
1514
+ import * as grpc from "@grpc/grpc-js";
1515
+
1516
+ export class GrpcServer implements OnReady, OnDrain {
1517
+ readonly #server = new grpc.Server();
1518
+ readonly #users = inject(UsersService); // resolved here: gRPC callbacks run outside the app
1519
+
1520
+ onReady() {
1521
+ this.#server.addService(usersServiceDefinition, {
1522
+ getUser: (call, callback) => callback(null, this.#users.get(call.request.id)),
1523
+ });
1524
+ return new Promise<void>((resolve, reject) =>
1525
+ this.#server.bindAsync("0.0.0.0:50051", grpc.ServerCredentials.createInsecure(), (error) =>
1526
+ error ? reject(error) : resolve(),
1527
+ ),
1528
+ );
1529
+ }
1530
+
1531
+ onDrain() {
1532
+ return new Promise<void>((resolve) => {
1533
+ const force = setTimeout(() => {
1534
+ this.#server.forceShutdown();
1535
+ resolve();
1536
+ }, 5000);
1537
+ this.#server.tryShutdown(() => {
1538
+ clearTimeout(force);
1539
+ resolve();
1540
+ });
1541
+ });
1542
+ }
1543
+ }
1544
+
1545
+ export const app = defineApp({ controllers, providers: [GrpcServer] });
1546
+ ```
1547
+
1548
+ ### Microfrontends, frontends and BFFs
1549
+
1550
+ Serve frontend assets and remote entries with `express.static`. brisk's strict API headers block cross-origin asset loading, so relax them and allow the shell's origin:
1551
+
1552
+ ```ts
1553
+ export const app = defineApp({
1554
+ controllers,
1555
+ http: {
1556
+ securityHeaders: {
1557
+ "content-security-policy": false, // or your policy
1558
+ "cross-origin-resource-policy": "cross-origin",
1559
+ "x-frame-options": false, // only when the frontend is embedded in iframes
1560
+ },
1561
+ },
1562
+ use: [fromExpress(cors({ origin: env.SHELL_ORIGIN }))],
1563
+ mount: { "/remotes/checkout": express.static("dist/checkout") },
1564
+ });
1565
+ ```
1566
+
1567
+ A backend-for-frontend is brisk controllers calling upstream services: throw `BadGatewayError` for bad upstream answers and `GatewayTimeoutError` for timeouts.
1568
+
1569
+ A frontend calls the API through the [typed client](#typed-client), with the manifest that `brisk client` generates.
1570
+
1571
+ ### Caching
1572
+
1573
+ - ETags and `304 Not Modified` work automatically for GET responses (to test them, see the 304 note under [Testing](#testing)).
1574
+ - Set `Cache-Control` in the handler: `res.set("cache-control", "public, max-age=60")`.
1575
+ - A Redis or in-memory cache client is a provider: connect in `onInit`, quit in `onShutdown`, `inject(Cache)` where needed.
1576
+
1577
+ ### Workers, jobs and queue consumers
1578
+
1579
+ ```ts
1580
+ export class DigestJob implements OnReady, OnDrain {
1581
+ #timer: NodeJS.Timeout | undefined;
1582
+
1583
+ onReady() {
1584
+ this.#timer = setInterval(() => inject(DigestService).send(), 60_000);
1585
+ }
1586
+
1587
+ onDrain() {
1588
+ clearInterval(this.#timer);
1589
+ }
1590
+ }
1591
+
1592
+ export class EmailConsumer implements OnReady, OnDrain {
1593
+ #running = false;
1594
+ #loop: Promise<void> | undefined;
1595
+
1596
+ onReady() {
1597
+ this.#running = true;
1598
+ this.#loop = this.#consume();
1599
+ }
1600
+
1601
+ async onDrain() {
1602
+ this.#running = false;
1603
+ await this.#loop; // finish the message in progress
1604
+ }
1605
+
1606
+ async #consume() {
1607
+ while (this.#running) await inject(EmailService).handle(await queue.next());
1608
+ }
1609
+ }
1610
+ ```
1611
+
1612
+ Run them next to HTTP (`providers: [DigestJob, EmailConsumer]`), or in their own process with the same code and no port:
1613
+
1614
+ ```ts
1615
+ // src/worker.ts
1616
+ await defineApp({ providers: [EmailConsumer] }).start(); // no controllers, no port; signals and graceful shutdown still apply
1617
+ ```
1618
+
1619
+ ### Worker threads
1620
+
1621
+ CPU-heavy work goes to a `worker_threads` pool owned by a provider:
1622
+
1623
+ ```ts
1624
+ import { Worker } from "node:worker_threads";
1625
+
1626
+ export class ImagePool implements OnInit, OnShutdown {
1627
+ #worker: Worker | undefined;
1628
+
1629
+ onInit() {
1630
+ this.#worker = new Worker(new URL("./resize.worker.ts", import.meta.url));
1631
+ }
1632
+
1633
+ resize(input: Uint8Array): Promise<Uint8Array> {
1634
+ const worker = this.#worker;
1635
+ if (!worker) return Promise.reject(new Error("pool not started"));
1636
+ return new Promise((resolve) => {
1637
+ worker.once("message", resolve);
1638
+ worker.postMessage(input);
1639
+ });
1640
+ }
1641
+
1642
+ async onShutdown() {
1643
+ await this.#worker?.terminate();
1644
+ }
1645
+ }
1646
+ ```
1647
+
1648
+ ### Lazy loading
1649
+
1650
+ - Instances are created on first use, not at boot.
1651
+ - `injectLazy(Token)` defers a dependency until its first call.
1652
+ - Load heavy code on demand inside a handler: `const { PdfService } = await import("./pdf.service.ts"); return inject(PdfService).render(id);`.
1653
+
1654
+ ### HTTPS and custom servers
1655
+
1656
+ ```ts
1657
+ import https from "node:https";
1658
+
1659
+ await app.listen(443, { server: https.createServer({ key, cert }) });
1660
+ ```
1661
+
1662
+ Inside providers, `inject(HttpServer)` returns the server from `onReady` on.
1663
+
1664
+ ### Serverless
1665
+
1666
+ ```ts
1667
+ const instance = app.create();
1668
+ await instance.init();
1669
+ export const handler = serverlessAdapter(instance.express); // your platform's Express adapter
1670
+ ```
1671
+
1672
+ ### Clusters, PM2 and Kubernetes
1673
+
1674
+ - brisk keeps no per-process state: run one process per core with PM2, `node:cluster` or replicas. Your own in-process state (an in-memory event bus, files uploaded to local disk, an in-memory rate limit) does not span them; move it to shared services first.
1675
+ - Behind a load balancer, set the server's keep-alive timeout above the balancer's idle timeout (for example 65 seconds behind a 60-second idle timeout) to avoid 502s: `instance.server.keepAliveTimeout = 65_000` on the instance `listen` resolves with, or pass your own server.
1676
+ - Bind `host: "0.0.0.0"` in containers when the default does not reach the container network.
1677
+ - Point liveness and readiness probes at `/health`. It answers 503 once shutdown begins, so load balancers stop sending traffic. SIGTERM triggers graceful shutdown; give the pod a termination grace period longer than `http.shutdownTimeoutMs`.
1678
+
1679
+ ## Production checklist
1680
+
1681
+ - Validate every variable in `defineEnv`; keep `.env` out of version control.
1682
+ - Give every route that takes input a schema, and a `response` schema for anything that returns stored data.
1683
+ - Keep `errors.exposeInternal` off in production.
1684
+ - Set `trust proxy` in `http.settings` only to match your proxy setup.
1685
+ - Add CORS with an explicit origin list and a rate limiter in the app's `use`.
1686
+ - Use JSON logs in production (no `pretty`); report 5xx errors with `hooks.onError`.
1687
+ - Connect clients in `onInit`, close them in `onShutdown`; stop long-lived work in `onDrain`.
1688
+ - Set `http.shutdownTimeoutMs` below your orchestrator's grace period, and the keep-alive timeout above your load balancer's idle timeout.
1689
+ - Map database and SDK errors with `errors.mappers` instead of try/catch in services.
1690
+ - Before running more than one instance, move in-process state to shared services: event buses, uploaded files on local disk and in-memory rate limits each see only their own instance.
1691
+ - Run `npm audit` (or `pnpm audit`) in CI.
1692
+
1693
+ ## Performance
1694
+
1695
+ - brisk costs about 15 to 25 µs of CPU per request over hand-wired Express doing the same work (auth, validation, response DTO, JSON errors), on a laptop CPU. That buys security headers, request ids, the request context and dependency scope.
1696
+ - The request path allocates little: sync middlewares and schemas never create promises, middleware results live on the request's own context (no `WeakMap` entries), routes sit on one flat router, and shutdown tracking costs one map entry per connection, not per request.
1697
+ - It stays stable under sustained load: 300 000 requests in a 30 s soak, heap flat after GC.
1698
+ - Graceful shutdown lets in-flight requests finish before the process exits.
1699
+
1700
+ Numbers: [benchmarks/RESULTS.md](https://github.com/MohammadRaiyyan/brisk/blob/main/benchmarks/RESULTS.md).
1701
+
1702
+ ## AI coding agents
1703
+
1704
+ brisk ships a complete guide for coding agents at `node_modules/@raiyyan/brisk/AGENTS.md` ([source](https://github.com/MohammadRaiyyan/brisk/blob/main/packages/brisk/AGENTS.md)).
1705
+
1706
+ Agents read the project's own `AGENTS.md` and `CLAUDE.md`, so run:
1707
+
1708
+ ```bash
1709
+ npx brisk agents
1710
+ ```
1711
+
1712
+ It writes a brisk section between `<!-- brisk:start -->` and `<!-- brisk:end -->` in your `AGENTS.md` (refreshed in place on later runs, the rest of the file untouched), and makes `CLAUDE.md` import `@AGENTS.md` and `@node_modules/@raiyyan/brisk/AGENTS.md`. `brisk init` runs it for you.
1713
+
1714
+ ## FAQ
1715
+
1716
+ **Can I use plain Express middleware?**
1717
+ Yes. App-wide middleware goes in the app's `use` (`use: [fromExpress(helmet()), fromExpress(cors())]`). Middleware for some routes only, such as multer, goes in the route's `use` through `fromExpress(...)`.
1718
+
1719
+ **Where do I put CORS?**
1720
+ In the app's `use`: `use: [fromExpress(cors({ origin: ["https://app.example.com"] }))]`. It runs before brisk's routes, so preflight requests are answered first.
1721
+
1722
+ **How do I return a file?**
1723
+ Set headers on `res` and return a `Readable` (`return createReadStream(path)`), or call `res.sendFile(path)` or `res.download(path)` and return nothing.
1724
+
1725
+ **How do I version an API?**
1726
+ Use the app `prefix` (`prefix: "/api/v1"`), or mount controllers with prefixes like `/v1/users` and `/v2/users` in the same app.
1727
+
1728
+ **How do I share types with a frontend?**
1729
+ Use the [typed client](#typed-client): `brisk client` writes the route manifest into the frontend, and `createClient<Api>(manifest, { baseUrl })` types every call from the server's schemas without bundling server code.
1730
+
1731
+ **Why no decorators?**
1732
+ Decorators need a compile step and hide wiring behind metadata. `inject()` in a field is explicit, fully typed, and runs on plain Node.
1733
+
1734
+ **Can a handler be synchronous?**
1735
+ Yes. Handlers, middlewares, hooks and lifecycle methods can be sync or async; a synchronous `throw` and a rejected promise produce the same error response.
1736
+
1737
+ **Can I run two apps in one process?**
1738
+ Yes. Every instance (each `listen`, `start`, `create` or test client) has its own dependencies. `inject()` resolves from the instance running the current code; outside any instance (a script, a REPL), use `instance.container.get(...)`.
1739
+
1740
+ ## Contributing
1741
+
1742
+ The repository is [github.com/MohammadRaiyyan/brisk](https://github.com/MohammadRaiyyan/brisk). Read the [contributor guide](https://github.com/MohammadRaiyyan/brisk/blob/main/AGENTS.md) and run `pnpm verify` before opening a pull request.
1743
+
1744
+ ## License
1745
+
1746
+ [MIT](https://github.com/MohammadRaiyyan/brisk/blob/main/LICENSE)