@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.
- package/AGENTS.md +135 -67
- package/README.md +467 -139
- package/dist/src/cli/agents.js +5 -5
- package/dist/src/cli/agents.js.map +1 -1
- package/dist/src/cli/cli.d.ts +1 -1
- package/dist/src/cli/cli.d.ts.map +1 -1
- package/dist/src/cli/cli.js +22 -36
- package/dist/src/cli/cli.js.map +1 -1
- package/dist/src/cli/init.d.ts +8 -11
- package/dist/src/cli/init.d.ts.map +1 -1
- package/dist/src/cli/init.js +120 -96
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/render.d.ts +30 -0
- package/dist/src/cli/render.d.ts.map +1 -0
- package/dist/src/cli/render.js +101 -0
- package/dist/src/cli/render.js.map +1 -0
- package/dist/src/cli/system.d.ts +2 -2
- package/dist/src/cli/system.d.ts.map +1 -1
- package/dist/src/cli/system.js +3 -5
- package/dist/src/cli/system.js.map +1 -1
- package/dist/src/client/client.d.ts.map +1 -1
- package/dist/src/client/client.js +32 -5
- package/dist/src/client/client.js.map +1 -1
- package/dist/src/client/index.d.ts +1 -1
- package/dist/src/client/index.d.ts.map +1 -1
- package/dist/src/di/container.d.ts +10 -2
- package/dist/src/di/container.d.ts.map +1 -1
- package/dist/src/di/container.js +20 -3
- package/dist/src/di/container.js.map +1 -1
- package/dist/src/http/app.d.ts +41 -12
- package/dist/src/http/app.d.ts.map +1 -1
- package/dist/src/http/app.js.map +1 -1
- package/dist/src/http/context.d.ts +5 -5
- package/dist/src/http/context.js +5 -5
- package/dist/src/http/controller.d.ts +26 -9
- package/dist/src/http/controller.d.ts.map +1 -1
- package/dist/src/http/controller.js +25 -52
- package/dist/src/http/controller.js.map +1 -1
- package/dist/src/http/error-handler.d.ts +27 -0
- package/dist/src/http/error-handler.d.ts.map +1 -1
- package/dist/src/http/error-handler.js +26 -3
- package/dist/src/http/error-handler.js.map +1 -1
- package/dist/src/http/instance.d.ts +2 -2
- package/dist/src/http/instance.d.ts.map +1 -1
- package/dist/src/http/instance.js +107 -39
- package/dist/src/http/instance.js.map +1 -1
- package/dist/src/http/middleware.d.ts +104 -17
- package/dist/src/http/middleware.d.ts.map +1 -1
- package/dist/src/http/middleware.js +85 -26
- package/dist/src/http/middleware.js.map +1 -1
- package/dist/src/http/openapi.d.ts.map +1 -1
- package/dist/src/http/openapi.js +30 -2
- package/dist/src/http/openapi.js.map +1 -1
- package/dist/src/http/routes.d.ts +29 -7
- package/dist/src/http/routes.d.ts.map +1 -1
- package/dist/src/http/validation.d.ts +16 -0
- package/dist/src/http/validation.d.ts.map +1 -0
- package/dist/src/http/validation.js +51 -0
- package/dist/src/http/validation.js.map +1 -0
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/testing/test-client.d.ts +8 -6
- package/dist/src/testing/test-client.d.ts.map +1 -1
- package/dist/src/testing/test-client.js +3 -2
- package/dist/src/testing/test-client.js.map +1 -1
- package/package.json +3 -2
- package/starters/minimal/.env.example +3 -0
- package/starters/minimal/.oxfmtrc.json +5 -0
- package/starters/minimal/.oxlintrc.json +14 -0
- package/starters/minimal/AGENTS.md +23 -0
- package/starters/minimal/CLAUDE.md +2 -0
- package/starters/minimal/README.md +53 -0
- package/starters/minimal/_gitignore +3 -0
- package/starters/minimal/package.json +29 -0
- package/starters/minimal/src/app.ts +14 -0
- package/starters/minimal/src/env.ts +7 -0
- package/starters/minimal/src/features/index.ts +8 -0
- package/starters/minimal/src/features/notes/notes.controller.ts +38 -0
- package/starters/minimal/src/features/notes/notes.repository.ts +36 -0
- package/starters/minimal/src/features/notes/notes.schema.ts +24 -0
- package/starters/minimal/src/features/notes/notes.service.ts +33 -0
- package/starters/minimal/src/main.ts +4 -0
- package/starters/minimal/src/schemas.ts +26 -0
- package/starters/minimal/templates/feature/src/features/index.ts.controllers.insert +1 -0
- package/starters/minimal/templates/feature/src/features/index.ts.imports.insert +1 -0
- package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.controller.ts +38 -0
- package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.repository.ts +36 -0
- package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.schema.ts +24 -0
- package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.service.ts +33 -0
- package/starters/minimal/templates/feature/test/{{kebab}}.service.test.ts +20 -0
- package/starters/minimal/templates/feature/test/{{kebab}}.test.ts +35 -0
- package/starters/minimal/templates/middleware/src/middlewares/{{kebab}}.ts +12 -0
- package/starters/minimal/test/notes.service.test.ts +20 -0
- package/starters/minimal/test/notes.test.ts +35 -0
- package/starters/minimal/tsconfig.json +17 -0
- package/starters/minimal/vitest.config.ts +9 -0
- package/starters/sqlite-auth/.dockerignore +8 -0
- package/starters/sqlite-auth/.env.example +9 -0
- package/starters/sqlite-auth/.oxfmtrc.json +5 -0
- package/starters/sqlite-auth/.oxlintrc.json +14 -0
- package/starters/sqlite-auth/AGENTS.md +23 -0
- package/starters/sqlite-auth/CLAUDE.md +2 -0
- package/starters/sqlite-auth/Dockerfile +23 -0
- package/starters/sqlite-auth/README.md +93 -0
- package/starters/sqlite-auth/_gitignore +5 -0
- package/starters/sqlite-auth/compose.yaml +14 -0
- package/starters/sqlite-auth/package.json +35 -0
- package/starters/sqlite-auth/public/.gitkeep +0 -0
- package/starters/sqlite-auth/src/app.ts +39 -0
- package/starters/sqlite-auth/src/auth/tokens.ts +41 -0
- package/starters/sqlite-auth/src/database/clock.ts +4 -0
- package/starters/sqlite-auth/src/database/database.ts +29 -0
- package/starters/sqlite-auth/src/database/errors.ts +11 -0
- package/starters/sqlite-auth/src/database/migrations.ts +29 -0
- package/starters/sqlite-auth/src/env.ts +17 -0
- package/starters/sqlite-auth/src/features/auth/auth.controller.ts +28 -0
- package/starters/sqlite-auth/src/features/index.ts +12 -0
- package/starters/sqlite-auth/src/features/notes/notes.controller.ts +39 -0
- package/starters/sqlite-auth/src/features/notes/notes.repository.ts +47 -0
- package/starters/sqlite-auth/src/features/notes/notes.schema.ts +24 -0
- package/starters/sqlite-auth/src/features/notes/notes.service.ts +33 -0
- package/starters/sqlite-auth/src/features/users/admin-seed.ts +21 -0
- package/starters/sqlite-auth/src/features/users/users.controller.ts +42 -0
- package/starters/sqlite-auth/src/features/users/users.errors.ts +13 -0
- package/starters/sqlite-auth/src/features/users/users.repository.ts +77 -0
- package/starters/sqlite-auth/src/features/users/users.schema.ts +35 -0
- package/starters/sqlite-auth/src/features/users/users.service.ts +80 -0
- package/starters/sqlite-auth/src/main.ts +4 -0
- package/starters/sqlite-auth/src/middlewares/authenticate.ts +29 -0
- package/starters/sqlite-auth/src/schemas.ts +26 -0
- package/starters/sqlite-auth/templates/feature/src/database/migrations.ts.migrations.insert +5 -0
- package/starters/sqlite-auth/templates/feature/src/features/index.ts.controllers.insert +1 -0
- package/starters/sqlite-auth/templates/feature/src/features/index.ts.imports.insert +1 -0
- package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.controller.ts +39 -0
- package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.repository.ts +47 -0
- package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.schema.ts +24 -0
- package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.service.ts +33 -0
- package/starters/sqlite-auth/templates/feature/test/{{kebab}}.service.test.ts +20 -0
- package/starters/sqlite-auth/templates/feature/test/{{kebab}}.test.ts +41 -0
- package/starters/sqlite-auth/templates/middleware/src/middlewares/{{kebab}}.ts +12 -0
- package/starters/sqlite-auth/test/auth.test.ts +18 -0
- package/starters/sqlite-auth/test/helpers.ts +13 -0
- package/starters/sqlite-auth/test/notes.service.test.ts +20 -0
- package/starters/sqlite-auth/test/notes.test.ts +41 -0
- package/starters/sqlite-auth/test/users.service.test.ts +25 -0
- package/starters/sqlite-auth/test/users.test.ts +164 -0
- package/starters/sqlite-auth/tsconfig.json +17 -0
- package/starters/sqlite-auth/vitest.config.ts +15 -0
- package/dist/src/cli/generate.d.ts +0 -9
- package/dist/src/cli/generate.d.ts.map +0 -1
- package/dist/src/cli/generate.js +0 -39
- package/dist/src/cli/generate.js.map +0 -1
- package/dist/src/cli/templates/auth.d.ts +0 -4
- package/dist/src/cli/templates/auth.d.ts.map +0 -1
- package/dist/src/cli/templates/auth.js +0 -90
- package/dist/src/cli/templates/auth.js.map +0 -1
- package/dist/src/cli/templates/database.d.ts +0 -4
- package/dist/src/cli/templates/database.d.ts.map +0 -1
- package/dist/src/cli/templates/database.js +0 -107
- package/dist/src/cli/templates/database.js.map +0 -1
- package/dist/src/cli/templates/docker.d.ts +0 -4
- package/dist/src/cli/templates/docker.d.ts.map +0 -1
- package/dist/src/cli/templates/docker.js +0 -102
- package/dist/src/cli/templates/docker.js.map +0 -1
- package/dist/src/cli/templates/feature.d.ts +0 -8
- package/dist/src/cli/templates/feature.d.ts.map +0 -1
- package/dist/src/cli/templates/feature.js +0 -145
- package/dist/src/cli/templates/feature.js.map +0 -1
- package/dist/src/cli/templates/project.d.ts +0 -24
- package/dist/src/cli/templates/project.d.ts.map +0 -1
- package/dist/src/cli/templates/project.js +0 -166
- 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
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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.
|
|
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
|
|
348
|
-
|
|
|
349
|
-
| `prefix`
|
|
350
|
-
| `use`
|
|
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 {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
}
|
|
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,
|
|
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
|
-
|
|
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` (
|
|
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
|
|
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: `
|
|
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
|
|
548
|
-
->
|
|
549
|
-
|
|
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.
|
|
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. `
|
|
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` (
|
|
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 `
|
|
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.
|
|
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 `
|
|
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
|
|
748
|
-
|
|
|
749
|
-
| `prefix`
|
|
750
|
-
| `controllers`
|
|
751
|
-
| `providers`
|
|
752
|
-
| `logger`
|
|
753
|
-
| `
|
|
754
|
-
| `
|
|
755
|
-
| `
|
|
756
|
-
| `
|
|
757
|
-
| `
|
|
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
|
-
| `
|
|
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
|
|
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` | `[]` |
|
|
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
|
-
|
|
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
|
|
973
|
-
- To test `304 Not Modified`, use `node:http
|
|
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` |
|
|
982
|
-
| `brisk g
|
|
983
|
-
| `brisk
|
|
984
|
-
| `brisk
|
|
985
|
-
| `brisk
|
|
986
|
-
| `brisk
|
|
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
|
-
|
|
1106
|
+
```bash
|
|
1107
|
+
npx brisk init [--starter <name>] [--pm pnpm|npm|yarn] [--no-install]
|
|
1108
|
+
```
|
|
992
1109
|
|
|
993
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
1124
|
+
### brisk g
|
|
1013
1125
|
|
|
1014
|
-
|
|
1126
|
+
```bash
|
|
1127
|
+
npx brisk g feature products
|
|
1128
|
+
npx brisk g middleware tenant
|
|
1129
|
+
```
|
|
1015
1130
|
|
|
1016
|
-
|
|
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
|
-
- `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1482
|
+
mount: { "/graphql": [express.json(), expressMiddleware(apollo)] },
|
|
1154
1483
|
});
|
|
1155
1484
|
```
|
|
1156
1485
|
|
|
1157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1234
|
-
|
|
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.
|
|
1358
|
-
- Add CORS with an explicit origin list and a rate limiter in `
|
|
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.
|
|
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 `
|
|
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.
|