@raiyyan/brisk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/AGENTS.md +135 -67
  2. package/README.md +467 -139
  3. package/dist/src/cli/agents.js +5 -5
  4. package/dist/src/cli/agents.js.map +1 -1
  5. package/dist/src/cli/cli.d.ts +1 -1
  6. package/dist/src/cli/cli.d.ts.map +1 -1
  7. package/dist/src/cli/cli.js +22 -36
  8. package/dist/src/cli/cli.js.map +1 -1
  9. package/dist/src/cli/init.d.ts +8 -11
  10. package/dist/src/cli/init.d.ts.map +1 -1
  11. package/dist/src/cli/init.js +120 -96
  12. package/dist/src/cli/init.js.map +1 -1
  13. package/dist/src/cli/render.d.ts +30 -0
  14. package/dist/src/cli/render.d.ts.map +1 -0
  15. package/dist/src/cli/render.js +101 -0
  16. package/dist/src/cli/render.js.map +1 -0
  17. package/dist/src/cli/system.d.ts +2 -2
  18. package/dist/src/cli/system.d.ts.map +1 -1
  19. package/dist/src/cli/system.js +3 -5
  20. package/dist/src/cli/system.js.map +1 -1
  21. package/dist/src/client/client.d.ts.map +1 -1
  22. package/dist/src/client/client.js +32 -5
  23. package/dist/src/client/client.js.map +1 -1
  24. package/dist/src/client/index.d.ts +1 -1
  25. package/dist/src/client/index.d.ts.map +1 -1
  26. package/dist/src/di/container.d.ts +10 -2
  27. package/dist/src/di/container.d.ts.map +1 -1
  28. package/dist/src/di/container.js +20 -3
  29. package/dist/src/di/container.js.map +1 -1
  30. package/dist/src/http/app.d.ts +41 -12
  31. package/dist/src/http/app.d.ts.map +1 -1
  32. package/dist/src/http/app.js.map +1 -1
  33. package/dist/src/http/context.d.ts +5 -5
  34. package/dist/src/http/context.js +5 -5
  35. package/dist/src/http/controller.d.ts +26 -9
  36. package/dist/src/http/controller.d.ts.map +1 -1
  37. package/dist/src/http/controller.js +25 -52
  38. package/dist/src/http/controller.js.map +1 -1
  39. package/dist/src/http/error-handler.d.ts +27 -0
  40. package/dist/src/http/error-handler.d.ts.map +1 -1
  41. package/dist/src/http/error-handler.js +26 -3
  42. package/dist/src/http/error-handler.js.map +1 -1
  43. package/dist/src/http/instance.d.ts +2 -2
  44. package/dist/src/http/instance.d.ts.map +1 -1
  45. package/dist/src/http/instance.js +107 -39
  46. package/dist/src/http/instance.js.map +1 -1
  47. package/dist/src/http/middleware.d.ts +104 -17
  48. package/dist/src/http/middleware.d.ts.map +1 -1
  49. package/dist/src/http/middleware.js +85 -26
  50. package/dist/src/http/middleware.js.map +1 -1
  51. package/dist/src/http/openapi.d.ts.map +1 -1
  52. package/dist/src/http/openapi.js +30 -2
  53. package/dist/src/http/openapi.js.map +1 -1
  54. package/dist/src/http/routes.d.ts +29 -7
  55. package/dist/src/http/routes.d.ts.map +1 -1
  56. package/dist/src/http/validation.d.ts +16 -0
  57. package/dist/src/http/validation.d.ts.map +1 -0
  58. package/dist/src/http/validation.js +51 -0
  59. package/dist/src/http/validation.js.map +1 -0
  60. package/dist/src/index.d.ts +2 -2
  61. package/dist/src/index.d.ts.map +1 -1
  62. package/dist/src/index.js +2 -2
  63. package/dist/src/index.js.map +1 -1
  64. package/dist/src/testing/test-client.d.ts +8 -6
  65. package/dist/src/testing/test-client.d.ts.map +1 -1
  66. package/dist/src/testing/test-client.js +3 -2
  67. package/dist/src/testing/test-client.js.map +1 -1
  68. package/package.json +3 -2
  69. package/starters/minimal/.env.example +3 -0
  70. package/starters/minimal/.oxfmtrc.json +5 -0
  71. package/starters/minimal/.oxlintrc.json +14 -0
  72. package/starters/minimal/AGENTS.md +23 -0
  73. package/starters/minimal/CLAUDE.md +2 -0
  74. package/starters/minimal/README.md +53 -0
  75. package/starters/minimal/_gitignore +3 -0
  76. package/starters/minimal/package.json +29 -0
  77. package/starters/minimal/src/app.ts +14 -0
  78. package/starters/minimal/src/env.ts +7 -0
  79. package/starters/minimal/src/features/index.ts +8 -0
  80. package/starters/minimal/src/features/notes/notes.controller.ts +38 -0
  81. package/starters/minimal/src/features/notes/notes.repository.ts +36 -0
  82. package/starters/minimal/src/features/notes/notes.schema.ts +24 -0
  83. package/starters/minimal/src/features/notes/notes.service.ts +33 -0
  84. package/starters/minimal/src/main.ts +4 -0
  85. package/starters/minimal/src/schemas.ts +26 -0
  86. package/starters/minimal/templates/feature/src/features/index.ts.controllers.insert +1 -0
  87. package/starters/minimal/templates/feature/src/features/index.ts.imports.insert +1 -0
  88. package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.controller.ts +38 -0
  89. package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.repository.ts +36 -0
  90. package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.schema.ts +24 -0
  91. package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.service.ts +33 -0
  92. package/starters/minimal/templates/feature/test/{{kebab}}.service.test.ts +20 -0
  93. package/starters/minimal/templates/feature/test/{{kebab}}.test.ts +35 -0
  94. package/starters/minimal/templates/middleware/src/middlewares/{{kebab}}.ts +12 -0
  95. package/starters/minimal/test/notes.service.test.ts +20 -0
  96. package/starters/minimal/test/notes.test.ts +35 -0
  97. package/starters/minimal/tsconfig.json +17 -0
  98. package/starters/minimal/vitest.config.ts +9 -0
  99. package/starters/sqlite-auth/.dockerignore +8 -0
  100. package/starters/sqlite-auth/.env.example +9 -0
  101. package/starters/sqlite-auth/.oxfmtrc.json +5 -0
  102. package/starters/sqlite-auth/.oxlintrc.json +14 -0
  103. package/starters/sqlite-auth/AGENTS.md +23 -0
  104. package/starters/sqlite-auth/CLAUDE.md +2 -0
  105. package/starters/sqlite-auth/Dockerfile +23 -0
  106. package/starters/sqlite-auth/README.md +93 -0
  107. package/starters/sqlite-auth/_gitignore +5 -0
  108. package/starters/sqlite-auth/compose.yaml +14 -0
  109. package/starters/sqlite-auth/package.json +35 -0
  110. package/starters/sqlite-auth/public/.gitkeep +0 -0
  111. package/starters/sqlite-auth/src/app.ts +39 -0
  112. package/starters/sqlite-auth/src/auth/tokens.ts +41 -0
  113. package/starters/sqlite-auth/src/database/clock.ts +4 -0
  114. package/starters/sqlite-auth/src/database/database.ts +29 -0
  115. package/starters/sqlite-auth/src/database/errors.ts +11 -0
  116. package/starters/sqlite-auth/src/database/migrations.ts +29 -0
  117. package/starters/sqlite-auth/src/env.ts +17 -0
  118. package/starters/sqlite-auth/src/features/auth/auth.controller.ts +28 -0
  119. package/starters/sqlite-auth/src/features/index.ts +12 -0
  120. package/starters/sqlite-auth/src/features/notes/notes.controller.ts +39 -0
  121. package/starters/sqlite-auth/src/features/notes/notes.repository.ts +47 -0
  122. package/starters/sqlite-auth/src/features/notes/notes.schema.ts +24 -0
  123. package/starters/sqlite-auth/src/features/notes/notes.service.ts +33 -0
  124. package/starters/sqlite-auth/src/features/users/admin-seed.ts +21 -0
  125. package/starters/sqlite-auth/src/features/users/users.controller.ts +42 -0
  126. package/starters/sqlite-auth/src/features/users/users.errors.ts +13 -0
  127. package/starters/sqlite-auth/src/features/users/users.repository.ts +77 -0
  128. package/starters/sqlite-auth/src/features/users/users.schema.ts +35 -0
  129. package/starters/sqlite-auth/src/features/users/users.service.ts +80 -0
  130. package/starters/sqlite-auth/src/main.ts +4 -0
  131. package/starters/sqlite-auth/src/middlewares/authenticate.ts +29 -0
  132. package/starters/sqlite-auth/src/schemas.ts +26 -0
  133. package/starters/sqlite-auth/templates/feature/src/database/migrations.ts.migrations.insert +5 -0
  134. package/starters/sqlite-auth/templates/feature/src/features/index.ts.controllers.insert +1 -0
  135. package/starters/sqlite-auth/templates/feature/src/features/index.ts.imports.insert +1 -0
  136. package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.controller.ts +39 -0
  137. package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.repository.ts +47 -0
  138. package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.schema.ts +24 -0
  139. package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.service.ts +33 -0
  140. package/starters/sqlite-auth/templates/feature/test/{{kebab}}.service.test.ts +20 -0
  141. package/starters/sqlite-auth/templates/feature/test/{{kebab}}.test.ts +41 -0
  142. package/starters/sqlite-auth/templates/middleware/src/middlewares/{{kebab}}.ts +12 -0
  143. package/starters/sqlite-auth/test/auth.test.ts +18 -0
  144. package/starters/sqlite-auth/test/helpers.ts +13 -0
  145. package/starters/sqlite-auth/test/notes.service.test.ts +20 -0
  146. package/starters/sqlite-auth/test/notes.test.ts +41 -0
  147. package/starters/sqlite-auth/test/users.service.test.ts +25 -0
  148. package/starters/sqlite-auth/test/users.test.ts +164 -0
  149. package/starters/sqlite-auth/tsconfig.json +17 -0
  150. package/starters/sqlite-auth/vitest.config.ts +15 -0
  151. package/dist/src/cli/generate.d.ts +0 -9
  152. package/dist/src/cli/generate.d.ts.map +0 -1
  153. package/dist/src/cli/generate.js +0 -39
  154. package/dist/src/cli/generate.js.map +0 -1
  155. package/dist/src/cli/templates/auth.d.ts +0 -4
  156. package/dist/src/cli/templates/auth.d.ts.map +0 -1
  157. package/dist/src/cli/templates/auth.js +0 -90
  158. package/dist/src/cli/templates/auth.js.map +0 -1
  159. package/dist/src/cli/templates/database.d.ts +0 -4
  160. package/dist/src/cli/templates/database.d.ts.map +0 -1
  161. package/dist/src/cli/templates/database.js +0 -107
  162. package/dist/src/cli/templates/database.js.map +0 -1
  163. package/dist/src/cli/templates/docker.d.ts +0 -4
  164. package/dist/src/cli/templates/docker.d.ts.map +0 -1
  165. package/dist/src/cli/templates/docker.js +0 -102
  166. package/dist/src/cli/templates/docker.js.map +0 -1
  167. package/dist/src/cli/templates/feature.d.ts +0 -8
  168. package/dist/src/cli/templates/feature.d.ts.map +0 -1
  169. package/dist/src/cli/templates/feature.js +0 -145
  170. package/dist/src/cli/templates/feature.js.map +0 -1
  171. package/dist/src/cli/templates/project.d.ts +0 -24
  172. package/dist/src/cli/templates/project.d.ts.map +0 -1
  173. package/dist/src/cli/templates/project.js +0 -166
  174. package/dist/src/cli/templates/project.js.map +0 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # brisk
2
2
 
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.
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
4
 
5
5
  ```ts
6
6
  import { defineApp, defineController, inject, NotFoundError, z } from "@raiyyan/brisk";
@@ -28,6 +28,7 @@ await app.listen(3000);
28
28
  - [Requirements](#requirements)
29
29
  - [Installation](#installation)
30
30
  - [Quick start](#quick-start)
31
+ - [Starters](#starters)
31
32
  - [Project structure](#project-structure)
32
33
  - [Configuration](#configuration)
33
34
  - [Controllers and routes](#controllers-and-routes)
@@ -45,6 +46,7 @@ await app.listen(3000);
45
46
  - [OpenAPI](#openapi)
46
47
  - [Testing](#testing)
47
48
  - [CLI](#cli)
49
+ - [Templates](#templates)
48
50
  - [Working with the ecosystem](#working-with-the-ecosystem)
49
51
  - [Production checklist](#production-checklist)
50
52
  - [Performance](#performance)
@@ -67,6 +69,7 @@ brisk sits in between:
67
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.
68
70
  - **Production lifecycle.** Init, ready, drain and shutdown hooks on provider classes, graceful shutdown, crash handling.
69
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.
70
73
 
71
74
  Six ideas, nothing else:
72
75
 
@@ -82,7 +85,7 @@ Six ideas, nothing else:
82
85
  ## Requirements
83
86
 
84
87
  - Node.js 24 or later.
85
- - npm or pnpm (any package manager works).
88
+ - npm, pnpm or yarn.
86
89
  - TypeScript 5.8 or later (including 7) with `erasableSyntaxOnly`, so Node can run your `.ts` files directly.
87
90
  - ES modules (`"type": "module"`).
88
91
 
@@ -90,16 +93,16 @@ Six ideas, nothing else:
90
93
 
91
94
  ```bash
92
95
  npm install @raiyyan/brisk
93
- npm install --save-dev typescript @types/node vitest
94
96
  ```
95
97
 
96
98
  ```bash
97
99
  pnpm add @raiyyan/brisk
98
- pnpm add --save-dev typescript @types/node vitest
99
100
  ```
100
101
 
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`.
102
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
+
103
106
  ## Quick start
104
107
 
105
108
  In an empty folder, with npm:
@@ -107,12 +110,11 @@ In an empty folder, with npm:
107
110
  ```bash
108
111
  npm init -y
109
112
  npm install @raiyyan/brisk
110
- npm install --save-dev typescript @types/node vitest
111
113
  npx brisk init
112
114
  cp .env.example .env
113
115
  npx brisk g feature products
114
- npx tsc --noEmit
115
- npx vitest run
116
+ npm run typecheck
117
+ npm test
116
118
  npm run dev
117
119
  ```
118
120
 
@@ -121,7 +123,6 @@ With pnpm:
121
123
  ```bash
122
124
  pnpm init
123
125
  pnpm add @raiyyan/brisk
124
- pnpm add --save-dev typescript @types/node vitest
125
126
  pnpm exec brisk init
126
127
  cp .env.example .env
127
128
  pnpm brisk g feature products
@@ -138,33 +139,52 @@ curl localhost:3000/api/products
138
139
  curl -X POST localhost:3000/api/products -H 'content-type: application/json' -d '{"name":""}' # 400 with field issues
139
140
  ```
140
141
 
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
+ `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)).
142
143
 
143
144
  ### Adding brisk to an existing Express project
144
145
 
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
+ 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:
146
147
 
147
148
  ```ts
148
149
  export const app = defineApp({
149
150
  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
- },
151
+ use: [fromExpress(cors({ origin: env.WEB_ORIGIN }))],
152
+ mount: { "/": [express.json(), legacyRouter] }, // brisk does not parse mount bodies; your routes read req.body
154
153
  });
155
154
  ```
156
155
 
156
+ brisk's routes answer first, and the legacy router gets every request they did not match.
157
+
157
158
  Then migrate route by route: write the new route in a controller, delete the old one.
158
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
+
159
178
  ## Project structure
160
179
 
161
- `brisk init` and `brisk g feature` create this layout:
180
+ The `minimal` starter, after `brisk g feature products`:
162
181
 
163
182
  ```
164
183
  src/
165
184
  env.ts defineEnv: typed configuration
166
185
  app.ts export const app = defineApp({ prefix: "/api", ... }); export type Api = typeof app
167
186
  main.ts await app.listen(env.PORT)
187
+ schemas.ts isoDate(), PageQuery, page(item): shared schema helpers
168
188
  middlewares/<name>.ts defineMiddleware (brisk g middleware)
169
189
  features/
170
190
  index.ts export const controllers = { products: productsController } (keep the @brisk markers)
@@ -173,17 +193,19 @@ src/
173
193
  products.repository.ts data access only
174
194
  products.service.ts business rules; throws error classes
175
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
176
199
  test/
177
200
  products.test.ts HTTP tests with createTestClient
201
+ products.service.test.ts service tests without HTTP
178
202
  .env.example
179
- .gitignore
203
+ .oxlintrc.json, .oxfmtrc.json lint and format settings (they ignore templates/)
180
204
  tsconfig.json lets Node run .ts directly
181
205
  vitest.config.ts test environment variables
182
206
  AGENTS.md, CLAUDE.md guide for AI coding agents (brisk agents)
183
207
  ```
184
208
 
185
- The options of `brisk init` add more (a database provider, auth, Docker, CI); see [CLI](#cli).
186
-
187
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:
188
210
 
189
211
  ```ts
@@ -196,6 +218,7 @@ export const app = defineApp({
196
218
  prefix: "/api",
197
219
  controllers,
198
220
  logger: createLogger({ level: env.NODE_ENV === "test" ? "silent" : env.LOG_LEVEL }),
221
+ openapi: { title: "API", version: "0.1.0" },
199
222
  errors: { exposeInternal: env.NODE_ENV === "development" },
200
223
  });
201
224
 
@@ -238,11 +261,11 @@ export interface Product {
238
261
  export class ProductsRepository {
239
262
  readonly #rows = new Map<string, Product>();
240
263
 
241
- findById(id: string): Product | undefined {
264
+ async findById(id: string): Promise<Product | undefined> {
242
265
  return this.#rows.get(id);
243
266
  }
244
267
 
245
- save(row: Product): Product {
268
+ async insert(row: Product): Promise<Product> {
246
269
  this.#rows.set(row.id, row);
247
270
  return row;
248
271
  }
@@ -259,14 +282,14 @@ import type { CreateProductBody } from "./products.schema.ts";
259
282
  export class ProductsService {
260
283
  readonly #repository = inject(ProductsRepository);
261
284
 
262
- get(id: string): Product {
263
- const row = this.#repository.findById(id);
285
+ async get(id: string): Promise<Product> {
286
+ const row = await this.#repository.findById(id);
264
287
  if (!row) throw new NotFoundError("Product not found");
265
288
  return row;
266
289
  }
267
290
 
268
- create(input: CreateProductBody): Product {
269
- return this.#repository.save({ id: randomUUID(), ...input });
291
+ create(input: CreateProductBody): Promise<Product> {
292
+ return this.#repository.insert({ id: randomUUID(), ...input });
270
293
  }
271
294
  }
272
295
  ```
@@ -344,10 +367,13 @@ export const ordersController = defineController({ prefix: "/orders", use: [auth
344
367
 
345
368
  `defineController(options, setup)`:
346
369
 
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 |
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.
351
377
 
352
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`).
353
379
 
@@ -374,7 +400,7 @@ Without a `params` schema, path params are typed from the full path, controller
374
400
  | `"/files/*path"` | `{ path: string[] }` |
375
401
  | `"/posts{/:slug}"` | `{ slug?: string }` |
376
402
 
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.
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.
378
404
 
379
405
  ### What a handler returns
380
406
 
@@ -445,23 +471,31 @@ export const ListOrdersQuery = z.object({
445
471
  ## Middleware
446
472
 
447
473
  ```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"));
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"));
452
486
  if (!user) throw new UnauthorizedError("Invalid token", { challenge: "Bearer" });
453
- return { user };
454
- }, "authenticate");
487
+ return { user, log: log.child({ userId: user.id }) };
488
+ });
455
489
 
456
490
  export const requireRole = (role: "admin" | "member") =>
457
- defineMiddleware(async (ctx) => {
491
+ defineMiddleware({ security: bearer }, async (ctx) => {
458
492
  const { user } = await authenticate.run(ctx);
459
493
  if (user.role !== role) throw new ForbiddenError(`Requires role: ${role}`);
460
494
  return { user };
461
495
  });
462
496
  ```
463
497
 
464
- `defineMiddleware(fn, name?)`:
498
+ `defineMiddleware(fn)` or `defineMiddleware(options, fn)`:
465
499
 
466
500
  - **Return an object** to add typed fields to the handler's ctx (`return { user }` gives `ctx.user`).
467
501
  - **Return nothing** for a pure check.
@@ -469,9 +503,53 @@ export const requireRole = (role: "admin" | "member") =>
469
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.
470
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.
471
505
  - **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.
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
473
515
 
474
- Plain Express middleware (`cors()`, `helmet()`, rate limiters, multer) goes in [`configureExpress`](#working-with-the-ecosystem).
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.
475
553
 
476
554
  ## Dependency injection
477
555
 
@@ -495,10 +573,10 @@ export const app = defineApp({ controllers, providers: [provide(Clock, () => new
495
573
 
496
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.
497
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`.
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.
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.
499
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".
500
578
  - **`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.
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.
502
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.
503
581
  - **`HttpServer`** exists only once the instance listens: inject it in `onReady`. Injecting it in a class field or `onInit` throws.
504
582
  - **Circular dependencies** are reported as `A -> B -> A`. Break them with `injectLazy`.
@@ -540,13 +618,15 @@ export class Database implements OnInit, OnShutdown {
540
618
  }
541
619
  ```
542
620
 
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;`.
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;`.
544
622
 
545
623
  ```
546
624
  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)
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)
550
630
  -> in-flight requests finish, with Connection: close (sockets still open after shutdownTimeoutMs are destroyed)
551
631
  -> providers' onShutdown (reverse) -> exit 0
552
632
  (a different second signal is ignored; the same signal again exits 1 at once;
@@ -556,7 +636,9 @@ crash unhandled rejection or uncaught exception -> logged as fatal -> shutdo
556
636
 
557
637
  A failing `onDrain` or `onShutdown` is logged and shutdown continues. `app.start()` runs the same lifecycle without an HTTP server.
558
638
 
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.
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.
560
642
 
561
643
  ## Errors
562
644
 
@@ -666,9 +748,11 @@ export const app = defineApp({
666
748
 
667
749
  `defaultErrorFormatter` is exported for formatters that only add fields. A mapper or `onError` hook that throws is logged and never breaks the response.
668
750
 
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`:
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`:
670
752
 
671
753
  ```ts
754
+ import { ConflictError, type ErrorMapper } from "@raiyyan/brisk";
755
+
672
756
  export const uniqueViolation: ErrorMapper = (error) => {
673
757
  for (let current: unknown = error; current instanceof Error; current = current.cause) {
674
758
  if ("code" in current && current.code === "23505") {
@@ -679,6 +763,20 @@ export const uniqueViolation: ErrorMapper = (error) => {
679
763
  };
680
764
  ```
681
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
+
682
780
  ## The request context
683
781
 
684
782
  Every handler and middleware receives a context:
@@ -692,7 +790,7 @@ Every handler and middleware receives a context:
692
790
 
693
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.
694
792
 
695
- Code that only has the Express `req` (plain middlewares and routes in `configureExpress`) uses `getContext(req)`:
793
+ Code that only has the Express `req` (Express middlewares and mounts) uses `getContext(req)`:
696
794
 
697
795
  ```ts
698
796
  import { getContext } from "@raiyyan/brisk";
@@ -734,27 +832,28 @@ export const app = defineApp({
734
832
 
735
833
  - **Request ids** from callers are accepted only when short and plain (letters, digits, `_ . : -`, up to 128 characters), so they cannot forge log lines.
736
834
  - **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")`.
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" } }`.
738
836
  - **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`.
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`.
740
838
  - **Internal error messages** never reach clients unless `errors.exposeInternal` is on (development only).
741
- - CORS and rate limiting are plain Express middlewares in `configureExpress`.
839
+ - CORS and rate limiting are plain Express middlewares in the app's `use`.
742
840
 
743
841
  ## defineApp options
744
842
 
745
843
  `defineApp(options)` declares the app. Every option is optional.
746
844
 
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) |
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) |
758
857
 
759
858
  `errors`:
760
859
 
@@ -766,13 +865,13 @@ export const app = defineApp({
766
865
 
767
866
  `http`:
768
867
 
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 |
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 |
776
875
 
777
876
  ## App reference
778
877
 
@@ -790,7 +889,7 @@ Each call builds a new, independent instance with its own container. Configurati
790
889
 
791
890
  | Option | On | Default | Description |
792
891
  | --------------- | --------------------------- | ----------------- | ---------------------------------------------------------------------------------------- |
793
- | `providers` | `listen`, `start`, `create` | `[]` | Replace the app's own providers, typically with fakes |
892
+ | `providers` | `listen`, `start`, `create` | `[]` | Added after the app's own providers: a value for the same token wins, typically a fake |
794
893
  | `manageProcess` | `listen`, `start` | `true` | SIGINT/SIGTERM shut down gracefully and exit 0; crashes are logged, shut down and exit 1 |
795
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 |
796
895
  | `server` | `listen` | a new HTTP server | Your own `http.Server` or `https.Server`; brisk serves its requests |
@@ -870,9 +969,11 @@ await api.users.remove({ params: { id } }, { signal: AbortSignal.timeout(5000) }
870
969
  ```
871
970
 
872
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`.
873
973
  - Every call accepts extra headers, such as `authorization` for a middleware that reads it.
874
974
  - The second argument is passed to `fetch`: `signal`, `credentials`, and so on.
875
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.
876
977
 
877
978
  A call resolves to:
878
979
 
@@ -903,7 +1004,21 @@ try {
903
1004
  }
904
1005
  ```
905
1006
 
906
- Types: `ApiOf<T>` (an app's routes), `Client<T>` (the client for them), `Jsonify<T>` (a type after JSON), `Manifest`.
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`.
907
1022
 
908
1023
  ## OpenAPI
909
1024
 
@@ -928,6 +1043,7 @@ export const app = defineApp({
928
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`.
929
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.
930
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).
931
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:
932
1048
 
933
1049
  ```ts
@@ -937,7 +1053,7 @@ export const app = defineApp({
937
1053
  .pipe(z.iso.datetime()), // documented as { type: "string", format: "date-time" }
938
1054
  ```
939
1055
 
940
- The typed client then types the field as `string` too.
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.
941
1057
 
942
1058
  ## Testing
943
1059
 
@@ -969,67 +1085,134 @@ it("rejects an invalid product", async () => {
969
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.
970
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`.
971
1087
  - `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.
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.
974
1090
 
975
1091
  ## CLI
976
1092
 
977
1093
  The package installs a `brisk` command. Run it from the app's root.
978
1094
 
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 |
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 |
988
1103
 
989
1104
  ### brisk init
990
1105
 
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`.
1106
+ ```bash
1107
+ npx brisk init [--starter <name>] [--pm pnpm|npm|yarn] [--no-install]
1108
+ ```
992
1109
 
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:
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 |
994
1115
 
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 |
1116
+ What it does:
1007
1117
 
1008
- ```bash
1009
- npx brisk init --db postgres --auth --docker --ci --yes
1010
- ```
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.
1011
1123
 
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.
1124
+ ### brisk g
1013
1125
 
1014
- ### brisk g feature
1126
+ ```bash
1127
+ npx brisk g feature products
1128
+ npx brisk g middleware tenant
1129
+ ```
1015
1130
 
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.
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`.
1021
1132
 
1022
1133
  ### brisk client and brisk openapi
1023
1134
 
1024
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.
1025
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
+
1026
1206
  ## Working with the ecosystem
1027
1207
 
1028
1208
  ### Not in your way
1029
1209
 
1030
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:
1031
1211
 
1032
- - `configureExpress(expressApp)`: full Express access, before JSON parsing and before brisk's routes.
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.
1033
1216
  - `instance.express`: the Express app (adapters, serverless with `app.create()`).
1034
1217
  - `instance.server` and the `HttpServer` token: the Node server (WebSockets, timeouts).
1035
1218
  - `ListenOptions.server`: your own server (HTTPS, tuned timeouts).
@@ -1037,29 +1220,62 @@ brisk owns only its own routes. Everything else is plain Express on a plain Node
1037
1220
  - `app.start()`: the same app without HTTP.
1038
1221
  - `ctx.res`: write a raw response from any handler.
1039
1222
 
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.
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)`.
1041
1230
 
1042
- ### Express middleware, static files, uploads, webhooks
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
1043
1234
 
1044
1235
  ```ts
1045
1236
  import cors from "cors";
1046
1237
  import express from "express";
1047
- import multer from "multer";
1048
1238
 
1049
1239
  export const app = defineApp({
1050
1240
  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
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
1059
1248
  },
1060
1249
  });
1061
1250
  ```
1062
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
+
1063
1279
  ### Server-Sent Events
1064
1280
 
1065
1281
  ```ts
@@ -1117,26 +1333,139 @@ export class Realtime implements OnReady, OnDrain {
1117
1333
  export const app = defineApp({ controllers, providers: [Realtime] });
1118
1334
  ```
1119
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
+
1120
1421
  ### GraphQL with Yoga
1121
1422
 
1122
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";
1123
1428
  import { createSchema, createYoga } from "graphql-yoga";
1124
1429
 
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) } },
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
+ },
1129
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
+ },
1130
1454
  });
1455
+ ```
1131
1456
 
1457
+ ```ts
1458
+ // src/app.ts
1132
1459
  export const app = defineApp({
1133
1460
  controllers,
1134
- configureExpress: (expressApp) => expressApp.use(yoga.graphqlEndpoint, yoga),
1461
+ mount: { [yoga.graphqlEndpoint]: (req, res) => yoga(req, res) },
1135
1462
  http: { securityHeaders: { "content-security-policy": false } }, // GraphiQL loads scripts; or set a GraphiQL-compatible policy
1136
1463
  });
1137
1464
  ```
1138
1465
 
1139
- Resolvers call `inject()` directly.
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.
1140
1469
 
1141
1470
  ### GraphQL with Apollo Server
1142
1471
 
@@ -1150,11 +1479,11 @@ await apollo.start();
1150
1479
 
1151
1480
  export const app = defineApp({
1152
1481
  controllers,
1153
- configureExpress: (expressApp) => expressApp.use("/graphql", express.json(), expressMiddleware(apollo)),
1482
+ mount: { "/graphql": [express.json(), expressMiddleware(apollo)] },
1154
1483
  });
1155
1484
  ```
1156
1485
 
1157
- `configureExpress` runs before brisk parses JSON, so the Apollo route adds `express.json()` itself.
1486
+ brisk does not parse mount bodies, so the Apollo mount adds `express.json()` itself.
1158
1487
 
1159
1488
  ### tRPC
1160
1489
 
@@ -1171,7 +1500,7 @@ const router = t.router({
1171
1500
 
1172
1501
  export const app = defineApp({
1173
1502
  controllers,
1174
- configureExpress: (expressApp) => expressApp.use("/trpc", createExpressMiddleware({ router })),
1503
+ mount: { "/trpc": createExpressMiddleware({ router }) },
1175
1504
  });
1176
1505
  ```
1177
1506
 
@@ -1230,10 +1559,8 @@ export const app = defineApp({
1230
1559
  "x-frame-options": false, // only when the frontend is embedded in iframes
1231
1560
  },
1232
1561
  },
1233
- configureExpress: (expressApp) => {
1234
- expressApp.use(cors({ origin: env.SHELL_ORIGIN }));
1235
- expressApp.use("/remotes/checkout", express.static("dist/checkout"));
1236
- },
1562
+ use: [fromExpress(cors({ origin: env.SHELL_ORIGIN }))],
1563
+ mount: { "/remotes/checkout": express.static("dist/checkout") },
1237
1564
  });
1238
1565
  ```
1239
1566
 
@@ -1243,7 +1570,7 @@ A frontend calls the API through the [typed client](#typed-client), with the man
1243
1570
 
1244
1571
  ### Caching
1245
1572
 
1246
- - ETags and `304 Not Modified` work automatically for GET responses.
1573
+ - ETags and `304 Not Modified` work automatically for GET responses (to test them, see the 304 note under [Testing](#testing)).
1247
1574
  - Set `Cache-Control` in the handler: `res.set("cache-control", "public, max-age=60")`.
1248
1575
  - A Redis or in-memory cache client is a provider: connect in `onInit`, quit in `onShutdown`, `inject(Cache)` where needed.
1249
1576
 
@@ -1344,22 +1671,23 @@ export const handler = serverlessAdapter(instance.express); // your platform's E
1344
1671
 
1345
1672
  ### Clusters, PM2 and Kubernetes
1346
1673
 
1347
- - brisk keeps no per-process state: run one process per core with PM2, `node:cluster` or replicas.
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.
1348
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.
1349
1676
  - 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`.
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`.
1351
1678
 
1352
1679
  ## Production checklist
1353
1680
 
1354
1681
  - Validate every variable in `defineEnv`; keep `.env` out of version control.
1355
1682
  - Give every route that takes input a schema, and a `response` schema for anything that returns stored data.
1356
1683
  - 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`.
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`.
1359
1686
  - Use JSON logs in production (no `pretty`); report 5xx errors with `hooks.onError`.
1360
1687
  - Connect clients in `onInit`, close them in `onShutdown`; stop long-lived work in `onDrain`.
1361
1688
  - Set `http.shutdownTimeoutMs` below your orchestrator's grace period, and the keep-alive timeout above your load balancer's idle timeout.
1362
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.
1363
1691
  - Run `npm audit` (or `pnpm audit`) in CI.
1364
1692
 
1365
1693
  ## Performance
@@ -1386,10 +1714,10 @@ It writes a brisk section between `<!-- brisk:start -->` and `<!-- brisk:end -->
1386
1714
  ## FAQ
1387
1715
 
1388
1716
  **Can I use plain Express middleware?**
1389
- Yes. Put it in `configureExpress` (`app.use(helmet())`, `app.use(cors())`), globally or on specific paths.
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(...)`.
1390
1718
 
1391
1719
  **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.
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.
1393
1721
 
1394
1722
  **How do I return a file?**
1395
1723
  Set headers on `res` and return a `Readable` (`return createReadStream(path)`), or call `res.sendFile(path)` or `res.download(path)` and return nothing.