@raiyyan/brisk 0.0.0-stage → 0.1.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 (140) hide show
  1. package/AGENTS.md +467 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1417 -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 +127 -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/generate.d.ts +9 -0
  21. package/dist/src/cli/generate.d.ts.map +1 -0
  22. package/dist/src/cli/generate.js +39 -0
  23. package/dist/src/cli/generate.js.map +1 -0
  24. package/dist/src/cli/init.d.ts +19 -0
  25. package/dist/src/cli/init.d.ts.map +1 -0
  26. package/dist/src/cli/init.js +125 -0
  27. package/dist/src/cli/init.js.map +1 -0
  28. package/dist/src/cli/names.d.ts +14 -0
  29. package/dist/src/cli/names.d.ts.map +1 -0
  30. package/dist/src/cli/names.js +25 -0
  31. package/dist/src/cli/names.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 +83 -0
  35. package/dist/src/cli/system.js.map +1 -0
  36. package/dist/src/cli/templates/auth.d.ts +4 -0
  37. package/dist/src/cli/templates/auth.d.ts.map +1 -0
  38. package/dist/src/cli/templates/auth.js +90 -0
  39. package/dist/src/cli/templates/auth.js.map +1 -0
  40. package/dist/src/cli/templates/database.d.ts +4 -0
  41. package/dist/src/cli/templates/database.d.ts.map +1 -0
  42. package/dist/src/cli/templates/database.js +107 -0
  43. package/dist/src/cli/templates/database.js.map +1 -0
  44. package/dist/src/cli/templates/docker.d.ts +4 -0
  45. package/dist/src/cli/templates/docker.d.ts.map +1 -0
  46. package/dist/src/cli/templates/docker.js +102 -0
  47. package/dist/src/cli/templates/docker.js.map +1 -0
  48. package/dist/src/cli/templates/feature.d.ts +8 -0
  49. package/dist/src/cli/templates/feature.d.ts.map +1 -0
  50. package/dist/src/cli/templates/feature.js +145 -0
  51. package/dist/src/cli/templates/feature.js.map +1 -0
  52. package/dist/src/cli/templates/project.d.ts +24 -0
  53. package/dist/src/cli/templates/project.d.ts.map +1 -0
  54. package/dist/src/cli/templates/project.js +166 -0
  55. package/dist/src/cli/templates/project.js.map +1 -0
  56. package/dist/src/client/client.d.ts +64 -0
  57. package/dist/src/client/client.d.ts.map +1 -0
  58. package/dist/src/client/client.js +143 -0
  59. package/dist/src/client/client.js.map +1 -0
  60. package/dist/src/client/index.d.ts +8 -0
  61. package/dist/src/client/index.d.ts.map +1 -0
  62. package/dist/src/client/index.js +7 -0
  63. package/dist/src/client/index.js.map +1 -0
  64. package/dist/src/config/env.d.ts +28 -0
  65. package/dist/src/config/env.d.ts.map +1 -0
  66. package/dist/src/config/env.js +35 -0
  67. package/dist/src/config/env.js.map +1 -0
  68. package/dist/src/config/logger.d.ts +32 -0
  69. package/dist/src/config/logger.d.ts.map +1 -0
  70. package/dist/src/config/logger.js +31 -0
  71. package/dist/src/config/logger.js.map +1 -0
  72. package/dist/src/di/container.d.ts +186 -0
  73. package/dist/src/di/container.d.ts.map +1 -0
  74. package/dist/src/di/container.js +208 -0
  75. package/dist/src/di/container.js.map +1 -0
  76. package/dist/src/di/index.d.ts +8 -0
  77. package/dist/src/di/index.d.ts.map +1 -0
  78. package/dist/src/di/index.js +8 -0
  79. package/dist/src/di/index.js.map +1 -0
  80. package/dist/src/errors/http-errors.d.ts +295 -0
  81. package/dist/src/errors/http-errors.d.ts.map +1 -0
  82. package/dist/src/errors/http-errors.js +311 -0
  83. package/dist/src/errors/http-errors.js.map +1 -0
  84. package/dist/src/errors/index.d.ts +8 -0
  85. package/dist/src/errors/index.d.ts.map +1 -0
  86. package/dist/src/errors/index.js +8 -0
  87. package/dist/src/errors/index.js.map +1 -0
  88. package/dist/src/http/app.d.ts +200 -0
  89. package/dist/src/http/app.d.ts.map +1 -0
  90. package/dist/src/http/app.js +45 -0
  91. package/dist/src/http/app.js.map +1 -0
  92. package/dist/src/http/context.d.ts +66 -0
  93. package/dist/src/http/context.d.ts.map +1 -0
  94. package/dist/src/http/context.js +96 -0
  95. package/dist/src/http/context.js.map +1 -0
  96. package/dist/src/http/controller.d.ts +178 -0
  97. package/dist/src/http/controller.d.ts.map +1 -0
  98. package/dist/src/http/controller.js +317 -0
  99. package/dist/src/http/controller.js.map +1 -0
  100. package/dist/src/http/error-handler.d.ts +59 -0
  101. package/dist/src/http/error-handler.d.ts.map +1 -0
  102. package/dist/src/http/error-handler.js +124 -0
  103. package/dist/src/http/error-handler.js.map +1 -0
  104. package/dist/src/http/instance.d.ts +14 -0
  105. package/dist/src/http/instance.d.ts.map +1 -0
  106. package/dist/src/http/instance.js +309 -0
  107. package/dist/src/http/instance.js.map +1 -0
  108. package/dist/src/http/middleware.d.ts +54 -0
  109. package/dist/src/http/middleware.d.ts.map +1 -0
  110. package/dist/src/http/middleware.js +101 -0
  111. package/dist/src/http/middleware.js.map +1 -0
  112. package/dist/src/http/openapi.d.ts +34 -0
  113. package/dist/src/http/openapi.d.ts.map +1 -0
  114. package/dist/src/http/openapi.js +123 -0
  115. package/dist/src/http/openapi.js.map +1 -0
  116. package/dist/src/http/path-params.d.ts +24 -0
  117. package/dist/src/http/path-params.d.ts.map +1 -0
  118. package/dist/src/http/path-params.js +2 -0
  119. package/dist/src/http/path-params.js.map +1 -0
  120. package/dist/src/http/routes.d.ts +119 -0
  121. package/dist/src/http/routes.d.ts.map +1 -0
  122. package/dist/src/http/routes.js +2 -0
  123. package/dist/src/http/routes.js.map +1 -0
  124. package/dist/src/http/server.d.ts +23 -0
  125. package/dist/src/http/server.d.ts.map +1 -0
  126. package/dist/src/http/server.js +22 -0
  127. package/dist/src/http/server.js.map +1 -0
  128. package/dist/src/index.d.ts +22 -0
  129. package/dist/src/index.d.ts.map +1 -0
  130. package/dist/src/index.js +19 -0
  131. package/dist/src/index.js.map +1 -0
  132. package/dist/src/testing/index.d.ts +7 -0
  133. package/dist/src/testing/index.d.ts.map +1 -0
  134. package/dist/src/testing/index.js +7 -0
  135. package/dist/src/testing/index.js.map +1 -0
  136. package/dist/src/testing/test-client.d.ts +65 -0
  137. package/dist/src/testing/test-client.d.ts.map +1 -0
  138. package/dist/src/testing/test-client.js +77 -0
  139. package/dist/src/testing/test-client.js.map +1 -0
  140. package/package.json +88 -3
package/README.md CHANGED
@@ -1,3 +1,1418 @@
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, and scaffolding.
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
+ - [Project structure](#project-structure)
32
+ - [Configuration](#configuration)
33
+ - [Controllers and routes](#controllers-and-routes)
34
+ - [Validation](#validation)
35
+ - [Middleware](#middleware)
36
+ - [Dependency injection](#dependency-injection)
37
+ - [Lifecycle](#lifecycle)
38
+ - [Errors](#errors)
39
+ - [The request context](#the-request-context)
40
+ - [Logging](#logging)
41
+ - [Security defaults](#security-defaults)
42
+ - [defineApp options](#defineapp-options)
43
+ - [App reference](#app-reference)
44
+ - [Typed client](#typed-client)
45
+ - [OpenAPI](#openapi)
46
+ - [Testing](#testing)
47
+ - [CLI](#cli)
48
+ - [Working with the ecosystem](#working-with-the-ecosystem)
49
+ - [Production checklist](#production-checklist)
50
+ - [Performance](#performance)
51
+ - [AI coding agents](#ai-coding-agents)
52
+ - [FAQ](#faq)
53
+ - [Contributing](#contributing)
54
+ - [License](#license)
55
+
56
+ ## Why brisk
57
+
58
+ 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.
59
+
60
+ brisk sits in between:
61
+
62
+ - **Express mental model.** A controller is a function that declares routes. Middlewares are functions. Express is always reachable.
63
+ - **Zod first.** Schemas validate and type `params`, `query`, `body` and `headers`, and response schemas strip what clients must not see.
64
+ - **Typed middleware output.** A middleware that returns `{ user }` gives every route that uses it a typed `ctx.user`.
65
+ - **Dependency injection without decorators.** `readonly #repo = inject(UsersRepository)` in a class field. Services and repositories are plain classes.
66
+ - **Just throw.** Error classes carry status, code and headers, and every error reaches the client in one response shape.
67
+ - **Typed client and OpenAPI from the same routes.** Named routes become `api.users.get(...)` in tests and frontends, and an OpenAPI 3.1 document.
68
+ - **Production lifecycle.** Init, ready, drain and shutdown hooks on provider classes, graceful shutdown, crash handling.
69
+ - **No build step in development.** Node runs your TypeScript directly.
70
+
71
+ Six ideas, nothing else:
72
+
73
+ | Idea | You write | You get |
74
+ | ------------------------ | ------------------------------------------- | ------------------------------------------------------------------- |
75
+ | `defineEnv` | a Zod shape | typed, frozen config; the app refuses to boot with a bad `.env` |
76
+ | `defineController` | routes with Zod schemas | validated, typed `params` `query` `body` `headers`; response DTOs |
77
+ | plain classes | services and repositories | business logic with no HTTP in it |
78
+ | `inject` / `defineToken` | `readonly #repo = inject(UsersRepository)` | singletons, swappable in tests, no decorators or constructor wiring |
79
+ | `defineMiddleware` | a function returning `{ user }` | `ctx.user`, typed, in every route that uses it |
80
+ | error classes | `throw new NotFoundError("User not found")` | status, code, headers and one response shape; no try/catch |
81
+
82
+ ## Requirements
83
+
84
+ - Node.js 24 or later.
85
+ - npm or pnpm (any package manager works).
86
+ - TypeScript 5.8 or later (including 7) with `erasableSyntaxOnly`, so Node can run your `.ts` files directly.
87
+ - ES modules (`"type": "module"`).
88
+
89
+ ## Installation
90
+
91
+ ```bash
92
+ npm install @raiyyan/brisk
93
+ npm install --save-dev typescript @types/node vitest
94
+ ```
95
+
96
+ ```bash
97
+ pnpm add @raiyyan/brisk
98
+ pnpm add --save-dev typescript @types/node vitest
99
+ ```
100
+
101
+ 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`.
102
+
103
+ ## Quick start
104
+
105
+ In an empty folder, with npm:
106
+
107
+ ```bash
108
+ npm init -y
109
+ npm install @raiyyan/brisk
110
+ npm install --save-dev typescript @types/node vitest
111
+ npx brisk init
112
+ cp .env.example .env
113
+ npx brisk g feature products
114
+ npx tsc --noEmit
115
+ npx vitest run
116
+ npm run dev
117
+ ```
118
+
119
+ With pnpm:
120
+
121
+ ```bash
122
+ pnpm init
123
+ pnpm add @raiyyan/brisk
124
+ pnpm add --save-dev typescript @types/node vitest
125
+ pnpm exec brisk init
126
+ cp .env.example .env
127
+ pnpm brisk g feature products
128
+ pnpm typecheck
129
+ pnpm test
130
+ pnpm dev
131
+ ```
132
+
133
+ The server listens on port 3000:
134
+
135
+ ```bash
136
+ curl -X POST localhost:3000/api/products -H 'content-type: application/json' -d '{"name":"Desk"}'
137
+ curl localhost:3000/api/products
138
+ curl -X POST localhost:3000/api/products -H 'content-type: application/json' -d '{"name":""}' # 400 with field issues
139
+ ```
140
+
141
+ `brisk init` asks about a database, auth, Docker, CI and web extras (or takes [flags](#brisk-init)), then installs the packages it needs. It never overwrites existing files, so it is safe in an existing project: it only adds missing files, `"type": "module"` and missing scripts.
142
+
143
+ ### Adding brisk to an existing Express project
144
+
145
+ Move your existing Express setup into `configureExpress`. Routers, middlewares and routes keep working unchanged, and new features are written as brisk controllers next to them:
146
+
147
+ ```ts
148
+ export const app = defineApp({
149
+ controllers,
150
+ configureExpress: (expressApp) => {
151
+ expressApp.use(express.json()); // your legacy routes read req.body before brisk parses JSON
152
+ expressApp.use(legacyRouter); // your existing express.Router()
153
+ },
154
+ });
155
+ ```
156
+
157
+ Then migrate route by route: write the new route in a controller, delete the old one.
158
+
159
+ ## Project structure
160
+
161
+ `brisk init` and `brisk g feature` create this layout:
162
+
163
+ ```
164
+ src/
165
+ env.ts defineEnv: typed configuration
166
+ app.ts export const app = defineApp({ prefix: "/api", ... }); export type Api = typeof app
167
+ main.ts await app.listen(env.PORT)
168
+ middlewares/<name>.ts defineMiddleware (brisk g middleware)
169
+ features/
170
+ index.ts export const controllers = { products: productsController } (keep the @brisk markers)
171
+ products/
172
+ products.schema.ts Zod schemas: what clients send and may see
173
+ products.repository.ts data access only
174
+ products.service.ts business rules; throws error classes
175
+ products.controller.ts defineController: HTTP only, delegates to the service
176
+ test/
177
+ products.test.ts HTTP tests with createTestClient
178
+ .env.example
179
+ .gitignore
180
+ tsconfig.json lets Node run .ts directly
181
+ vitest.config.ts test environment variables
182
+ AGENTS.md, CLAUDE.md guide for AI coding agents (brisk agents)
183
+ ```
184
+
185
+ The options of `brisk init` add more (a database provider, auth, Docker, CI); see [CLI](#cli).
186
+
187
+ `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:
188
+
189
+ ```ts
190
+ // src/app.ts
191
+ import { createLogger, defineApp } from "@raiyyan/brisk";
192
+ import { env } from "./env.ts";
193
+ import { controllers } from "./features/index.ts";
194
+
195
+ export const app = defineApp({
196
+ prefix: "/api",
197
+ controllers,
198
+ logger: createLogger({ level: env.NODE_ENV === "test" ? "silent" : env.LOG_LEVEL }),
199
+ errors: { exposeInternal: env.NODE_ENV === "development" },
200
+ });
201
+
202
+ /** The API's type, for a frontend's typed client. */
203
+ export type Api = typeof app;
204
+ ```
205
+
206
+ ```ts
207
+ // src/main.ts
208
+ import { app } from "./app.ts";
209
+ import { env } from "./env.ts";
210
+
211
+ await app.listen(env.PORT);
212
+ ```
213
+
214
+ Each layer of a feature has one job:
215
+
216
+ - **schema**: Zod schemas for request bodies, params, queries and responses.
217
+ - **repository**: reads and writes data. No HTTP, no business rules.
218
+ - **service**: business rules. Takes and returns plain values, throws error classes (`NotFoundError`, `ConflictError`, ...). Knows nothing about Express.
219
+ - **controller**: maps HTTP to service calls. Reads top to bottom.
220
+
221
+ ```ts
222
+ // products.schema.ts
223
+ import { z } from "@raiyyan/brisk";
224
+
225
+ export const CreateProductBody = z.object({ name: z.string().trim().min(1) });
226
+ export const ProductIdParams = z.object({ id: z.uuid() });
227
+ export const ProductResponse = z.object({ id: z.string(), name: z.string() });
228
+ export type CreateProductBody = z.infer<typeof CreateProductBody>;
229
+ ```
230
+
231
+ ```ts
232
+ // products.repository.ts
233
+ export interface Product {
234
+ id: string;
235
+ name: string;
236
+ }
237
+
238
+ export class ProductsRepository {
239
+ readonly #rows = new Map<string, Product>();
240
+
241
+ findById(id: string): Product | undefined {
242
+ return this.#rows.get(id);
243
+ }
244
+
245
+ save(row: Product): Product {
246
+ this.#rows.set(row.id, row);
247
+ return row;
248
+ }
249
+ }
250
+ ```
251
+
252
+ ```ts
253
+ // products.service.ts
254
+ import { randomUUID } from "node:crypto";
255
+ import { inject, NotFoundError } from "@raiyyan/brisk";
256
+ import { ProductsRepository, type Product } from "./products.repository.ts";
257
+ import type { CreateProductBody } from "./products.schema.ts";
258
+
259
+ export class ProductsService {
260
+ readonly #repository = inject(ProductsRepository);
261
+
262
+ get(id: string): Product {
263
+ const row = this.#repository.findById(id);
264
+ if (!row) throw new NotFoundError("Product not found");
265
+ return row;
266
+ }
267
+
268
+ create(input: CreateProductBody): Product {
269
+ return this.#repository.save({ id: randomUUID(), ...input });
270
+ }
271
+ }
272
+ ```
273
+
274
+ ```ts
275
+ // products.controller.ts
276
+ import { defineController, inject } from "@raiyyan/brisk";
277
+ import { CreateProductBody, ProductIdParams, ProductResponse } from "./products.schema.ts";
278
+ import { ProductsService } from "./products.service.ts";
279
+
280
+ export const productsController = defineController({ prefix: "/products" }, (route) => {
281
+ const products = inject(ProductsService);
282
+
283
+ return {
284
+ get: route.get("/:id", { params: ProductIdParams, response: ProductResponse }, ({ params }) =>
285
+ products.get(params.id),
286
+ ),
287
+ create: route.post("/", { body: CreateProductBody, response: ProductResponse }, ({ body }) =>
288
+ products.create(body),
289
+ ),
290
+ };
291
+ });
292
+ ```
293
+
294
+ ## Configuration
295
+
296
+ ```ts
297
+ // src/env.ts
298
+ import { defineEnv, z } from "@raiyyan/brisk";
299
+
300
+ export const env = defineEnv({
301
+ NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
302
+ PORT: z.coerce.number().int().positive().default(3000),
303
+ DATABASE_URL: z.url(),
304
+ API_TOKEN: z.string().min(16),
305
+ });
306
+
307
+ env.PORT; // number
308
+ ```
309
+
310
+ `defineEnv(shape, options?)`:
311
+
312
+ - Loads `.env` into `process.env` when the file exists, then validates `process.env` against the shape.
313
+ - Returns a frozen, fully typed object.
314
+ - 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.
315
+ - 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.
316
+
317
+ | Option | Type | Default | Description |
318
+ | -------- | ------------------------------------- | -------- | ------------------------------------------------------------- |
319
+ | `file` | `string` | `".env"` | Dotenv file to load when present |
320
+ | `source` | `Record<string, string \| undefined>` | none | Validate this object instead of `process.env`; failures throw |
321
+
322
+ ## Controllers and routes
323
+
324
+ ```ts
325
+ import { defineController, inject } from "@raiyyan/brisk";
326
+
327
+ export const ordersController = defineController({ prefix: "/orders", use: [authenticate] }, (route) => {
328
+ const orders = inject(OrdersService);
329
+
330
+ return {
331
+ list: route.get("/", { query: ListOrdersQuery, response: OrderResponse.array() }, ({ query, user }) =>
332
+ orders.list(user.id, query),
333
+ ),
334
+ get: route.get("/:id", ({ params }) => orders.get(params.id)), // params typed as { id: string }
335
+ create: route.post("/", { body: CreateOrderBody, response: OrderResponse }, ({ body, user }) =>
336
+ orders.create(user.id, body),
337
+ ),
338
+ admin: route.group("/admin", { use: [requireRole("admin")] }, (admin) => ({
339
+ remove: admin.delete("/:id", ({ params }) => orders.remove(params.id)),
340
+ })),
341
+ };
342
+ });
343
+ ```
344
+
345
+ `defineController(options, setup)`:
346
+
347
+ | Option | Type | Description |
348
+ | -------- | -------------- | -------------------------------------------------------------------------- |
349
+ | `prefix` | `string` | Mount path, joined to the app's `prefix` |
350
+ | `use` | `Middleware[]` | Middlewares for every route; their outputs are typed on each handler's ctx |
351
+
352
+ `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`).
353
+
354
+ - Route methods (`route.get`, `route.post`, `route.put`, `route.patch`, `route.delete`) take `(path, handler)` or `(path, options, handler)` and return the route.
355
+ - `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.
356
+ - 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.
357
+ - 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.
358
+
359
+ | Route option | Type | Description |
360
+ | ------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------ |
361
+ | `params` | Zod schema | Validates path params; the parsed value is `ctx.params` |
362
+ | `query` | Zod schema | Validates the query string; `ctx.query` |
363
+ | `body` | Zod schema | Validates the request body; `ctx.body` |
364
+ | `headers` | Zod schema | Validates request headers (lower-case names); `ctx.headers` |
365
+ | `response` | Zod schema | The handler's result is parsed through it: its return type is checked and unlisted fields are stripped |
366
+ | `use` | `Middleware[]` | Route middlewares, run after the controller's and the group's |
367
+ | `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 |
368
+
369
+ Without a `params` schema, path params are typed from the full path, controller prefix and groups included:
370
+
371
+ | Path | `ctx.params` |
372
+ | --------------------------------------- | ------------------------------- |
373
+ | `"/members/:id"` under `"/orgs/:orgId"` | `{ orgId: string; id: string }` |
374
+ | `"/files/*path"` | `{ path: string[] }` |
375
+ | `"/posts{/:slug}"` | `{ slug?: string }` |
376
+
377
+ 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.
378
+
379
+ ### What a handler returns
380
+
381
+ The return value is the response:
382
+
383
+ | Return value | Response |
384
+ | ------------------------- | ---------------------------------------------------------------- |
385
+ | `undefined` (or nothing) | `204 No Content` (or `status`) |
386
+ | a Node `Readable` | streamed to the client |
387
+ | a `Buffer` / `Uint8Array` | sent as bytes (`application/octet-stream` unless you set a type) |
388
+ | anything else | JSON, parsed through `response` when set |
389
+
390
+ 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.
391
+
392
+ 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`.
393
+
394
+ ```ts
395
+ import { createReadStream } from "node:fs";
396
+
397
+ return {
398
+ report: route.get("/report.csv", ({ res }) => {
399
+ res.type("text/csv").attachment("report.csv");
400
+ return createReadStream("report.csv");
401
+ }),
402
+ logo: route.get("/logo", ({ res }) => res.sendFile("/srv/assets/logo.png")),
403
+ };
404
+ ```
405
+
406
+ Handlers and setup may be sync or async. A thrown error, sync or async, becomes the error response; never call `next()`.
407
+
408
+ ## Validation
409
+
410
+ Every input schema is a Zod schema. Invalid input stops the request before your handler runs and produces a `400`:
411
+
412
+ ```json
413
+ {
414
+ "error": {
415
+ "code": "VALIDATION_FAILED",
416
+ "message": "Invalid request body",
417
+ "details": {
418
+ "location": "body",
419
+ "issues": [{ "path": "email", "message": "Invalid email address", "code": "invalid_format" }]
420
+ },
421
+ "requestId": "3f0c9a1e-..."
422
+ }
423
+ }
424
+ ```
425
+
426
+ - `location` is `params`, `query`, `body` or `headers`. `path` is a dot path (`items.0.qty`), ready to map onto form fields.
427
+ - 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.
428
+ - 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())`.
429
+ - Async refinements work (`z.string().refine(async (email) => !(await users.exists(email)))`); schemas without them stay on the synchronous fast path.
430
+ - JSON bodies are parsed for `application/json` and every `application/*+json` type, such as `application/merge-patch+json`.
431
+ - 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.
432
+ - `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.
433
+ - 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.
434
+ - A `ZodError` thrown anywhere in your code (for example a service calling `schema.parse(...)`) becomes the same 400 with `location: "input"`.
435
+ - A handler result that does not match its `response` schema is a server bug and becomes a 500, never a 400.
436
+
437
+ ```ts
438
+ export const ListOrdersQuery = z.object({
439
+ status: z.enum(["open", "paid"]).optional(),
440
+ page: z.coerce.number().int().min(1).default(1),
441
+ limit: z.coerce.number().int().min(1).max(100).default(20),
442
+ });
443
+ ```
444
+
445
+ ## Middleware
446
+
447
+ ```ts
448
+ import { defineMiddleware, ForbiddenError, UnauthorizedError } from "@raiyyan/brisk";
449
+
450
+ export const authenticate = defineMiddleware(async ({ req }) => {
451
+ const user = await verifyToken(req.get("authorization"));
452
+ if (!user) throw new UnauthorizedError("Invalid token", { challenge: "Bearer" });
453
+ return { user };
454
+ }, "authenticate");
455
+
456
+ export const requireRole = (role: "admin" | "member") =>
457
+ defineMiddleware(async (ctx) => {
458
+ const { user } = await authenticate.run(ctx);
459
+ if (user.role !== role) throw new ForbiddenError(`Requires role: ${role}`);
460
+ return { user };
461
+ });
462
+ ```
463
+
464
+ `defineMiddleware(fn, name?)`:
465
+
466
+ - **Return an object** to add typed fields to the handler's ctx (`return { user }` gives `ctx.user`).
467
+ - **Return nothing** for a pure check.
468
+ - **Throw an error class** to stop the request.
469
+ - **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.
470
+ - **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.
471
+ - **Sync or async**, and `inject()` works inside.
472
+ - Order: controller `use`, then group `use`, then route `use`, then 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.
473
+
474
+ Plain Express middleware (`cors()`, `helmet()`, rate limiters, multer) goes in [`configureExpress`](#working-with-the-ecosystem).
475
+
476
+ ## Dependency injection
477
+
478
+ ```ts
479
+ import { defineApp, defineToken, inject, injectLazy, provide } from "@raiyyan/brisk";
480
+
481
+ export const Clock = defineToken<() => Date>("Clock", () => () => new Date());
482
+
483
+ export class UsersRepository {
484
+ readonly #db = inject(Database);
485
+ }
486
+
487
+ export class UsersService {
488
+ readonly #users = inject(UsersRepository);
489
+ readonly #mailer = injectLazy(Mailer); // created on first call: this.#mailer().send(...)
490
+ readonly #now = inject(Clock);
491
+ }
492
+
493
+ export const app = defineApp({ controllers, providers: [provide(Clock, () => new Date("2026-01-01"))] });
494
+ ```
495
+
496
+ - **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.
497
+ - **`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`.
498
+ - **`provide(token, value)`** fixes a value for a token in `defineApp({ providers })`, in the `providers` of `listen`, `start`, `create` or `createTestClient` (which replace the app's own, for fakes in tests), or in `new Container([...])`. Later entries win.
499
+ - **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".
500
+ - **`injectLazy(token)`** returns `() => T` and creates the dependency on first call: for heavy clients, or to break a circular dependency.
501
+ - **Where `inject()` works:** wherever the app runs your code: class fields, token factories, controller setup, every request (brisk routes, middlewares, and everything in `configureExpress`, so GraphQL resolvers and tRPC procedures too), lifecycle hooks, and timers and promises started from any of those.
502
+ - **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.
503
+ - **`HttpServer`** exists only once the instance listens: inject it in `onReady`. Injecting it in a class field or `onInit` throws.
504
+ - **Circular dependencies** are reported as `A -> B -> A`. Break them with `injectLazy`.
505
+ - **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:
506
+
507
+ ```ts
508
+ import { Container, provide } from "@raiyyan/brisk";
509
+
510
+ const container = new Container([provide(Clock, () => new Date("2030-01-01"))]);
511
+ const users = container.get(UsersService);
512
+ ```
513
+
514
+ ## Lifecycle
515
+
516
+ A provider class joins the lifecycle by implementing any of these interfaces. Each method may be sync or async, and `inject()` works inside.
517
+
518
+ | Interface | Method | When |
519
+ | ------------ | ------------ | -------------------------------------------------------------------------------------------------------- |
520
+ | `OnInit` | `onInit` | Before the server listens, dependencies first: connect databases, open pools |
521
+ | `OnReady` | `onReady` | Once the server accepts requests: attach WebSockets (inject `HttpServer` here), start jobs and consumers |
522
+ | `OnDrain` | `onDrain` | The moment shutdown starts: close WebSockets and SSE, stop consumers and timers |
523
+ | `OnShutdown` | `onShutdown` | After the last in-flight request, dependents first: release clients and pools |
524
+
525
+ ```ts
526
+ import { inject, Logger, type OnInit, type OnShutdown } from "@raiyyan/brisk";
527
+
528
+ export class Database implements OnInit, OnShutdown {
529
+ readonly #log = inject(Logger);
530
+ #pool: Pool | undefined;
531
+
532
+ async onInit() {
533
+ this.#pool = await connect(env.DATABASE_URL);
534
+ this.#log.info("database connected");
535
+ }
536
+
537
+ async onShutdown() {
538
+ await this.#pool?.end();
539
+ }
540
+ }
541
+ ```
542
+
543
+ Startup and shutdown work always lives in a provider class. `defineApp({ hooks })` takes only per-request hooks: `onRequest(ctx)`, `onResponse(ctx, { status, durationMs, aborted })` and `onError(error, ctx)`. `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;`.
544
+
545
+ ```
546
+ boot providers' onInit (dependencies first) -> listen -> providers' onReady
547
+ request configureExpress -> JSON body -> hooks.onRequest -> middlewares -> validation -> handler
548
+ -> response -> hooks.onResponse (any error -> hooks.onError -> error response)
549
+ shutdown SIGINT/SIGTERM -> stop accepting connections -> providers' onDrain (reverse)
550
+ -> in-flight requests finish, with Connection: close (sockets still open after shutdownTimeoutMs are destroyed)
551
+ -> providers' onShutdown (reverse) -> exit 0
552
+ (a different second signal is ignored; the same signal again exits 1 at once;
553
+ still running shutdownTimeoutMs + 10 s after the signal -> exit 1)
554
+ crash unhandled rejection or uncaught exception -> logged as fatal -> shutdown -> exit 1
555
+ ```
556
+
557
+ A failing `onDrain` or `onShutdown` is logged and shutdown continues. `app.start()` runs the same lifecycle without an HTTP server.
558
+
559
+ Each `listen`, `start` or `create` builds a new, independent instance with its own container, so one app can run in many tests at once. If startup fails (an `onInit` throws, the port is taken), brisk runs the drain and shutdown hooks for what already started, then rethrows, so pools and clients are released.
560
+
561
+ ## Errors
562
+
563
+ Throw from anywhere: handlers, services, repositories, middlewares. Never catch an error just to build a response.
564
+
565
+ ```ts
566
+ throw new NotFoundError("User not found");
567
+ throw new ConflictError("Email already registered", { code: "EMAIL_TAKEN", details: { email } });
568
+ throw new TooManyRequestsError("Slow down", { retryAfter: 30 }); // sends Retry-After: 30
569
+ throw new UnauthorizedError("Login required", { challenge: "Bearer" }); // sends WWW-Authenticate
570
+ throw new BadGatewayError("Payment provider failed", { cause: error }); // cause is logged, never sent
571
+ ```
572
+
573
+ | Class | Status | Notes |
574
+ | --------------------------- | ------ | ---------------------------------------------------------------- |
575
+ | `BadRequestError` | 400 | |
576
+ | `ValidationError` | 400 | `(location, issues)`; `ValidationError.fromZod(error, location)` |
577
+ | `UnauthorizedError` | 401 | `challenge` option sends `WWW-Authenticate` |
578
+ | `ForbiddenError` | 403 | |
579
+ | `NotFoundError` | 404 | |
580
+ | `MethodNotAllowedError` | 405 | `(allowed, message?, options?)` sends `Allow` |
581
+ | `NotAcceptableError` | 406 | |
582
+ | `RequestTimeoutError` | 408 | |
583
+ | `ConflictError` | 409 | |
584
+ | `GoneError` | 410 | |
585
+ | `PreconditionFailedError` | 412 | a stale `If-Match` in optimistic concurrency |
586
+ | `PayloadTooLargeError` | 413 | |
587
+ | `UnsupportedMediaTypeError` | 415 | |
588
+ | `UnprocessableEntityError` | 422 | |
589
+ | `TooManyRequestsError` | 429 | `retryAfter` option (seconds) sends `Retry-After` |
590
+ | `InternalServerError` | 500 | its message is sent: keep it generic, put details in `cause` |
591
+ | `NotImplementedError` | 501 | |
592
+ | `BadGatewayError` | 502 | an upstream service answered badly |
593
+ | `ServiceUnavailableError` | 503 | `retryAfter` option sends `Retry-After` |
594
+ | `GatewayTimeoutError` | 504 | an upstream service did not answer in time |
595
+ | `HttpError` | any | the base class: `new HttpError(status, message, options)` |
596
+
597
+ Every class takes `(message?, options?)`:
598
+
599
+ | Option | Description |
600
+ | --------- | --------------------------------------------------------------------- |
601
+ | `code` | Machine-readable code. Default: derived from the status (`NOT_FOUND`) |
602
+ | `details` | Extra data sent to the client as `error.details` |
603
+ | `cause` | The underlying error: logged with its stack, never sent |
604
+ | `headers` | Response headers sent with the error |
605
+
606
+ Every error response has one shape: `{ "error": { "code", "message", "details", "requestId" } }`.
607
+
608
+ ### Domain errors
609
+
610
+ ```ts
611
+ import { ConflictError, ValidationError } from "@raiyyan/brisk";
612
+
613
+ export class EmailTakenError extends ConflictError {
614
+ constructor(email: string) {
615
+ super("Email already registered", { code: "EMAIL_TAKEN", details: { email } });
616
+ }
617
+ }
618
+
619
+ throw new ValidationError("body", [{ path: "slug", message: "Already used", code: "taken" }]);
620
+ ```
621
+
622
+ ### Handled automatically
623
+
624
+ | Situation | Response |
625
+ | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
626
+ | Invalid params, query, body or headers | 400 `VALIDATION_FAILED` with field issues |
627
+ | `ZodError` thrown in your code | 400 `VALIDATION_FAILED`, `location: "input"` |
628
+ | Malformed JSON | 400 `INVALID_JSON` |
629
+ | Body over `http.bodyLimit` | 413 `PAYLOAD_TOO_LARGE` |
630
+ | Unsupported body charset or encoding | 415 `UNSUPPORTED_MEDIA_TYPE` |
631
+ | 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) |
632
+ | An Express middleware error following `http-errors` (`status` + `expose`) | that status; the message only when `expose` is true |
633
+ | Known path, wrong method (across all controllers) | 405 with `Allow`; `OPTIONS` answers 204 with `Allow` |
634
+ | Unknown path | 404 `ROUTE_NOT_FOUND` |
635
+ | Handler result breaks its `response` schema | 500: a server bug |
636
+ | Error after the response started (a failing stream) | connection destroyed, so the client never mistakes it for a complete body |
637
+ | Anything else thrown | 500 `INTERNAL_SERVER_ERROR` with a generic message; full error logged |
638
+ | Unhandled rejection or uncaught exception | logged as fatal, graceful shutdown, exit 1 |
639
+
640
+ 5xx errors are logged at `error` with stack and cause; 4xx at `debug` (the request log line already carries the status).
641
+
642
+ ### Error mappers and formatters
643
+
644
+ ```ts
645
+ export const app = defineApp({
646
+ controllers,
647
+ errors: {
648
+ // Translate library errors in one place instead of try/catch in services. The first match wins.
649
+ mappers: [
650
+ (error) =>
651
+ error instanceof DatabaseError && error.code === "23505"
652
+ ? new ConflictError("Already exists")
653
+ : undefined,
654
+ ],
655
+ // Change the error body, for example to RFC 9457 problem details.
656
+ format: (error, ctx) => ({
657
+ type: `https://errors.example.com/${error.code}`,
658
+ title: error.message,
659
+ status: error.status,
660
+ instance: ctx.req.originalUrl,
661
+ }),
662
+ },
663
+ hooks: { onError: (error, ctx) => reportToTracker(error, ctx.requestId) },
664
+ });
665
+ ```
666
+
667
+ `defaultErrorFormatter` is exported for formatters that only add fields. A mapper or `onError` hook that throws is logged and never breaks the response.
668
+
669
+ ORMs often wrap the driver's error (Drizzle keeps it in `cause`), so a mapper should walk the `cause` chain. `brisk init --db sqlite` or `--db postgres` generates one in `src/database/errors.ts` that turns a unique violation into 409 `ALREADY_EXISTS`:
670
+
671
+ ```ts
672
+ export const uniqueViolation: ErrorMapper = (error) => {
673
+ for (let current: unknown = error; current instanceof Error; current = current.cause) {
674
+ if ("code" in current && current.code === "23505") {
675
+ return new ConflictError("Already exists", { code: "ALREADY_EXISTS", cause: error });
676
+ }
677
+ }
678
+ return undefined;
679
+ };
680
+ ```
681
+
682
+ ## The request context
683
+
684
+ Every handler and middleware receives a context:
685
+
686
+ | Field | Description |
687
+ | ----------- | ----------------------------------------------------------------------------------------- |
688
+ | `req` | The Express request |
689
+ | `res` | The Express response; write to it directly to stream or send files |
690
+ | `requestId` | The caller's `x-request-id` when it is safe, otherwise a new UUID; echoed in the response |
691
+ | `log` | A logger for this request: every line carries the request id |
692
+
693
+ 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.
694
+
695
+ Code that only has the Express `req` (plain middlewares and routes in `configureExpress`) uses `getContext(req)`:
696
+
697
+ ```ts
698
+ import { getContext } from "@raiyyan/brisk";
699
+
700
+ app.get("/events", (req, res) => {
701
+ getContext(req).log.info("stream opened");
702
+ });
703
+ ```
704
+
705
+ ## Logging
706
+
707
+ ```ts
708
+ import { createLogger, defineApp } from "@raiyyan/brisk";
709
+
710
+ export const app = defineApp({
711
+ controllers,
712
+ logger: createLogger({ level: env.LOG_LEVEL, pretty: env.NODE_ENV === "development" }),
713
+ });
714
+ ```
715
+
716
+ - `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.
717
+ - Pass any pino logger as `logger`.
718
+ - The app logger is injectable: `readonly #log = inject(Logger)`.
719
+ - `ctx.log` carries the request id on every line.
720
+ - 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.
721
+ - `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`.
722
+
723
+ ## Security defaults
724
+
725
+ - **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`.
726
+ - **Override them** with `http.securityHeaders`: `false` turns them off; an object overrides individual headers, and `false` as a value removes one:
727
+
728
+ ```ts
729
+ export const app = defineApp({
730
+ controllers,
731
+ http: { securityHeaders: { "content-security-policy": "default-src 'self'", "x-frame-options": false } },
732
+ });
733
+ ```
734
+
735
+ - **Request ids** from callers are accepted only when short and plain (letters, digits, `_ . : -`, up to 128 characters), so they cannot forge log lines.
736
+ - **Body limit** of `1mb` for JSON (`http.bodyLimit`).
737
+ - **Flat query strings:** query values are strings or arrays of strings, never nested objects. If you need nested query objects, enable them with `configureExpress: (expressApp) => expressApp.set("query parser", "extended")`.
738
+ - **No mass assignment:** unknown body fields are stripped by your schemas.
739
+ - **`trust proxy`** stays off unless you set `http.trustProxy`. Set it only behind a proxy you control, otherwise clients can spoof `req.ip`.
740
+ - **Internal error messages** never reach clients unless `errors.exposeInternal` is on (development only).
741
+ - CORS and rate limiting are plain Express middlewares in `configureExpress`.
742
+
743
+ ## defineApp options
744
+
745
+ `defineApp(options)` declares the app. Every option is optional.
746
+
747
+ | Option | Type | Default | Description |
748
+ | ------------------ | ------------------------ | ---------------- | ------------------------------------------------------------------------------- |
749
+ | `prefix` | `string` | `"/"` | Path every controller is mounted under, such as `/api` |
750
+ | `controllers` | `{ [name]: Controller }` | `{}` | Controllers by name; the names are the typed client's top level |
751
+ | `providers` | `(Provider \| Class)[]` | `[]` | `provide(token, value)` entries, and classes to create at startup |
752
+ | `logger` | `Logger` | `createLogger()` | The app logger; injectable as `Logger` |
753
+ | `configureExpress` | `(expressApp) => void` | none | Full Express access, before JSON parsing and before brisk's routes |
754
+ | `hooks` | `AppHooks` | `{}` | `onRequest`, `onResponse`, `onError` (startup and shutdown belong in providers) |
755
+ | `openapi` | `OpenApiOptions` | not served | Serve an [OpenAPI](#openapi) document |
756
+ | `errors` | `ErrorOptions` | `{}` | How errors become responses (below) |
757
+ | `http` | `HttpOptions` | `{}` | HTTP-level settings (below) |
758
+
759
+ `errors`:
760
+
761
+ | Option | Type | Default | Description |
762
+ | ---------------- | ---------------- | ----------------------- | ------------------------------------------------------------ |
763
+ | `mappers` | `ErrorMapper[]` | `[]` | `(error) => HttpError \| undefined`; the first match wins |
764
+ | `format` | `ErrorFormatter` | `defaultErrorFormatter` | `(error, ctx) => body` for error responses |
765
+ | `exposeInternal` | `boolean` | `false` | Send the real message of unexpected errors. Development only |
766
+
767
+ `http`:
768
+
769
+ | Option | Type | Default | Description |
770
+ | ------------------- | -------------------------------------------- | ----------- | ------------------------------------------------------------------------------------ |
771
+ | `bodyLimit` | `string` | `"1mb"` | JSON body size limit |
772
+ | `trustProxy` | `boolean \| number \| string` | not set | Express `trust proxy` setting; only behind a proxy you control |
773
+ | `securityHeaders` | `boolean \| Record<string, string \| false>` | `true` | Strict API headers; an object overrides individual headers, `false` removes one |
774
+ | `healthPath` | `string \| false` | `"/health"` | Health route answering `{ "status": "ok" }`, outside the prefix; `false` disables it |
775
+ | `shutdownTimeoutMs` | `number` | `10000` | How long shutdown waits for in-flight requests before closing remaining connections |
776
+
777
+ ## App reference
778
+
779
+ `defineApp` returns an `App`. It runs nothing until one of these builds an instance:
780
+
781
+ | Member | Description |
782
+ | ------------------------ | ---------------------------------------------------------------------------------------------- |
783
+ | `listen(port, options?)` | Builds an instance, runs `onInit`, listens, runs `onReady`; resolves with the instance |
784
+ | `start(options?)` | The same without an HTTP server, for workers and schedulers |
785
+ | `create(options?)` | Builds an instance without starting it, for platforms that bring their own server (serverless) |
786
+ | `manifest` | The named routes as data, for the [typed client](#typed-client) (`brisk client` writes it) |
787
+ | `openapi()` | The [OpenAPI](#openapi) document (`brisk openapi` writes it) |
788
+
789
+ 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`.
790
+
791
+ | Option | On | Default | Description |
792
+ | --------------- | --------------------------- | ----------------- | ---------------------------------------------------------------------------------------- |
793
+ | `providers` | `listen`, `start`, `create` | `[]` | Replace the app's own providers, typically with fakes |
794
+ | `manageProcess` | `listen`, `start` | `true` | SIGINT/SIGTERM shut down gracefully and exit 0; crashes are logged, shut down and exit 1 |
795
+ | `host` | `listen` | all interfaces | Interface to bind, such as `0.0.0.0` in a container or `127.0.0.1` for local only |
796
+ | `server` | `listen` | a new HTTP server | Your own `http.Server` or `https.Server`; brisk serves its requests |
797
+
798
+ The instance (`AppInstance`):
799
+
800
+ | Member | Description |
801
+ | ------------ | ------------------------------------------------------------------------------------ |
802
+ | `express` | The Express app |
803
+ | `server` | The listening Node server, or `undefined` when the instance did not listen |
804
+ | `container` | The dependency container: `instance.container.get(UsersService)` |
805
+ | `logger` | The app logger |
806
+ | `manifest` | The named routes as data |
807
+ | `openapi()` | The OpenAPI document |
808
+ | `init()` | Runs the `onInit` hooks once; `listen` and `start` call it. Call it after `create()` |
809
+ | `shutdown()` | Graceful shutdown; safe to call more than once |
810
+
811
+ ```ts
812
+ const instance = await app.listen(env.PORT);
813
+ instance.server?.keepAliveTimeout; // the Node server
814
+ await instance.shutdown();
815
+ ```
816
+
817
+ ## Typed client
818
+
819
+ `@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.
820
+
821
+ ```ts
822
+ import { ApiError, createClient } from "@raiyyan/brisk/client";
823
+ ```
824
+
825
+ `createClient` takes the app itself, or a route manifest plus the app's type:
826
+
827
+ | Form | Where |
828
+ | -------------------------------------- | ---------------------------------------------------------------------------------------------- |
829
+ | `createClient(app, options)` | Node: tests, scripts, server-to-server calls. `app` is the definition or a running instance |
830
+ | `createClient<Api>(manifest, options)` | Browsers: `manifest` is generated by `brisk client`, and `Api` is `typeof app` (`import type`) |
831
+
832
+ 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:
833
+
834
+ ```bash
835
+ npx brisk client --out web/src/api.manifest.ts
836
+ ```
837
+
838
+ ```ts
839
+ // web/src/api.ts
840
+ import { createClient } from "@raiyyan/brisk/client";
841
+ import type { Api } from "../../server/src/app.ts"; // export type Api = typeof app
842
+ import { manifest } from "./api.manifest.ts";
843
+
844
+ export const api = createClient<Api>(manifest, {
845
+ baseUrl: import.meta.env.VITE_API_URL,
846
+ headers: () => ({ authorization: `Bearer ${session.token}` }),
847
+ });
848
+ ```
849
+
850
+ `import type` brings in types only, so no server code reaches the bundle.
851
+
852
+ | Option | Type | Description |
853
+ | --------- | ----------------------------------- | ----------------------------------------------------------- |
854
+ | `baseUrl` | `string` | Origin of the API, such as `https://api.example.com` |
855
+ | `headers` | object, or a function returning one | Headers for every request; a function is called per request |
856
+ | `fetch` | `typeof fetch` | The fetch to use. Default: the global `fetch` |
857
+
858
+ ### Calling routes
859
+
860
+ Each named route is a function at `api.<controller>.<route>`, with groups nested under their names:
861
+
862
+ ```ts
863
+ const user = await api.users.get({ params: { id } });
864
+ const page = await api.users.list({ query: { search: "ada", limit: 20 } });
865
+ const created = await api.users.create({
866
+ body: { email: "ada@example.com", name: "Ada", password: "correct-horse-battery" },
867
+ headers: { authorization: `Bearer ${token}` },
868
+ });
869
+ await api.users.remove({ params: { id } }, { signal: AbortSignal.timeout(5000) });
870
+ ```
871
+
872
+ - 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.
873
+ - Every call accepts extra headers, such as `authorization` for a middleware that reads it.
874
+ - The second argument is passed to `fetch`: `signal`, `credentials`, and so on.
875
+ - Array query values become repeated keys (`?tag=a&tag=b`); a `*splat` param takes an array of segments.
876
+
877
+ A call resolves to:
878
+
879
+ | The route | The call resolves to |
880
+ | ------------------------- | ------------------------------------------------------------------------------------- |
881
+ | has a `response` schema | the schema's output |
882
+ | has none | the handler's return value after JSON: `Date` becomes `string`, functions are dropped |
883
+ | returns a stream or bytes | the raw `Response`, to read as you need |
884
+ | returns nothing | `undefined` (204) |
885
+
886
+ A non-2xx response rejects with `ApiError`:
887
+
888
+ | Field | Description |
889
+ | ----------- | --------------------------------------------------- |
890
+ | `status` | HTTP status |
891
+ | `code` | `error.code` from the body, or `HTTP_ERROR` |
892
+ | `message` | `error.message` from the body, or a generic message |
893
+ | `details` | `error.details` from the body |
894
+ | `requestId` | `error.requestId` from the body |
895
+ | `body` | The parsed body, whatever its shape |
896
+
897
+ ```ts
898
+ try {
899
+ await api.users.create({ body, headers });
900
+ } catch (error) {
901
+ if (error instanceof ApiError && error.code === "EMAIL_TAKEN") return showEmailTaken();
902
+ throw error;
903
+ }
904
+ ```
905
+
906
+ Types: `ApiOf<T>` (an app's routes), `Client<T>` (the client for them), `Jsonify<T>` (a type after JSON), `Manifest`.
907
+
908
+ ## OpenAPI
909
+
910
+ brisk builds an OpenAPI 3.1 document from the routes' Zod schemas. Pass `openapi` to serve it:
911
+
912
+ ```ts
913
+ export const app = defineApp({
914
+ controllers,
915
+ openapi: { title: "Shop API", version: "1.0.0" },
916
+ });
917
+ ```
918
+
919
+ | Option | Type | Default | Description |
920
+ | ------------------ | ------------------------- | ------------------ | -------------------------------------------------------------------- |
921
+ | `title`, `version` | `string` | required | The document's `info` |
922
+ | `description` | `string` | none | The document's `info.description` |
923
+ | `servers` | `{ url, description? }[]` | none (same origin) | Base URLs of the API |
924
+ | `path` | `string \| false` | `"/openapi.json"` | Where it is served, outside the app `prefix`; `false` serves nothing |
925
+
926
+ - `app.openapi()` returns the document, with or without the option (title `API`, version `0.0.0` without it).
927
+ - `brisk openapi --out openapi.json` writes it to a file, for client generators and API gateways.
928
+ - 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`.
929
+ - `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.
930
+ - Express paths are converted: `:id` and `*path` become `{id}` and `{path}`, and an optional group (`/posts{/:slug}`) gives two paths.
931
+ - 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:
932
+
933
+ ```ts
934
+ createdAt: z
935
+ .date()
936
+ .transform((date) => date.toISOString())
937
+ .pipe(z.iso.datetime()), // documented as { type: "string", format: "date-time" }
938
+ ```
939
+
940
+ The typed client then types the field as `string` too.
941
+
942
+ ## Testing
943
+
944
+ ```ts
945
+ import { provide } from "@raiyyan/brisk";
946
+ import { createTestClient, readError } from "@raiyyan/brisk/testing";
947
+ import { afterAll, expect, it } from "vitest";
948
+ import { app } from "../src/app.ts";
949
+ import { Clock } from "../src/database/clock.ts";
950
+
951
+ const api = await createTestClient(app, { providers: [provide(Clock, () => new Date("2026-01-01"))] });
952
+ afterAll(() => api.close());
953
+
954
+ it("creates a product", async () => {
955
+ const product = await api.call.products.create({ body: { name: "Desk" } }); // typed
956
+ expect(await api.call.products.get({ params: { id: product.id } })).toEqual(product);
957
+ });
958
+
959
+ it("rejects an invalid product", async () => {
960
+ const res = await api.post("/api/products", { body: { name: "" } }); // raw
961
+ expect(res.status).toBe(400);
962
+ expect(readError(res)).toMatchObject({ code: "VALIDATION_FAILED", details: { location: "body" } });
963
+ });
964
+ ```
965
+
966
+ - `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).
967
+ - Each test client gets its own instance, so test files run in parallel without sharing state.
968
+ - 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.
969
+ - 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.
970
+ - 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`.
971
+ - `readError(res)` returns the typed `error` object and fails the test if the body is not an error response.
972
+ - Swap dependencies with `providers`: they replace the app's own. Unit-test services without HTTP with `new Container([...]).get(Service)`.
973
+ - To test `304 Not Modified`, use `node:http` rather than `fetch`: `fetch` adds `Cache-Control: no-cache` to requests with `If-None-Match`, which correctly disables 304.
974
+
975
+ ## CLI
976
+
977
+ The package installs a `brisk` command. Run it from the app's root.
978
+
979
+ | Command | What it does |
980
+ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
981
+ | `brisk init` | Scaffolds a runnable app in the current folder (see below), installs missing packages, then runs `brisk agents` |
982
+ | `brisk g feature <name>` | Writes `src/features/<name>/` (schema, repository, service, controller) and `test/<name>.test.ts`, and registers the controller |
983
+ | `brisk g middleware <name>` | Writes a typed middleware in `src/middlewares/<name>.ts` |
984
+ | `brisk client --out <file>` | Writes the route manifest for a frontend's [typed client](#typed-client) |
985
+ | `brisk openapi --out <file>` | Writes the [OpenAPI](#openapi) document as JSON |
986
+ | `brisk agents` | Adds or refreshes the brisk guide for AI coding agents in `AGENTS.md` and `CLAUDE.md` |
987
+ | `brisk help`, `--version` | Lists commands; prints the installed version |
988
+
989
+ ### brisk init
990
+
991
+ `brisk init` creates `src/env.ts`, `src/app.ts` (`export const app = defineApp(...)` with prefix `/api`, and `export type Api`), `src/main.ts` (`await app.listen(env.PORT)`), `src/features/index.ts`, `.env.example` and a `tsconfig.json` that lets Node run `.ts` directly. It never overwrites existing files. It adds `"type": "module"` and any missing `dev`, `start`, `typecheck`, `test` and `brisk` scripts to `package.json`.
992
+
993
+ It also writes `vitest.config.ts` (test environment variables) and `.gitignore`. Without flags it asks about each option; flags answer the questions up front:
994
+
995
+ | Flag | Adds |
996
+ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
997
+ | `--db sqlite` | A `Database` provider on Node's built-in `node:sqlite` (no dependency), opened in `onInit` and closed in `onShutdown` |
998
+ | `--db postgres` | A `Database` provider with `pg` and Drizzle, `src/database/schema.ts`, `drizzle.config.ts`, and `db:generate` / `db:migrate` scripts |
999
+ | `--db` (either) | `src/database/errors.ts`: an error mapper that turns a unique violation into 409 `ALREADY_EXISTS`, registered in `errors.mappers` |
1000
+ | `--auth` | HS256 bearer tokens signed with `node:crypto` (`src/auth/tokens.ts`), `authenticate` and `requireRole` middlewares, and their tests |
1001
+ | `--docker` | A `Dockerfile` (runs as a non-root user, with a health check), `compose.yaml` (with the database for postgres, a volume for sqlite) and `.dockerignore` |
1002
+ | `--ci` | A GitHub Actions workflow running typecheck and tests |
1003
+ | `--web` | CORS, `express-rate-limit`, `express.static` for `public/`, and `pino-pretty` for development logs |
1004
+ | `--pm <pnpm\|npm\|yarn>` | The package manager. Default: the one running the CLI, else the one whose lockfile is present, else npm |
1005
+ | `--yes`, `-y` | Take the defaults (no database, no extras) for every option not given, and ask nothing |
1006
+ | `--no-install` | Print the install commands instead of running them |
1007
+
1008
+ ```bash
1009
+ npx brisk init --db postgres --auth --docker --ci --yes
1010
+ ```
1011
+
1012
+ Packages are installed after the files are written. If an install fails, the files stay in place and the CLI prints the commands to run again; with pnpm it also points to `pnpm approve-builds`, for packages whose build scripts pnpm blocked.
1013
+
1014
+ ### brisk g feature
1015
+
1016
+ - `brisk g feature products` writes `products.schema.ts`, `.repository.ts`, `.service.ts` and `.controller.ts` with singular entity names (`Product`, `CreateProductBody`, `ProductResponse`), a test, and registers `products: productsController` in `src/features/index.ts`. The key is the controller's name in the typed client.
1017
+ - Names may be `kebab-case`, `camelCase`, `PascalCase` or `snake_case`; files use kebab-case.
1018
+ - `g` is short for `generate`.
1019
+ - When `oxfmt` or `prettier` is installed in the project, generated files are formatted with it, so they match your style.
1020
+ - Keep the `// @brisk:imports` and `// @brisk:controllers` markers in `src/features/index.ts`; the CLI registers new controllers there.
1021
+
1022
+ ### brisk client and brisk openapi
1023
+
1024
+ 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.
1025
+
1026
+ ## Working with the ecosystem
1027
+
1028
+ ### Not in your way
1029
+
1030
+ 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:
1031
+
1032
+ - `configureExpress(expressApp)`: full Express access, before JSON parsing and before brisk's routes.
1033
+ - `instance.express`: the Express app (adapters, serverless with `app.create()`).
1034
+ - `instance.server` and the `HttpServer` token: the Node server (WebSockets, timeouts).
1035
+ - `ListenOptions.server`: your own server (HTTPS, tuned timeouts).
1036
+ - Provider lifecycle hooks: run anything alongside HTTP.
1037
+ - `app.start()`: the same app without HTTP.
1038
+ - `ctx.res`: write a raw response from any handler.
1039
+
1040
+ Requests in `configureExpress` have a context (`getContext(req)`), and errors thrown there get the same error responses. Because it runs before JSON parsing, raw, form and multipart bodies are yours to parse; a form body parsed there reaches a brisk route's `body` schema.
1041
+
1042
+ ### Express middleware, static files, uploads, webhooks
1043
+
1044
+ ```ts
1045
+ import cors from "cors";
1046
+ import express from "express";
1047
+ import multer from "multer";
1048
+
1049
+ export const app = defineApp({
1050
+ controllers,
1051
+ configureExpress: (expressApp) => {
1052
+ expressApp.use(cors({ origin: env.WEB_ORIGIN }));
1053
+ expressApp.use("/static", express.static("public"));
1054
+ expressApp.post("/webhooks/stripe", express.raw({ type: "application/json" }), stripeWebhook); // raw bytes for signatures
1055
+ expressApp.post("/upload", multer({ dest: "uploads/" }).single("file"), (req, res) => {
1056
+ res.json({ name: req.file?.originalname });
1057
+ });
1058
+ expressApp.use(express.urlencoded({ extended: false })); // form bodies reach brisk `body` schemas
1059
+ },
1060
+ });
1061
+ ```
1062
+
1063
+ ### Server-Sent Events
1064
+
1065
+ ```ts
1066
+ return {
1067
+ events: route.get("/events", ({ res, requestId }) => {
1068
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
1069
+ res.write(`data: ${requestId}\n\n`);
1070
+ }),
1071
+ };
1072
+ ```
1073
+
1074
+ End long-lived streams from a provider's `onDrain` so shutdown does not wait for them.
1075
+
1076
+ ### WebSockets with ws
1077
+
1078
+ ```ts
1079
+ import { HttpServer, inject, type OnDrain, type OnReady } from "@raiyyan/brisk";
1080
+ import { WebSocketServer } from "ws";
1081
+
1082
+ export class ChatGateway implements OnReady, OnDrain {
1083
+ #wss: WebSocketServer | undefined;
1084
+
1085
+ onReady() {
1086
+ this.#wss = new WebSocketServer({ server: inject(HttpServer) });
1087
+ this.#wss.on("connection", (socket) => socket.on("message", (data) => socket.send(data)));
1088
+ }
1089
+
1090
+ onDrain() {
1091
+ for (const client of this.#wss?.clients ?? []) client.close(1001, "server shutting down");
1092
+ this.#wss?.close();
1093
+ }
1094
+ }
1095
+
1096
+ export const app = defineApp({ controllers, providers: [ChatGateway] });
1097
+ ```
1098
+
1099
+ ### Socket.IO
1100
+
1101
+ ```ts
1102
+ import { HttpServer, inject, type OnDrain, type OnReady } from "@raiyyan/brisk";
1103
+ import { Server } from "socket.io";
1104
+
1105
+ export class Realtime implements OnReady, OnDrain {
1106
+ #io: Server | undefined;
1107
+
1108
+ onReady() {
1109
+ this.#io = new Server(inject(HttpServer));
1110
+ }
1111
+
1112
+ async onDrain() {
1113
+ await this.#io?.close();
1114
+ }
1115
+ }
1116
+
1117
+ export const app = defineApp({ controllers, providers: [Realtime] });
1118
+ ```
1119
+
1120
+ ### GraphQL with Yoga
1121
+
1122
+ ```ts
1123
+ import { createSchema, createYoga } from "graphql-yoga";
1124
+
1125
+ const yoga = createYoga({
1126
+ schema: createSchema({
1127
+ typeDefs: "type Query { user(id: ID!): User } type User { id: ID! name: String! }",
1128
+ resolvers: { Query: { user: (_parent, args: { id: string }) => inject(UsersService).get(args.id) } },
1129
+ }),
1130
+ });
1131
+
1132
+ export const app = defineApp({
1133
+ controllers,
1134
+ configureExpress: (expressApp) => expressApp.use(yoga.graphqlEndpoint, yoga),
1135
+ http: { securityHeaders: { "content-security-policy": false } }, // GraphiQL loads scripts; or set a GraphiQL-compatible policy
1136
+ });
1137
+ ```
1138
+
1139
+ Resolvers call `inject()` directly.
1140
+
1141
+ ### GraphQL with Apollo Server
1142
+
1143
+ ```ts
1144
+ import { ApolloServer } from "@apollo/server";
1145
+ import { expressMiddleware } from "@as-integrations/express5";
1146
+ import express from "express";
1147
+
1148
+ const apollo = new ApolloServer({ typeDefs, resolvers });
1149
+ await apollo.start();
1150
+
1151
+ export const app = defineApp({
1152
+ controllers,
1153
+ configureExpress: (expressApp) => expressApp.use("/graphql", express.json(), expressMiddleware(apollo)),
1154
+ });
1155
+ ```
1156
+
1157
+ `configureExpress` runs before brisk parses JSON, so the Apollo route adds `express.json()` itself.
1158
+
1159
+ ### tRPC
1160
+
1161
+ ```ts
1162
+ import { initTRPC } from "@trpc/server";
1163
+ import { createExpressMiddleware } from "@trpc/server/adapters/express";
1164
+
1165
+ const t = initTRPC.create();
1166
+ const router = t.router({
1167
+ user: t.procedure
1168
+ .input(z.object({ id: z.uuid() }))
1169
+ .query(({ input }) => inject(UsersService).get(input.id)),
1170
+ });
1171
+
1172
+ export const app = defineApp({
1173
+ controllers,
1174
+ configureExpress: (expressApp) => expressApp.use("/trpc", createExpressMiddleware({ router })),
1175
+ });
1176
+ ```
1177
+
1178
+ tRPC v11 accepts Zod schemas directly, so your brisk schemas work as procedure inputs.
1179
+
1180
+ ### gRPC
1181
+
1182
+ 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.
1183
+
1184
+ ```ts
1185
+ import * as grpc from "@grpc/grpc-js";
1186
+
1187
+ export class GrpcServer implements OnReady, OnDrain {
1188
+ readonly #server = new grpc.Server();
1189
+ readonly #users = inject(UsersService); // resolved here: gRPC callbacks run outside the app
1190
+
1191
+ onReady() {
1192
+ this.#server.addService(usersServiceDefinition, {
1193
+ getUser: (call, callback) => callback(null, this.#users.get(call.request.id)),
1194
+ });
1195
+ return new Promise<void>((resolve, reject) =>
1196
+ this.#server.bindAsync("0.0.0.0:50051", grpc.ServerCredentials.createInsecure(), (error) =>
1197
+ error ? reject(error) : resolve(),
1198
+ ),
1199
+ );
1200
+ }
1201
+
1202
+ onDrain() {
1203
+ return new Promise<void>((resolve) => {
1204
+ const force = setTimeout(() => {
1205
+ this.#server.forceShutdown();
1206
+ resolve();
1207
+ }, 5000);
1208
+ this.#server.tryShutdown(() => {
1209
+ clearTimeout(force);
1210
+ resolve();
1211
+ });
1212
+ });
1213
+ }
1214
+ }
1215
+
1216
+ export const app = defineApp({ controllers, providers: [GrpcServer] });
1217
+ ```
1218
+
1219
+ ### Microfrontends, frontends and BFFs
1220
+
1221
+ 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:
1222
+
1223
+ ```ts
1224
+ export const app = defineApp({
1225
+ controllers,
1226
+ http: {
1227
+ securityHeaders: {
1228
+ "content-security-policy": false, // or your policy
1229
+ "cross-origin-resource-policy": "cross-origin",
1230
+ "x-frame-options": false, // only when the frontend is embedded in iframes
1231
+ },
1232
+ },
1233
+ configureExpress: (expressApp) => {
1234
+ expressApp.use(cors({ origin: env.SHELL_ORIGIN }));
1235
+ expressApp.use("/remotes/checkout", express.static("dist/checkout"));
1236
+ },
1237
+ });
1238
+ ```
1239
+
1240
+ A backend-for-frontend is brisk controllers calling upstream services: throw `BadGatewayError` for bad upstream answers and `GatewayTimeoutError` for timeouts.
1241
+
1242
+ A frontend calls the API through the [typed client](#typed-client), with the manifest that `brisk client` generates.
1243
+
1244
+ ### Caching
1245
+
1246
+ - ETags and `304 Not Modified` work automatically for GET responses.
1247
+ - Set `Cache-Control` in the handler: `res.set("cache-control", "public, max-age=60")`.
1248
+ - A Redis or in-memory cache client is a provider: connect in `onInit`, quit in `onShutdown`, `inject(Cache)` where needed.
1249
+
1250
+ ### Workers, jobs and queue consumers
1251
+
1252
+ ```ts
1253
+ export class DigestJob implements OnReady, OnDrain {
1254
+ #timer: NodeJS.Timeout | undefined;
1255
+
1256
+ onReady() {
1257
+ this.#timer = setInterval(() => inject(DigestService).send(), 60_000);
1258
+ }
1259
+
1260
+ onDrain() {
1261
+ clearInterval(this.#timer);
1262
+ }
1263
+ }
1264
+
1265
+ export class EmailConsumer implements OnReady, OnDrain {
1266
+ #running = false;
1267
+ #loop: Promise<void> | undefined;
1268
+
1269
+ onReady() {
1270
+ this.#running = true;
1271
+ this.#loop = this.#consume();
1272
+ }
1273
+
1274
+ async onDrain() {
1275
+ this.#running = false;
1276
+ await this.#loop; // finish the message in progress
1277
+ }
1278
+
1279
+ async #consume() {
1280
+ while (this.#running) await inject(EmailService).handle(await queue.next());
1281
+ }
1282
+ }
1283
+ ```
1284
+
1285
+ Run them next to HTTP (`providers: [DigestJob, EmailConsumer]`), or in their own process with the same code and no port:
1286
+
1287
+ ```ts
1288
+ // src/worker.ts
1289
+ await defineApp({ providers: [EmailConsumer] }).start(); // no controllers, no port; signals and graceful shutdown still apply
1290
+ ```
1291
+
1292
+ ### Worker threads
1293
+
1294
+ CPU-heavy work goes to a `worker_threads` pool owned by a provider:
1295
+
1296
+ ```ts
1297
+ import { Worker } from "node:worker_threads";
1298
+
1299
+ export class ImagePool implements OnInit, OnShutdown {
1300
+ #worker: Worker | undefined;
1301
+
1302
+ onInit() {
1303
+ this.#worker = new Worker(new URL("./resize.worker.ts", import.meta.url));
1304
+ }
1305
+
1306
+ resize(input: Uint8Array): Promise<Uint8Array> {
1307
+ const worker = this.#worker;
1308
+ if (!worker) return Promise.reject(new Error("pool not started"));
1309
+ return new Promise((resolve) => {
1310
+ worker.once("message", resolve);
1311
+ worker.postMessage(input);
1312
+ });
1313
+ }
1314
+
1315
+ async onShutdown() {
1316
+ await this.#worker?.terminate();
1317
+ }
1318
+ }
1319
+ ```
1320
+
1321
+ ### Lazy loading
1322
+
1323
+ - Instances are created on first use, not at boot.
1324
+ - `injectLazy(Token)` defers a dependency until its first call.
1325
+ - Load heavy code on demand inside a handler: `const { PdfService } = await import("./pdf.service.ts"); return inject(PdfService).render(id);`.
1326
+
1327
+ ### HTTPS and custom servers
1328
+
1329
+ ```ts
1330
+ import https from "node:https";
1331
+
1332
+ await app.listen(443, { server: https.createServer({ key, cert }) });
1333
+ ```
1334
+
1335
+ Inside providers, `inject(HttpServer)` returns the server from `onReady` on.
1336
+
1337
+ ### Serverless
1338
+
1339
+ ```ts
1340
+ const instance = app.create();
1341
+ await instance.init();
1342
+ export const handler = serverlessAdapter(instance.express); // your platform's Express adapter
1343
+ ```
1344
+
1345
+ ### Clusters, PM2 and Kubernetes
1346
+
1347
+ - brisk keeps no per-process state: run one process per core with PM2, `node:cluster` or replicas.
1348
+ - 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.
1349
+ - Bind `host: "0.0.0.0"` in containers when the default does not reach the container network.
1350
+ - Point liveness and readiness probes at `/health`. SIGTERM triggers graceful shutdown; give the pod a termination grace period longer than `http.shutdownTimeoutMs`.
1351
+
1352
+ ## Production checklist
1353
+
1354
+ - Validate every variable in `defineEnv`; keep `.env` out of version control.
1355
+ - Give every route that takes input a schema, and a `response` schema for anything that returns stored data.
1356
+ - Keep `errors.exposeInternal` off in production.
1357
+ - Set `http.trustProxy` only to match your proxy setup.
1358
+ - Add CORS with an explicit origin list and a rate limiter in `configureExpress`.
1359
+ - Use JSON logs in production (no `pretty`); report 5xx errors with `hooks.onError`.
1360
+ - Connect clients in `onInit`, close them in `onShutdown`; stop long-lived work in `onDrain`.
1361
+ - Set `http.shutdownTimeoutMs` below your orchestrator's grace period, and the keep-alive timeout above your load balancer's idle timeout.
1362
+ - Map database and SDK errors with `errors.mappers` instead of try/catch in services.
1363
+ - Run `npm audit` (or `pnpm audit`) in CI.
1364
+
1365
+ ## Performance
1366
+
1367
+ - 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.
1368
+ - 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.
1369
+ - It stays stable under sustained load: 300 000 requests in a 30 s soak, heap flat after GC.
1370
+ - Graceful shutdown lets in-flight requests finish before the process exits.
1371
+
1372
+ Numbers: [benchmarks/RESULTS.md](https://github.com/MohammadRaiyyan/brisk/blob/main/benchmarks/RESULTS.md).
1373
+
1374
+ ## AI coding agents
1375
+
1376
+ 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)).
1377
+
1378
+ Agents read the project's own `AGENTS.md` and `CLAUDE.md`, so run:
1379
+
1380
+ ```bash
1381
+ npx brisk agents
1382
+ ```
1383
+
1384
+ 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.
1385
+
1386
+ ## FAQ
1387
+
1388
+ **Can I use plain Express middleware?**
1389
+ Yes. Put it in `configureExpress` (`app.use(helmet())`, `app.use(cors())`), globally or on specific paths.
1390
+
1391
+ **Where do I put CORS?**
1392
+ In `configureExpress`: `app.use(cors({ origin: ["https://app.example.com"] }))`. It runs before brisk's routes, so preflight requests are answered first.
1393
+
1394
+ **How do I return a file?**
1395
+ Set headers on `res` and return a `Readable` (`return createReadStream(path)`), or call `res.sendFile(path)` or `res.download(path)` and return nothing.
1396
+
1397
+ **How do I version an API?**
1398
+ Use the app `prefix` (`prefix: "/api/v1"`), or mount controllers with prefixes like `/v1/users` and `/v2/users` in the same app.
1399
+
1400
+ **How do I share types with a frontend?**
1401
+ 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.
1402
+
1403
+ **Why no decorators?**
1404
+ Decorators need a compile step and hide wiring behind metadata. `inject()` in a field is explicit, fully typed, and runs on plain Node.
1405
+
1406
+ **Can a handler be synchronous?**
1407
+ Yes. Handlers, middlewares, hooks and lifecycle methods can be sync or async; a synchronous `throw` and a rejected promise produce the same error response.
1408
+
1409
+ **Can I run two apps in one process?**
1410
+ 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(...)`.
1411
+
1412
+ ## Contributing
1413
+
1414
+ 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.
1415
+
1416
+ ## License
1417
+
1418
+ [MIT](https://github.com/MohammadRaiyyan/brisk/blob/main/LICENSE)