@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/AGENTS.md CHANGED
@@ -16,16 +16,22 @@ Full documentation: https://github.com/MohammadRaiyyan/brisk#readme
16
16
 
17
17
  ## Starting a project
18
18
 
19
- - New app: install `@raiyyan/brisk` (and `typescript`, `@types/node`, `vitest` as dev dependencies), then run `npx brisk init`. It creates `src/env.ts`, `src/app.ts` (`export const app = defineApp(...)` with prefix `/api`, and `export type Api = typeof app`), `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, adds `"type": "module"` and missing `dev`, `start`, `typecheck`, `test` and `brisk` scripts, then runs `brisk agents`.
20
- - `brisk init` asks about each option, or takes flags: `--db none|sqlite|postgres` (a `Database` provider and a unique-violation mapper to 409 `ALREADY_EXISTS`; postgres uses `pg` + Drizzle), `--auth` (HS256 bearer tokens, `authenticate` and `requireRole` middlewares), `--docker`, `--ci` (GitHub Actions), `--web` (CORS, rate limiting, static `public/`, pretty dev logs), `--pm`, `--yes`/`-y` (defaults, no questions), `--no-install`. It installs missing packages with the detected package manager after writing files.
21
- - Then: `cp .env.example .env`, `pnpm brisk g feature <name>`, `pnpm dev`.
19
+ - brisk owns mechanisms (routing, validation, DI, lifecycle, errors, typed client, OpenAPI, test client, template rendering). Choices (database, auth scheme, lint and format tools, Docker, CI, folder layout, pagination shape) live in the project: in the starter it began from and in its own `templates/`. Follow the project's existing choices; do not look for them in brisk.
20
+ - New app: install `@raiyyan/brisk`, then `npx brisk init [--starter <name>] [--pm pnpm|npm|yarn] [--no-install]`. Starters: `minimal` (default; in-memory feature template, no database) and `sqlite-auth` (`node:sqlite` with migrations at startup, scrypt passwords, HS256 bearer tokens, admin seeded from env, CORS, rate limit, static files, Dockerfile and compose). `--starter list` lists them.
21
+ - `brisk init` copies the starter without overwriting any file, merges `type`, `engines`, scripts and dependencies into `package.json` (pinning brisk to the running version), installs, and runs `brisk agents`. There are no other init flags.
22
+ - Both starters ship oxlint and oxfmt (`lint`, `format`, `format:check` scripts), `typecheck`, `test`, `dev`, and a deletable `notes` example feature.
23
+ - `src/schemas.ts` in a starter holds `isoDate()`, `PageQuery` and `page(item)`. They are project code, not brisk exports.
24
+ - Then: `cp .env.example .env`, `npx brisk g feature <name>`, `npm run dev`.
22
25
  - `npx brisk agents` adds or refreshes a brisk section (between `<!-- brisk:start -->` and `<!-- brisk:end -->`) in the project's `AGENTS.md` and makes `CLAUDE.md` import `@AGENTS.md` and `@node_modules/@raiyyan/brisk/AGENTS.md`. Run it again after upgrading brisk.
23
26
 
24
27
  ## Not in your way
25
28
 
26
29
  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. The escape hatches:
27
30
 
28
- - `configureExpress(expressApp)`: full Express access, before JSON parsing and before brisk's routes.
31
+ - `use: [fromExpress(cors()), ...]` in `defineApp`: any Express middleware for every request, before brisk's routes (after `/health` and `/openapi.json`, so it never blocks them).
32
+ - `mount: { "/path": handler }` in `defineApp`: Express handlers that are not brisk routes (static files, GraphQL, tRPC, webhooks, a legacy router, a SPA fallback). They answer only what brisk's routes did not.
33
+ - `http.settings`: Express app settings (`{ "trust proxy": 1 }`, `{ "query parser": "extended" }`).
34
+ - `fromExpress(handler)`: any Express middleware in one route's `use`, in order with brisk's middlewares.
29
35
  - `instance.express`: the Express app itself (for adapters; serverless uses `app.create()`).
30
36
  - `instance.server` and the `HttpServer` token: the Node server, for WebSockets and timeouts.
31
37
  - `ListenOptions.server`: bring your own server (HTTPS, tuned timeouts).
@@ -33,13 +39,14 @@ brisk owns only its own routes. Everything else is plain Express on a plain Node
33
39
  - `app.start()`: the same app without HTTP, for workers and schedulers.
34
40
  - `ctx.res`: write a raw response from any handler.
35
41
 
36
- ## Project layout
42
+ ## Project layout and generators
37
43
 
38
44
  ```
39
45
  src/
40
46
  env.ts defineEnv(...)
41
47
  app.ts export const app = defineApp({...}); export type Api = typeof app
42
48
  main.ts await app.listen(env.PORT)
49
+ schemas.ts isoDate(), PageQuery, page(item)
43
50
  middlewares/<name>.ts defineMiddleware(...)
44
51
  features/
45
52
  index.ts export const controllers = { users: usersController } (keep the @brisk markers)
@@ -48,10 +55,15 @@ src/
48
55
  <name>.repository.ts data access only
49
56
  <name>.service.ts business rules, throws error classes
50
57
  <name>.controller.ts defineController(...): HTTP only, delegates to the service
58
+ templates/<kind>/ what `brisk g <kind> <name>` renders; owned by the project
51
59
  test/<name>.test.ts
52
60
  ```
53
61
 
54
- Generate a feature with `pnpm brisk g feature <name>`; it writes the four files (singular entity names: `products` gives `Product`, `CreateProductBody`) and a test, registers the controller as `products: productsController` at the `// @brisk:imports` and `// @brisk:controllers` markers in `src/features/index.ts`, and runs the project's formatter (oxfmt or Prettier) on the result. `pnpm brisk g middleware <name>` writes a middleware. `pnpm brisk help` lists commands.
62
+ - Create new code with `npx brisk g <kind> <name>` (`feature`, `middleware`, or any folder in `templates/`), then edit it. When the project's conventions change, change the templates too.
63
+ - `brisk g` renders `templates/<kind>/` into the project root. Tokens in paths and contents: `{{kebab}}` (`blog-posts`), `{{camel}}` (`blogPosts`), `{{pascal}}` (`BlogPosts`), `{{snake}}` (`blog_posts`), `{{entity}}` (`BlogPost`), `{{entityCamel}}` (`blogPost`).
64
+ - A template file named `<target>.<marker>.insert` is inserted into the existing `<target>` before the line containing `@brisk:<marker>`: `// @brisk:imports` and `// @brisk:controllers` in `src/features/index.ts`, `// @brisk:migrations` in `sqlite-auth`'s `src/database/migrations.ts`. Never delete marker lines.
65
+ - Everything is checked before anything is written: an unknown token, an existing file or a missing marker fails with nothing changed. Output is formatted with the project's oxfmt, prettier or biome when installed.
66
+ - Keep `templates/` out of lint, format, typecheck and tests (the starters' configs do).
55
67
 
56
68
  ## Environment
57
69
 
@@ -102,38 +114,48 @@ export const usersController = defineController({ prefix: "/users", use: [authen
102
114
  - success status: 201 for POST, 200 otherwise; `res.status(code)` in the handler overrides the default, and the `status` option overrides both
103
115
  - writing to `res` yourself also works; brisk then sends nothing.
104
116
  - `setup` runs once per instance, when it is built (`listen`, `start`, `create`, `createTestClient`); `inject()` works there and in handlers. Configuration errors (a route left out of the returned object, an invalid `status`) are thrown then, not at `defineApp`.
105
- - Dates in a `response` schema: `z.date().transform((d) => d.toISOString()).pipe(z.iso.datetime())`, so the client type and OpenAPI say `string` (`date-time`).
117
+ - Dates in a `response` schema: `z.date().transform((d) => d.toISOString()).pipe(z.iso.datetime())` (the starters' `isoDate()`), so the client type and OpenAPI say `string` (`date-time`).
118
+ - Two routes with the same method and path (parameter names aside) fail when an instance is built.
119
+ - `defineController({ prefix, use, providers })`: `providers` are created at startup with the app (a feature's WebSocket gateway, cache invalidator). They come before the app's providers, so the app's and tests' values for the same token win.
106
120
 
107
121
  ## Middlewares
108
122
 
109
- - A middleware may not return `req`, `res`, `requestId`, `params`, `query`, `body` or `headers` (compile error). Returning `log` replaces the request logger.
123
+ `defineMiddleware(fn)` or `defineMiddleware({ params?, query?, security? }, fn)`. There is no name argument.
110
124
 
111
125
  ```ts
112
- export const authenticate = defineMiddleware(async ({ req }) => {
113
- const user = await verify(req.get("authorization"));
126
+ const bearer = { bearer: { type: "http", scheme: "bearer", bearerFormat: "JWT" } } satisfies SecuritySchemes;
127
+
128
+ export const authenticate = defineMiddleware({ security: bearer }, async ({ req, log }) => {
129
+ const user = await inject(Sessions).verify(req.get("authorization"));
114
130
  if (!user) throw new UnauthorizedError("Invalid token", { challenge: "Bearer" });
115
- return { user };
131
+ return { user, log: log.child({ userId: user.id }) };
116
132
  });
117
133
 
118
- export const requireRole = (role: Role) =>
119
- defineMiddleware(async (ctx) => {
134
+ export const requireProjectRole = (role: ProjectRole) =>
135
+ defineMiddleware({ params: z.object({ projectId: z.uuid() }), security: bearer }, async (ctx) => {
120
136
  const { user } = await authenticate.run(ctx);
121
- if (user.role !== role) throw new ForbiddenError();
122
- return { user };
137
+ return { projectRole: await inject(ProjectAccess).require(user.id, ctx.params.projectId, role) };
123
138
  });
124
139
  ```
125
140
 
126
- Return an object to add typed fields to the handler's `ctx`, return nothing for a pure check, throw an error class to stop the request. Each middleware runs at most once per request: another middleware or a handler can `await other.run(ctx)` to reuse its result. Sync or async both work.
141
+ - Return an object to add typed fields to the handler's `ctx`, return nothing for a pure check, throw an error class to stop the request. Sync or async.
142
+ - Each middleware runs at most once per request: another middleware or a handler can `await other.run(ctx)` to reuse its result.
143
+ - A middleware may not return `req`, `res`, `requestId`, `params`, `query`, `body` or `headers` (compile error). Returning `log` replaces the request logger.
144
+ - Order: controller `use`, group `use`, route `use`, then the route's validation. Authentication therefore rejects before input is validated.
145
+ - `params` / `query` options: validated before the middleware runs (400 `VALIDATION_FAILED`, async refinements work) and typed on its ctx. Use them for per-resource authorization such as project membership.
146
+ - `security`: OpenAPI security scheme objects. Every route behind the middleware gets `security` in OpenAPI and the document gets `components.securitySchemes`; routes without it stay public. `middleware.security` reads it back.
147
+ - `fromExpress(handler)` runs Express middleware inside `use`, in order: `use: [authenticate, fromExpress(multer(...).single("file"))]` parses uploads only after authentication. Errors passed to `next` reach the app's error handler (`http-errors` `status`/`expose` honored, `errors.mappers` apply); if it ends the response, the route stops there.
148
+ - App-wide middleware (CORS, helmet, rate limits, an audit log) goes in the app's `use`: `defineApp({ use: [fromExpress(cors({ origin })), audit] })`. A `defineMiddleware` there runs once per request, stops it by throwing, and adds its `security` to every OpenAPI operation, but its output is not typed on handler ctx: to type it, list it in the controller's `use` as well (it still runs once), or read it with `await audit.run(ctx)`.
127
149
 
128
150
  ## Dependency injection
129
151
 
130
152
  - A class with a no-argument constructor is its own token and a singleton per app: `readonly #repo = inject(UsersRepository)` in a field.
131
153
  - `defineToken<T>(name, factory?)` names anything else: config, clients, functions. Without a factory it must be provided.
132
- - `provide(token, value)` fixes a value: `defineApp({ providers: [provide(Clock, () => fixedDate)] })`, or `createTestClient(app, { providers: [...] })` to replace them in a test.
154
+ - `provide(token, value)` fixes a value: `defineApp({ providers: [provide(Clock, () => fixedDate)] })`. Later entries win; the order is controllers' providers, the app's, then those passed to `createTestClient` / `listen` / `start` / `create`. Those last are added to the app's, not swapped in: a value for the same token wins, and every listed class is still created.
133
155
  - A class listed in `providers` is created at boot even if nothing injects it (WebSocket gateways, consumers, jobs) so its lifecycle hooks run.
134
156
  - `injectLazy(token)` returns `() => T` and creates on first call: for heavy clients or to break a circular dependency.
135
157
  - `inject()` works in class fields, token factories, controller setup, middlewares, handlers, lifecycle hooks, and timers or loops started from them. Cycles are reported as `A -> B -> A`.
136
- - `inject()` works for the whole request, including everything in `configureExpress` (GraphQL resolvers, tRPC procedures). It does not work in callbacks a library fires on its own outside a request (gRPC handlers, queue client callbacks, `ws` message listeners): resolve the dependency in a class field and use the field. Outside the app, use an instance's container: `instance.container.get(...)`. In unit tests with `new Container([...])`, call `container.get(...)`.
158
+ - `inject()` works for the whole request, including the app's `use` and every `mount` (GraphQL resolvers, tRPC procedures). It does not work in callbacks a library fires on its own outside a request (gRPC handlers, queue client callbacks, `ws` message listeners): resolve the dependency in a class field and use the field. Outside the app, use an instance's container: `instance.container.get(...)`. In unit tests with `new Container([...])`, call `container.get(...)`.
137
159
  - `HttpServer` exists only once the instance listens: inject it in `onReady`, never in a field or `onInit` (that throws).
138
160
  - Unit tests without HTTP: `new Container([provide(Clock, fake)]).get(UsersService)`. `Logger` has a silent default there, so no logger provider is needed.
139
161
 
@@ -141,13 +163,15 @@ Return an object to add typed fields to the handler's `ctx`, return nothing for
141
163
 
142
164
  ```
143
165
  boot providers' onInit (dependencies first) -> listen -> providers' onReady
144
- request security headers -> request id -> configureExpress -> JSON body -> hooks.onRequest
145
- -> controller/group/route middlewares -> Zod validation -> handler -> response -> hooks.onResponse
146
- (any throw -> error handler -> hooks.onError)
147
- shutdown SIGINT/SIGTERM -> stop accepting -> providers' onDrain (reverse)
166
+ request security headers -> request id -> /health, /openapi.json -> app use
167
+ -> brisk route: JSON body -> controller/group/route middlewares -> Zod validation -> handler -> response
168
+ -> 405 / OPTIONS -> mount -> 404
169
+ (any throw -> error handler -> hooks.onError; hooks.onResponse when the response closes)
170
+ shutdown SIGINT/SIGTERM -> /health answers 503 -> stop accepting -> providers' onDrain (reverse)
148
171
  -> in-flight requests finish with Connection: close (sockets still open after http.shutdownTimeoutMs are destroyed)
149
172
  -> providers' onShutdown (reverse) -> exit 0
150
173
  crash unhandled rejection / uncaught exception -> logged fatal -> shutdown -> exit 1
174
+ startup failure (a hook throws, port taken) -> logged fatal "startup failed" with provider and hook -> shutdown -> rethrown
151
175
  ```
152
176
 
153
177
  Implement `OnInit`, `OnReady`, `OnDrain`, `OnShutdown` on a provider class. `onDrain` is where long-lived work stops (close WebSockets and SSE, stop consumers and timers); `onShutdown` releases resources (database pools, clients). Hook failures are logged and shutdown continues. All hooks may be sync or async.
@@ -163,7 +187,8 @@ export const app = defineApp({
163
187
  logger: createLogger({ level: env.LOG_LEVEL, pretty: env.NODE_ENV === "development" }),
164
188
  openapi: { title: "Shop API", version: "1.0.0" },
165
189
  errors: { mappers: [uniqueViolation], exposeInternal: env.NODE_ENV === "development" },
166
- configureExpress: (expressApp) => expressApp.use(cors({ origin: env.WEB_ORIGIN })),
190
+ use: [fromExpress(cors({ origin: env.WEB_ORIGIN }))],
191
+ mount: { "/static": express.static("public", { maxAge: "1h" }) },
167
192
  });
168
193
  export type Api = typeof app;
169
194
 
@@ -171,24 +196,28 @@ export type Api = typeof app;
171
196
  await app.listen(env.PORT);
172
197
  ```
173
198
 
174
- | Option | Purpose |
175
- | ------------------ | ---------------------------------------------------------------------------------------------------- |
176
- | `prefix` | Mount path for all controllers, such as `/api` |
177
- | `controllers` | Controllers by name: `{ users: usersController }`; the names are the typed client's top level |
178
- | `providers` | `provide(token, value)` entries and classes to create at startup |
179
- | `logger` | A pino logger (`createLogger({ level, pretty })`); injectable as `Logger` |
180
- | `configureExpress` | `(expressApp) => void` with full Express access, before JSON parsing and before brisk routes |
181
- | `hooks` | `onRequest`, `onResponse`, `onError` only; startup and shutdown work goes in provider classes |
182
- | `openapi` | `{ title, version, description?, servers?, path? }`: serve the OpenAPI document |
183
- | `errors` | `{ mappers, format, exposeInternal }`: translate library errors, change the error body, dev messages |
184
- | `http` | `{ bodyLimit, trustProxy, securityHeaders, healthPath, shutdownTimeoutMs }` |
199
+ | Option | Purpose |
200
+ | ------------- | ------------------------------------------------------------------------------------------------------ |
201
+ | `prefix` | Mount path for all controllers, such as `/api` |
202
+ | `controllers` | Controllers by name: `{ users: usersController }`; the names are the typed client's top level |
203
+ | `providers` | `provide(token, value)` entries and classes to create at startup |
204
+ | `logger` | A pino logger (`createLogger({ level, pretty })`); injectable as `Logger` |
205
+ | `use` | Middlewares for every request after `/health` and `/openapi.json`, before brisk's routes and mounts |
206
+ | `mount` | `{ "/path": handler \| [handlers] }`: Express handlers that answer what brisk's routes did not |
207
+ | `hooks` | `onResponse`, `onError` only; startup and shutdown work goes in provider classes |
208
+ | `openapi` | `{ title, version, description?, servers?, path? }`: serve the OpenAPI document |
209
+ | `errors` | `{ mappers, format, exposeInternal }`: translate library errors, change the error body, dev messages |
210
+ | `http` | `{ bodyLimit, settings, securityHeaders, healthPath, shutdownTimeoutMs }`; health is 503 when draining |
185
211
 
186
- Name the `configureExpress` parameter `expressApp`: `app` would shadow the exported `app`.
212
+ Mounts: paths are absolute (the `prefix` does not apply) and start with `/`; Express strips the path from `req.url`. brisk does not parse their bodies, so add a parser to the mount when it needs one (`[express.json(), handler]`). They get `getContext(req)`, `inject()`, the app's `use` and the error handler, and appear in the startup log as `MOUNT /path`. A mount never shadows a brisk route, so `mount: { "/": spaFallback }` is a fallback. brisk parses JSON only on its own routes.
213
+
214
+ `http.settings` is applied with `app.set(name, value)` after brisk's defaults: `{ "trust proxy": 1 }` only behind a proxy you control, `{ "query parser": "extended" }` for nested query objects (the default is flat).
187
215
 
188
216
  - `app.listen(port, { host?, server?, manageProcess?, providers? })` builds a fresh instance, starts it and resolves with it. `app.start({ manageProcess?, providers? })` does the same without HTTP (workers). `app.create({ providers? })` returns an unstarted instance (serverless: `const instance = app.create(); await instance.init();`, then hand `instance.express` to the adapter).
189
- - Every call builds a new, independent instance; `providers` there replace the app's own.
217
+ - Every call builds a new, independent instance; `providers` there are added after the app's own.
190
218
  - `app.manifest` and `app.openapi()` work on the definition, without starting anything.
191
219
  - The instance: `express`, `server`, `container`, `logger`, `manifest`, `openapi()`, `init()`, `shutdown()`.
220
+ - `inject(ErrorTranslator)(error)` returns `{ status, code, message, details }` as a route would answer, `errors.mappers` applied and internal messages hidden: for GraphQL `maskError` or WebSocket close codes. Call it directly inside a request (resolvers, mounts), resolve it in a class field for library callbacks (`ws` message listeners), and use `instance.container.get(ErrorTranslator)` outside the app.
192
221
  - `manageProcess` (default `true`) installs signal and crash handling.
193
222
 
194
223
  ## Errors
@@ -240,6 +269,7 @@ Handled automatically:
240
269
  | Malformed JSON | 400 `INVALID_JSON` |
241
270
  | Body over `http.bodyLimit` | 413 `PAYLOAD_TOO_LARGE` |
242
271
  | Body sent to a `body` route that no parser read | 415 `UNSUPPORTED_MEDIA_TYPE` |
272
+ | Express middleware error with `status`+`expose` | that status; the message only when `expose` is true |
243
273
  | Known path, wrong method | 405 with `Allow`; `OPTIONS` gets 204 with `Allow` |
244
274
  | Unknown path | 404 `ROUTE_NOT_FOUND` |
245
275
  | Result breaks its `response` schema | 500 (a server bug) |
@@ -248,7 +278,9 @@ Handled automatically:
248
278
  ## Typed client
249
279
 
250
280
  - `createClient(app, { baseUrl })` in Node (the definition or an instance); in a browser `createClient<Api>(manifest, { baseUrl, headers?, fetch? })` from `@raiyyan/brisk/client`, with the manifest written by `brisk client --out web/src/api.manifest.ts` (run it again after changing routes). `Api` is `typeof app` (`export type Api = typeof app` in `src/app.ts`), imported with `import type`.
251
- - Calls: `await api.users.get({ params, query, body, headers })`. Inputs are typed by the route's schemas; every call accepts extra headers (such as `authorization`); a second argument is passed to `fetch` (`signal`).
281
+ - Calls: `await api.users.get({ params, query, body, headers })`. Inputs are typed by the route's schemas; `params` and `query` also take the schema's output when a URL carries it (`z.stringbool()` takes `true`, `z.coerce.number()` takes `2`). Every call accepts extra headers (such as `authorization`); a second argument is passed to `fetch` (`signal`).
282
+ - `FormData`, `Blob`, `URLSearchParams`, `ArrayBuffer`/typed arrays and `ReadableStream` bodies are sent as they are (fetch sets the content type and multipart boundary); other bodies, strings included, are JSON.
283
+ - `api.files.upload.raw(input, init?)` sends the same request and resolves to the raw `Response` whatever its status, with the client's headers applied and string bodies sent as they are: for downloads, uploads (`body: formData`) and non-JSON. Its body type is `RawBody` or the route's body.
252
284
  - A call resolves to the `response` schema's output, else the handler's return value after JSON (`Date` becomes `string`); a stream or bytes come back as the raw `Response`, nothing as `undefined`.
253
285
  - Non-2xx rejects with `ApiError` (`status`, `code`, `message`, `details`, `requestId`, `body`). Branch on `error.code`, not the message.
254
286
 
@@ -257,6 +289,8 @@ Handled automatically:
257
289
  - `defineApp({ openapi: { title, version, description?, servers?, path? } })` serves an OpenAPI 3.1 document at `/openapi.json` (`path: false` serves nothing). `app.openapi()` returns it; `brisk openapi --out openapi.json` writes it.
258
290
  - `operationId` is `controller.route`, tags are controller names; request schemas are documented as clients send them, `response` as clients receive it.
259
291
  - Transforms and `z.date()` are documented as "any": end response schemas in the type they produce (`.pipe(z.iso.datetime())`).
292
+ - Routes behind a middleware with `security` list its schemes; the document lists them under `components.securitySchemes`.
293
+ - `/openapi.json` and `/health` are answered before the app's `use`.
260
294
 
261
295
  ## Testing
262
296
 
@@ -273,10 +307,43 @@ it("validates the body", async () => {
273
307
  });
274
308
  ```
275
309
 
276
- `createTestClient(app, { providers? })` starts a fresh instance on a random port with the full lifecycle (`providers` replace the app's own) and returns `call` (the typed client: `await api.call.users.create({ body, headers })`, rejecting with `ApiError`), raw `get | post | put | patch | delete(path, { body, headers })`, `baseUrl`, `instance` (the running `AppInstance`: `api.instance.container.get(X)`) and `close()`. Use `call` for behavior and the raw methods for status codes, headers and malformed input. A raw string or `Uint8Array` body is sent as is. `res.body` is `unknown`: parse it with Zod or use `toMatchObject`. `readError(res)` returns the typed error object.
310
+ `createTestClient(app, { providers? })` starts a fresh instance on a random port with the full lifecycle (`providers` are added after the app's own; a value for the same token wins) and returns `call` (the typed client: `await api.call.users.create({ body, headers })`, rejecting with `ApiError`), raw `get | post | put | patch | delete(path, { body, headers })`, `baseUrl`, `instance` (the running `AppInstance`: `api.instance.container.get(X)`) and `close()`. Use `call` for behavior and the raw methods for status codes, headers and malformed input. A raw string or `Uint8Array` body is sent as is. `res.body` is `unknown`: parse it with Zod or use `toMatchObject`. `readError(res)` returns the typed error object.
311
+
312
+ `fetch`, and so the test client's raw methods, adds `Cache-Control: no-cache` to a request carrying `If-None-Match`, which disables 304. Test 304s with `node:http`, or send `cache-control: max-age=0`.
277
313
 
278
314
  ## Recipes
279
315
 
316
+ Shared access rules: put the rules in one service and call it from every entry point (the middleware with declared `params` above, WebSocket gateways, GraphQL resolvers). Never re-implement them per transport.
317
+
318
+ ```ts
319
+ export class ProjectAccess {
320
+ readonly #members = inject(MembersRepository);
321
+ async roleIn(userId: string, projectId: string): Promise<ProjectRole | undefined> {
322
+ return (await this.#members.find(projectId, userId))?.role;
323
+ }
324
+ /** 404 for a non-member (ids do not leak), 403 for too low a role. */
325
+ async require(userId: string, projectId: string, role: ProjectRole): Promise<ProjectRole> {
326
+ const actual = await this.roleIn(userId, projectId);
327
+ if (!actual) throw new NotFoundError("Project not found");
328
+ if (RANK[actual] < RANK[role]) throw new ForbiddenError(`Requires the ${role} role`);
329
+ return actual;
330
+ }
331
+ }
332
+ ```
333
+
334
+ A gateway resolves it in a class field (`readonly #access = inject(ProjectAccess)`), because `ws` callbacks run outside the app; a resolver calls `inject(ProjectAccess).require(...)`.
335
+
336
+ Uploads: parse multipart on the route that takes it, after authentication. Clients send it with `.raw` and a `FormData` body:
337
+
338
+ ```ts
339
+ const upload = fromExpress(multer({ dest: "uploads/", limits: { fileSize: 5_000_000 } }).single("file"));
340
+ // in a controller with use: [authenticate]
341
+ create: route.post("/:taskId/attachments", { use: [upload] }, ({ req, params }) => {
342
+ if (!req.file) throw new BadRequestError("Send the file in the `file` field");
343
+ return attachments.save(params.taskId, req.file);
344
+ }),
345
+ ```
346
+
280
347
  WebSocket gateway (`ws`, works the same with Socket.IO):
281
348
 
282
349
  ```ts
@@ -294,27 +361,25 @@ export class ChatGateway implements OnReady, OnDrain {
294
361
  export const app = defineApp({ controllers, providers: [ChatGateway] });
295
362
  ```
296
363
 
297
- Plain Express (static files, SSE, uploads, webhooks, proxies) through `configureExpress`, which runs before JSON parsing:
364
+ Plain Express (CORS, static files, SSE, webhooks, form bodies) through `use` and `mount`:
298
365
 
299
366
  ```ts
300
367
  export const app = defineApp({
301
368
  controllers,
302
- configureExpress: (expressApp) => {
303
- expressApp.use(cors({ origin: env.WEB_ORIGIN }));
304
- expressApp.use("/static", express.static("public"));
305
- expressApp.post("/webhooks/stripe", express.raw({ type: "application/json" }), stripeWebhook);
306
- expressApp.post("/upload", multer().single("file"), upload);
307
- expressApp.use(express.urlencoded({ extended: false }));
308
- expressApp.get("/events", (req, res) => {
369
+ use: [fromExpress(cors({ origin: env.WEB_ORIGIN })), fromExpress(express.urlencoded({ extended: false }))],
370
+ mount: {
371
+ "/static": express.static("public"),
372
+ "/webhooks/stripe": [express.raw({ type: "application/json" }), stripeWebhook], // raw bytes for signatures
373
+ "/events": (req, res) => {
309
374
  res.writeHead(200, { "content-type": "text/event-stream" });
310
375
  res.write(`data: ${getContext(req).requestId}\n\n`);
311
- });
376
+ },
312
377
  },
313
378
  http: { securityHeaders: { "content-security-policy": "default-src 'self'" } },
314
379
  });
315
380
  ```
316
381
 
317
- Requests there have a context (`getContext(req)`) and thrown errors use the app's error handler. A form body parsed there reaches a brisk route's `body` schema.
382
+ Mounts have a context (`getContext(req)`) and thrown errors use the app's error handler. A form body parsed in `use` reaches a brisk route's `body` schema.
318
383
 
319
384
  Worker thread pool for CPU-heavy work:
320
385
 
@@ -350,30 +415,33 @@ Lazy loading: `injectLazy(HeavyClient)` defers creation; heavy code loads on dem
350
415
 
351
416
  Database clients: a class with `async onInit()` that connects and `onShutdown()` that disconnects. Translate driver errors once with `errors.mappers` (for example unique violation to `ConflictError`).
352
417
 
353
- GraphQL with Yoga. Resolvers call `inject()`:
418
+ GraphQL with Yoga. Resolvers, the context factory and `maskError` call `inject()`. Mount it through a function (Yoga's own type is not an Express `RequestHandler`):
354
419
 
355
420
  ```ts
356
- const yoga = createYoga({
357
- schema: createSchema({
358
- typeDefs: "type Query { user(id: ID!): User }",
359
- resolvers: { Query: { user: (_, { id }) => inject(UsersService).get(id) } },
360
- }),
421
+ export const yoga = createYoga<{ req: Request; res: Response }, { user: User | undefined }>({
422
+ schema, // resolvers: inject(ProjectAccess).require(user.id, args.id, "viewer"), then the service
423
+ context: async ({ req }) => ({ user: await inject(Sessions).verify(req.get("authorization")) }),
424
+ maskError: (error) => {
425
+ if (error instanceof GraphQLError && !error.originalError) return error; // syntax and validation errors
426
+ const cause = error instanceof GraphQLError ? error.originalError : error;
427
+ const { status, code, message } = inject(ErrorTranslator)(cause);
428
+ return new GraphQLError(message, { extensions: { code, status } });
429
+ },
361
430
  });
362
431
  export const app = defineApp({
363
432
  controllers,
364
- configureExpress: (expressApp) => expressApp.use(yoga.graphqlEndpoint, yoga),
433
+ mount: { [yoga.graphqlEndpoint]: (req, res) => yoga(req, res) },
365
434
  http: { securityHeaders: { "content-security-policy": false } }, // GraphiQL needs scripts; or set a GraphiQL-compatible policy
366
435
  });
367
436
  ```
368
437
 
369
- GraphQL with Apollo Server. `configureExpress` runs before brisk's JSON parser, so the Apollo route adds `express.json()` itself:
438
+ GraphQL with Apollo Server. brisk does not parse mount bodies, so the mount adds `express.json()`:
370
439
 
371
440
  ```ts
372
441
  await apollo.start();
373
442
  export const app = defineApp({
374
443
  controllers,
375
- configureExpress: (expressApp) =>
376
- expressApp.use("/graphql", express.json(), expressMiddleware(apollo, { context })),
444
+ mount: { "/graphql": [express.json(), expressMiddleware(apollo, { context })] },
377
445
  });
378
446
  ```
379
447
 
@@ -388,7 +456,7 @@ const router = t.router({
388
456
  });
389
457
  export const app = defineApp({
390
458
  controllers,
391
- configureExpress: (expressApp) => expressApp.use("/trpc", createExpressMiddleware({ router })),
459
+ mount: { "/trpc": createExpressMiddleware({ router }) },
392
460
  });
393
461
  ```
394
462
 
@@ -426,7 +494,7 @@ export const app = defineApp({ controllers, providers: [GrpcServer] });
426
494
 
427
495
  Socket.IO: `this.#io = new Server(inject(HttpServer))` in `onReady`, `await this.#io.close()` in `onDrain`, and list the class in `providers`.
428
496
 
429
- Microfrontends and serving frontends: serve static assets and remote entries with `express.static` in `configureExpress`. brisk's strict API headers block cross-origin asset loading, so relax them and allow the shell's origin:
497
+ Microfrontends and serving frontends: serve static assets and remote entries with `express.static` in `mount`. brisk's strict API headers block cross-origin asset loading, so relax them and allow the shell's origin:
430
498
 
431
499
  ```ts
432
500
  export const app = defineApp({
@@ -438,10 +506,8 @@ export const app = defineApp({
438
506
  "x-frame-options": false, // only when the frontend is embedded in iframes
439
507
  },
440
508
  },
441
- configureExpress: (expressApp) => {
442
- expressApp.use(cors({ origin: env.SHELL_ORIGIN }));
443
- expressApp.use("/remotes/checkout", express.static("dist/checkout"));
444
- },
509
+ use: [fromExpress(cors({ origin: env.SHELL_ORIGIN }))],
510
+ mount: { "/remotes/checkout": express.static("dist/checkout") },
445
511
  });
446
512
  ```
447
513
 
@@ -451,7 +517,7 @@ Custom or HTTPS server: `await app.listen(443, { server: https.createServer(tlsO
451
517
 
452
518
  Serverless: `const instance = app.create(); await instance.init();`, then pass `instance.express` to the platform adapter.
453
519
 
454
- Cluster, PM2, Kubernetes: brisk keeps no per-process state; run one process per core. Behind a load balancer set `server.keepAliveTimeout` above the balancer's idle timeout (via `instance.server` on the instance `listen` resolves with, or your own server) to avoid 502s. Bind `host: "0.0.0.0"` in containers when needed.
520
+ Cluster, PM2, Kubernetes: brisk keeps no per-process state; run one process per core. The app's own in-process state (event buses, uploads on local disk, in-memory rate limits) does not span instances: move it to shared services first. `/health` answers 503 once shutdown begins. Behind a load balancer set `server.keepAliveTimeout` above the balancer's idle timeout (via `instance.server` on the instance `listen` resolves with, or your own server) to avoid 502s. Bind `host: "0.0.0.0"` in containers when needed.
455
521
 
456
522
  ## Do and don't
457
523
 
@@ -460,8 +526,10 @@ Cluster, PM2, Kubernetes: brisk keeps no per-process state; run one process per
460
526
  - Do destructure what you need from `ctx` (`({ params, user, log }) => ...`) rather than copying the whole context.
461
527
  - Do keep services and repositories free of Express types; take plain values, return plain values.
462
528
  - Do give every route that takes input a schema, and a `response` schema for anything returning stored data.
463
- - Do add plain Express pieces through `configureExpress`; don't add routes to `instance.express` afterwards (they would land after the 404 handler).
464
- - Do use `provide()` in tests to swap dependencies; don't mutate singletons.
529
+ - Do add app-wide middleware through the app's `use`, non-brisk handlers through `mount`, and per-route Express middleware through `fromExpress` in a route's `use`; don't add routes to `instance.express` afterwards (they would land after the 404 handler).
530
+ - Do generate new features and middlewares with `brisk g`, and update `templates/` when conventions change; don't hand-copy a feature.
531
+ - Do keep authorization rules in a service shared by middlewares, gateways and resolvers.
532
+ - Do use `provide()` in tests to swap dependencies (test providers are added after the app's own); don't mutate singletons.
465
533
  - Do declare the app once (`export const app = defineApp(...)`) and import it everywhere; each `listen`, `start`, `create` or `createTestClient` builds its own instance. Don't write a `buildApp()` factory.
466
534
  - Do put long-lived connections behind `onDrain`, and resources behind `onShutdown`. Startup and shutdown work lives in provider classes; `defineApp({ hooks })` is per-request only.
467
535
  - Don't call `inject()` inside callbacks a library fires on its own (`ws` message listeners, gRPC handlers, queue clients); resolve the dependency in a class field.