@raiyyan/brisk 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +535 -0
- package/LICENSE +21 -0
- package/README.md +1745 -2
- package/dist/bin/brisk.d.ts +3 -0
- package/dist/bin/brisk.d.ts.map +1 -0
- package/dist/bin/brisk.js +13 -0
- package/dist/bin/brisk.js.map +1 -0
- package/dist/src/cli/agents.d.ts +7 -0
- package/dist/src/cli/agents.d.ts.map +1 -0
- package/dist/src/cli/agents.js +55 -0
- package/dist/src/cli/agents.js.map +1 -0
- package/dist/src/cli/cli.d.ts +5 -0
- package/dist/src/cli/cli.d.ts.map +1 -0
- package/dist/src/cli/cli.js +113 -0
- package/dist/src/cli/cli.js.map +1 -0
- package/dist/src/cli/client.d.ts +12 -0
- package/dist/src/cli/client.d.ts.map +1 -0
- package/dist/src/cli/client.js +76 -0
- package/dist/src/cli/client.js.map +1 -0
- package/dist/src/cli/init.d.ts +16 -0
- package/dist/src/cli/init.d.ts.map +1 -0
- package/dist/src/cli/init.js +149 -0
- package/dist/src/cli/init.js.map +1 -0
- package/dist/src/cli/names.d.ts +14 -0
- package/dist/src/cli/names.d.ts.map +1 -0
- package/dist/src/cli/names.js +25 -0
- package/dist/src/cli/names.js.map +1 -0
- 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 +35 -0
- package/dist/src/cli/system.d.ts.map +1 -0
- package/dist/src/cli/system.js +81 -0
- package/dist/src/cli/system.js.map +1 -0
- package/dist/src/client/client.d.ts +64 -0
- package/dist/src/client/client.d.ts.map +1 -0
- package/dist/src/client/client.js +170 -0
- package/dist/src/client/client.js.map +1 -0
- package/dist/src/client/index.d.ts +8 -0
- package/dist/src/client/index.d.ts.map +1 -0
- package/dist/src/client/index.js +7 -0
- package/dist/src/client/index.js.map +1 -0
- package/dist/src/config/env.d.ts +28 -0
- package/dist/src/config/env.d.ts.map +1 -0
- package/dist/src/config/env.js +35 -0
- package/dist/src/config/env.js.map +1 -0
- package/dist/src/config/logger.d.ts +32 -0
- package/dist/src/config/logger.d.ts.map +1 -0
- package/dist/src/config/logger.js +31 -0
- package/dist/src/config/logger.js.map +1 -0
- package/dist/src/di/container.d.ts +194 -0
- package/dist/src/di/container.d.ts.map +1 -0
- package/dist/src/di/container.js +225 -0
- package/dist/src/di/container.js.map +1 -0
- package/dist/src/di/index.d.ts +8 -0
- package/dist/src/di/index.d.ts.map +1 -0
- package/dist/src/di/index.js +8 -0
- package/dist/src/di/index.js.map +1 -0
- package/dist/src/errors/http-errors.d.ts +295 -0
- package/dist/src/errors/http-errors.d.ts.map +1 -0
- package/dist/src/errors/http-errors.js +311 -0
- package/dist/src/errors/http-errors.js.map +1 -0
- package/dist/src/errors/index.d.ts +8 -0
- package/dist/src/errors/index.d.ts.map +1 -0
- package/dist/src/errors/index.js +8 -0
- package/dist/src/errors/index.js.map +1 -0
- package/dist/src/http/app.d.ts +229 -0
- package/dist/src/http/app.d.ts.map +1 -0
- package/dist/src/http/app.js +45 -0
- package/dist/src/http/app.js.map +1 -0
- package/dist/src/http/context.d.ts +66 -0
- package/dist/src/http/context.d.ts.map +1 -0
- package/dist/src/http/context.js +96 -0
- package/dist/src/http/context.js.map +1 -0
- package/dist/src/http/controller.d.ts +195 -0
- package/dist/src/http/controller.d.ts.map +1 -0
- package/dist/src/http/controller.js +290 -0
- package/dist/src/http/controller.js.map +1 -0
- package/dist/src/http/error-handler.d.ts +86 -0
- package/dist/src/http/error-handler.d.ts.map +1 -0
- package/dist/src/http/error-handler.js +147 -0
- package/dist/src/http/error-handler.js.map +1 -0
- package/dist/src/http/instance.d.ts +14 -0
- package/dist/src/http/instance.d.ts.map +1 -0
- package/dist/src/http/instance.js +377 -0
- package/dist/src/http/instance.js.map +1 -0
- package/dist/src/http/middleware.d.ts +141 -0
- package/dist/src/http/middleware.d.ts.map +1 -0
- package/dist/src/http/middleware.js +160 -0
- package/dist/src/http/middleware.js.map +1 -0
- package/dist/src/http/openapi.d.ts +34 -0
- package/dist/src/http/openapi.d.ts.map +1 -0
- package/dist/src/http/openapi.js +151 -0
- package/dist/src/http/openapi.js.map +1 -0
- package/dist/src/http/path-params.d.ts +24 -0
- package/dist/src/http/path-params.d.ts.map +1 -0
- package/dist/src/http/path-params.js +2 -0
- package/dist/src/http/path-params.js.map +1 -0
- package/dist/src/http/routes.d.ts +141 -0
- package/dist/src/http/routes.d.ts.map +1 -0
- package/dist/src/http/routes.js +2 -0
- package/dist/src/http/routes.js.map +1 -0
- package/dist/src/http/server.d.ts +23 -0
- package/dist/src/http/server.d.ts.map +1 -0
- package/dist/src/http/server.js +22 -0
- package/dist/src/http/server.js.map +1 -0
- 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 +22 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +19 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/testing/index.d.ts +7 -0
- package/dist/src/testing/index.d.ts.map +1 -0
- package/dist/src/testing/index.js +7 -0
- package/dist/src/testing/index.js.map +1 -0
- package/dist/src/testing/test-client.d.ts +67 -0
- package/dist/src/testing/test-client.d.ts.map +1 -0
- package/dist/src/testing/test-client.js +78 -0
- package/dist/src/testing/test-client.js.map +1 -0
- package/package.json +89 -3
- 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/AGENTS.md
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
# Building apps with brisk
|
|
2
|
+
|
|
3
|
+
Guide for AI coding agents working in an app that depends on `@raiyyan/brisk`. brisk is a thin, typed layer over Express 5 and Zod: Express stays fully reachable, and brisk removes the wiring (validation, DTOs, errors, typed context, env, DI, lifecycle).
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { defineApp, defineController, inject, z, NotFoundError } from "@raiyyan/brisk";
|
|
7
|
+
import { createTestClient, readError } from "@raiyyan/brisk/testing";
|
|
8
|
+
import { ApiError, createClient } from "@raiyyan/brisk/client"; // browser-safe: no server code
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`@raiyyan/brisk/di` and `@raiyyan/brisk/errors` are narrower entries; everything in them is also exported from `@raiyyan/brisk`.
|
|
12
|
+
|
|
13
|
+
`z` is re-exported Zod 4. Use it so schemas and brisk use the same Zod. Requires Node 24 or later. `pino-pretty` is an optional peer dependency: install it to use `createLogger({ pretty: true })`.
|
|
14
|
+
|
|
15
|
+
Full documentation: https://github.com/MohammadRaiyyan/brisk#readme
|
|
16
|
+
|
|
17
|
+
## Starting a project
|
|
18
|
+
|
|
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`.
|
|
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.
|
|
26
|
+
|
|
27
|
+
## Not in your way
|
|
28
|
+
|
|
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:
|
|
30
|
+
|
|
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.
|
|
35
|
+
- `instance.express`: the Express app itself (for adapters; serverless uses `app.create()`).
|
|
36
|
+
- `instance.server` and the `HttpServer` token: the Node server, for WebSockets and timeouts.
|
|
37
|
+
- `ListenOptions.server`: bring your own server (HTTPS, tuned timeouts).
|
|
38
|
+
- Provider lifecycle (`OnInit`, `OnReady`, `OnDrain`, `OnShutdown`): run anything alongside HTTP.
|
|
39
|
+
- `app.start()`: the same app without HTTP, for workers and schedulers.
|
|
40
|
+
- `ctx.res`: write a raw response from any handler.
|
|
41
|
+
|
|
42
|
+
## Project layout and generators
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
src/
|
|
46
|
+
env.ts defineEnv(...)
|
|
47
|
+
app.ts export const app = defineApp({...}); export type Api = typeof app
|
|
48
|
+
main.ts await app.listen(env.PORT)
|
|
49
|
+
schemas.ts isoDate(), PageQuery, page(item)
|
|
50
|
+
middlewares/<name>.ts defineMiddleware(...)
|
|
51
|
+
features/
|
|
52
|
+
index.ts export const controllers = { users: usersController } (keep the @brisk markers)
|
|
53
|
+
<name>/
|
|
54
|
+
<name>.schema.ts Zod request and response schemas
|
|
55
|
+
<name>.repository.ts data access only
|
|
56
|
+
<name>.service.ts business rules, throws error classes
|
|
57
|
+
<name>.controller.ts defineController(...): HTTP only, delegates to the service
|
|
58
|
+
templates/<kind>/ what `brisk g <kind> <name>` renders; owned by the project
|
|
59
|
+
test/<name>.test.ts
|
|
60
|
+
```
|
|
61
|
+
|
|
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).
|
|
67
|
+
|
|
68
|
+
## Environment
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
export const env = defineEnv({
|
|
72
|
+
PORT: z.coerce.number().int().positive().default(3000),
|
|
73
|
+
DATABASE_URL: z.url(),
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Loads `.env` if present, validates `process.env`, returns a frozen typed object. Invalid config prints every problem and exits (throws under `NODE_ENV=test`, or when `{ source }` is passed).
|
|
78
|
+
|
|
79
|
+
## Controllers
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
export const usersController = defineController({ prefix: "/users", use: [authenticate] }, (route) => {
|
|
83
|
+
const users = inject(UsersService);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
me: route.get("/me", { response: UserResponse }, ({ user }) => users.get(user.id)),
|
|
87
|
+
get: route.get("/:id", { params: UserIdParams, response: UserResponse }, ({ params }) =>
|
|
88
|
+
users.get(params.id),
|
|
89
|
+
),
|
|
90
|
+
admin: route.group("/", { use: [requireRole("admin")] }, (admin) => ({
|
|
91
|
+
create: admin.post("/", { body: CreateUserBody, response: UserResponse }, ({ body }) =>
|
|
92
|
+
users.create(body),
|
|
93
|
+
),
|
|
94
|
+
remove: admin.delete("/:id", { params: UserIdParams }, ({ params }) => {
|
|
95
|
+
users.remove(params.id);
|
|
96
|
+
}),
|
|
97
|
+
})),
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
- Always return every route by name from `setup`. The names are the typed client (`api.users.get`, `api.users.admin.create`) and the OpenAPI `operationId` (`users.admin.create`). If `setup` returns an object that leaves out a declared route, building an instance fails. A `setup` that returns nothing works but its controller is missing from the typed client.
|
|
103
|
+
- Methods: `route.get | post | put | patch | delete`, each `(path, handler)` or `(path, options, handler)`, each returning the route. `route.group(path, setup)` or `route.group(path, { use }, setup)` nests routes and returns what its `setup` returns.
|
|
104
|
+
- Options: `params`, `query`, `body`, `headers` (Zod schemas; validated values are typed on `ctx`), `response` (Zod schema; the result is parsed through it so unlisted fields never leak, and the handler's return type is checked against it), `use` (route middlewares, after the controller's and group's), `status` (2xx or 3xx only; throw an error class for failures).
|
|
105
|
+
- Without a `params` schema, path params are typed from the full path: `"/:orgId/users/:id"` gives `{ orgId: string; id: string }`, `"/files/*path"` gives `{ path: string[] }`, `"/posts{/:slug}"` gives `{ slug?: string }`. A `params` schema must list every param of the full path, prefix included, or it is stripped.
|
|
106
|
+
- Express matches routes in declaration order: declare wildcard and optional-segment routes after the specific routes they would catch.
|
|
107
|
+
- Params, query and env values are strings. Booleans: `z.stringbool()`, never `z.coerce.boolean()` (it makes `"false"` true). Async refinements are supported.
|
|
108
|
+
- Handler context: `req`, `res`, `requestId`, `log` (request logger carrying the request id), every middleware's output, and `params`, `query`, `body`, `headers`. Destructure what you need.
|
|
109
|
+
- The return value is the response:
|
|
110
|
+
- `undefined`: 204 (or `status`)
|
|
111
|
+
- a Node `Readable`: streamed with backpressure
|
|
112
|
+
- a `Buffer` / `Uint8Array`: sent as bytes
|
|
113
|
+
- anything else: JSON, through `response` when set
|
|
114
|
+
- success status: 201 for POST, 200 otherwise; `res.status(code)` in the handler overrides the default, and the `status` option overrides both
|
|
115
|
+
- writing to `res` yourself also works; brisk then sends nothing.
|
|
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`.
|
|
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.
|
|
120
|
+
|
|
121
|
+
## Middlewares
|
|
122
|
+
|
|
123
|
+
`defineMiddleware(fn)` or `defineMiddleware({ params?, query?, security? }, fn)`. There is no name argument.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
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"));
|
|
130
|
+
if (!user) throw new UnauthorizedError("Invalid token", { challenge: "Bearer" });
|
|
131
|
+
return { user, log: log.child({ userId: user.id }) };
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
export const requireProjectRole = (role: ProjectRole) =>
|
|
135
|
+
defineMiddleware({ params: z.object({ projectId: z.uuid() }), security: bearer }, async (ctx) => {
|
|
136
|
+
const { user } = await authenticate.run(ctx);
|
|
137
|
+
return { projectRole: await inject(ProjectAccess).require(user.id, ctx.params.projectId, role) };
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
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)`.
|
|
149
|
+
|
|
150
|
+
## Dependency injection
|
|
151
|
+
|
|
152
|
+
- A class with a no-argument constructor is its own token and a singleton per app: `readonly #repo = inject(UsersRepository)` in a field.
|
|
153
|
+
- `defineToken<T>(name, factory?)` names anything else: config, clients, functions. Without a factory it must be provided.
|
|
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.
|
|
155
|
+
- A class listed in `providers` is created at boot even if nothing injects it (WebSocket gateways, consumers, jobs) so its lifecycle hooks run.
|
|
156
|
+
- `injectLazy(token)` returns `() => T` and creates on first call: for heavy clients or to break a circular dependency.
|
|
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`.
|
|
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(...)`.
|
|
159
|
+
- `HttpServer` exists only once the instance listens: inject it in `onReady`, never in a field or `onInit` (that throws).
|
|
160
|
+
- Unit tests without HTTP: `new Container([provide(Clock, fake)]).get(UsersService)`. `Logger` has a silent default there, so no logger provider is needed.
|
|
161
|
+
|
|
162
|
+
## Lifecycle
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
boot providers' onInit (dependencies first) -> listen -> providers' onReady
|
|
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)
|
|
171
|
+
-> in-flight requests finish with Connection: close (sockets still open after http.shutdownTimeoutMs are destroyed)
|
|
172
|
+
-> providers' onShutdown (reverse) -> exit 0
|
|
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
|
|
175
|
+
```
|
|
176
|
+
|
|
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.
|
|
178
|
+
|
|
179
|
+
## The app
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
// src/app.ts: declares the app; nothing runs at import time
|
|
183
|
+
export const app = defineApp({
|
|
184
|
+
prefix: "/api",
|
|
185
|
+
controllers, // { users: usersController }, from src/features/index.ts
|
|
186
|
+
providers: [AdminSeed],
|
|
187
|
+
logger: createLogger({ level: env.LOG_LEVEL, pretty: env.NODE_ENV === "development" }),
|
|
188
|
+
openapi: { title: "Shop API", version: "1.0.0" },
|
|
189
|
+
errors: { mappers: [uniqueViolation], exposeInternal: env.NODE_ENV === "development" },
|
|
190
|
+
use: [fromExpress(cors({ origin: env.WEB_ORIGIN }))],
|
|
191
|
+
mount: { "/static": express.static("public", { maxAge: "1h" }) },
|
|
192
|
+
});
|
|
193
|
+
export type Api = typeof app;
|
|
194
|
+
|
|
195
|
+
// src/main.ts
|
|
196
|
+
await app.listen(env.PORT);
|
|
197
|
+
```
|
|
198
|
+
|
|
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 |
|
|
211
|
+
|
|
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).
|
|
215
|
+
|
|
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).
|
|
217
|
+
- Every call builds a new, independent instance; `providers` there are added after the app's own.
|
|
218
|
+
- `app.manifest` and `app.openapi()` work on the definition, without starting anything.
|
|
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.
|
|
221
|
+
- `manageProcess` (default `true`) installs signal and crash handling.
|
|
222
|
+
|
|
223
|
+
## Errors
|
|
224
|
+
|
|
225
|
+
Throw from anywhere; never catch just to respond.
|
|
226
|
+
|
|
227
|
+
| Class | Status | Extra |
|
|
228
|
+
| --------------------------- | ------ | ------------------------------------------------------------------ |
|
|
229
|
+
| `BadRequestError` | 400 | |
|
|
230
|
+
| `UnauthorizedError` | 401 | `{ challenge }` sends `WWW-Authenticate` |
|
|
231
|
+
| `ForbiddenError` | 403 | |
|
|
232
|
+
| `NotFoundError` | 404 | |
|
|
233
|
+
| `MethodNotAllowedError` | 405 | `(allowed, message?)` sends `Allow` |
|
|
234
|
+
| `NotAcceptableError` | 406 | |
|
|
235
|
+
| `RequestTimeoutError` | 408 | |
|
|
236
|
+
| `ConflictError` | 409 | |
|
|
237
|
+
| `GoneError` | 410 | |
|
|
238
|
+
| `PreconditionFailedError` | 412 | |
|
|
239
|
+
| `PayloadTooLargeError` | 413 | |
|
|
240
|
+
| `UnsupportedMediaTypeError` | 415 | |
|
|
241
|
+
| `UnprocessableEntityError` | 422 | |
|
|
242
|
+
| `TooManyRequestsError` | 429 | `{ retryAfter }` sends `Retry-After` |
|
|
243
|
+
| `InternalServerError` | 500 | message is sent; put details in `cause` |
|
|
244
|
+
| `NotImplementedError` | 501 | |
|
|
245
|
+
| `BadGatewayError` | 502 | |
|
|
246
|
+
| `ServiceUnavailableError` | 503 | `{ retryAfter }` sends `Retry-After` |
|
|
247
|
+
| `GatewayTimeoutError` | 504 | |
|
|
248
|
+
| `ValidationError` | 400 | `(location, issues)` or `ValidationError.fromZod(error, location)` |
|
|
249
|
+
| `HttpError` | any | `(status, message, options)` |
|
|
250
|
+
|
|
251
|
+
Every class takes `(message?, { code?, details?, cause?, headers? })`; `code` defaults from the status (`NOT_FOUND`). Domain errors are subclasses:
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
export class EmailTakenError extends ConflictError {
|
|
255
|
+
constructor(email: string) {
|
|
256
|
+
super("Email already registered", { code: "EMAIL_TAKEN", details: { email } });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Error body: `{ "error": { "code", "message", "details", "requestId" } }`.
|
|
262
|
+
|
|
263
|
+
Handled automatically:
|
|
264
|
+
|
|
265
|
+
| Situation | Response |
|
|
266
|
+
| ----------------------------------------------- | ------------------------------------------------------------------ |
|
|
267
|
+
| Invalid params, query, body or headers | 400 `VALIDATION_FAILED`, `details.issues[{ path, message, code }]` |
|
|
268
|
+
| `ZodError` thrown anywhere in app code | 400 `VALIDATION_FAILED`, `location: "input"` |
|
|
269
|
+
| Malformed JSON | 400 `INVALID_JSON` |
|
|
270
|
+
| Body over `http.bodyLimit` | 413 `PAYLOAD_TOO_LARGE` |
|
|
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 |
|
|
273
|
+
| Known path, wrong method | 405 with `Allow`; `OPTIONS` gets 204 with `Allow` |
|
|
274
|
+
| Unknown path | 404 `ROUTE_NOT_FOUND` |
|
|
275
|
+
| Result breaks its `response` schema | 500 (a server bug) |
|
|
276
|
+
| Anything else | 500 generic message; full error and cause logged |
|
|
277
|
+
|
|
278
|
+
## Typed client
|
|
279
|
+
|
|
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`.
|
|
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.
|
|
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`.
|
|
285
|
+
- Non-2xx rejects with `ApiError` (`status`, `code`, `message`, `details`, `requestId`, `body`). Branch on `error.code`, not the message.
|
|
286
|
+
|
|
287
|
+
## OpenAPI
|
|
288
|
+
|
|
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.
|
|
290
|
+
- `operationId` is `controller.route`, tags are controller names; request schemas are documented as clients send them, `response` as clients receive it.
|
|
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`.
|
|
294
|
+
|
|
295
|
+
## Testing
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
import { app } from "../src/app.ts";
|
|
299
|
+
|
|
300
|
+
const api = await createTestClient(app, { providers: [provide(Clock, () => new Date("2026-01-01"))] });
|
|
301
|
+
afterAll(() => api.close());
|
|
302
|
+
|
|
303
|
+
it("validates the body", async () => {
|
|
304
|
+
const res = await api.post("/api/users", { headers: { authorization: "Bearer t" }, body: { email: "x" } });
|
|
305
|
+
expect(res.status).toBe(400);
|
|
306
|
+
expect(readError(res).details).toMatchObject({ location: "body" });
|
|
307
|
+
});
|
|
308
|
+
```
|
|
309
|
+
|
|
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`.
|
|
313
|
+
|
|
314
|
+
## Recipes
|
|
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
|
+
|
|
347
|
+
WebSocket gateway (`ws`, works the same with Socket.IO):
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
export class ChatGateway implements OnReady, OnDrain {
|
|
351
|
+
#wss: WebSocketServer | undefined;
|
|
352
|
+
onReady() {
|
|
353
|
+
this.#wss = new WebSocketServer({ server: inject(HttpServer) });
|
|
354
|
+
this.#wss.on("connection", (socket) => socket.on("message", (data) => socket.send(data)));
|
|
355
|
+
}
|
|
356
|
+
onDrain() {
|
|
357
|
+
for (const client of this.#wss?.clients ?? []) client.close(1001, "server shutting down");
|
|
358
|
+
this.#wss?.close();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
export const app = defineApp({ controllers, providers: [ChatGateway] });
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Plain Express (CORS, static files, SSE, webhooks, form bodies) through `use` and `mount`:
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
export const app = defineApp({
|
|
368
|
+
controllers,
|
|
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) => {
|
|
374
|
+
res.writeHead(200, { "content-type": "text/event-stream" });
|
|
375
|
+
res.write(`data: ${getContext(req).requestId}\n\n`);
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
http: { securityHeaders: { "content-security-policy": "default-src 'self'" } },
|
|
379
|
+
});
|
|
380
|
+
```
|
|
381
|
+
|
|
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.
|
|
383
|
+
|
|
384
|
+
Worker thread pool for CPU-heavy work:
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
export class ImagePool implements OnInit, OnShutdown {
|
|
388
|
+
#worker: Worker | undefined;
|
|
389
|
+
onInit() {
|
|
390
|
+
this.#worker = new Worker(new URL("./resize.worker.ts", import.meta.url));
|
|
391
|
+
}
|
|
392
|
+
run(input: Job): Promise<Result> {
|
|
393
|
+
/* postMessage and await the reply */
|
|
394
|
+
}
|
|
395
|
+
async onShutdown() {
|
|
396
|
+
await this.#worker?.terminate();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Scheduled job: start the timer in `onReady` (`inject()` works inside it), clear it in `onDrain`, list the class in `providers`.
|
|
402
|
+
|
|
403
|
+
Queue consumer, in its own process with the same code and providers:
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
// worker.ts
|
|
407
|
+
await defineApp({ providers: [EmailConsumer] }).start(); // no controllers, no HTTP; signals and graceful shutdown still apply
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
In `onDrain`, stop taking messages and await the one in progress.
|
|
411
|
+
|
|
412
|
+
Caching: Express ETags and 304s work automatically for GET; set `Cache-Control` with `res.set(...)` in the handler. A Redis client is a provider: connect in `onInit`, quit in `onShutdown`.
|
|
413
|
+
|
|
414
|
+
Lazy loading: `injectLazy(HeavyClient)` defers creation; heavy code loads on demand with `const { PdfService } = await import("./pdf.service.ts"); inject(PdfService).render(...)` inside a handler.
|
|
415
|
+
|
|
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`).
|
|
417
|
+
|
|
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`):
|
|
419
|
+
|
|
420
|
+
```ts
|
|
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
|
+
},
|
|
430
|
+
});
|
|
431
|
+
export const app = defineApp({
|
|
432
|
+
controllers,
|
|
433
|
+
mount: { [yoga.graphqlEndpoint]: (req, res) => yoga(req, res) },
|
|
434
|
+
http: { securityHeaders: { "content-security-policy": false } }, // GraphiQL needs scripts; or set a GraphiQL-compatible policy
|
|
435
|
+
});
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
GraphQL with Apollo Server. brisk does not parse mount bodies, so the mount adds `express.json()`:
|
|
439
|
+
|
|
440
|
+
```ts
|
|
441
|
+
await apollo.start();
|
|
442
|
+
export const app = defineApp({
|
|
443
|
+
controllers,
|
|
444
|
+
mount: { "/graphql": [express.json(), expressMiddleware(apollo, { context })] },
|
|
445
|
+
});
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
tRPC. Procedures call `inject()`, and tRPC v11 accepts Zod schemas directly:
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
const t = initTRPC.create();
|
|
452
|
+
const router = t.router({
|
|
453
|
+
user: t.procedure
|
|
454
|
+
.input(z.object({ id: z.uuid() }))
|
|
455
|
+
.query(({ input }) => inject(UsersService).get(input.id)),
|
|
456
|
+
});
|
|
457
|
+
export const app = defineApp({
|
|
458
|
+
controllers,
|
|
459
|
+
mount: { "/trpc": createExpressMiddleware({ router }) },
|
|
460
|
+
});
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
gRPC with `@grpc/grpc-js` runs on its own HTTP/2 port, as in any Node app (this recipe is not covered by brisk's test suite):
|
|
464
|
+
|
|
465
|
+
```ts
|
|
466
|
+
export class GrpcServer implements OnReady, OnDrain {
|
|
467
|
+
readonly #server = new grpc.Server();
|
|
468
|
+
readonly #users = inject(UsersService); // resolved here: gRPC callbacks run outside the app
|
|
469
|
+
onReady() {
|
|
470
|
+
this.#server.addService(usersServiceDefinition, {
|
|
471
|
+
getUser: (call, callback) => callback(null, this.#users.get(call.request.id)),
|
|
472
|
+
});
|
|
473
|
+
return new Promise<void>((resolve, reject) =>
|
|
474
|
+
this.#server.bindAsync("0.0.0.0:50051", grpc.ServerCredentials.createInsecure(), (error) =>
|
|
475
|
+
error ? reject(error) : resolve(),
|
|
476
|
+
),
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
onDrain() {
|
|
480
|
+
return new Promise<void>((resolve) => {
|
|
481
|
+
const force = setTimeout(() => {
|
|
482
|
+
this.#server.forceShutdown();
|
|
483
|
+
resolve();
|
|
484
|
+
}, 5000);
|
|
485
|
+
this.#server.tryShutdown(() => {
|
|
486
|
+
clearTimeout(force);
|
|
487
|
+
resolve();
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
export const app = defineApp({ controllers, providers: [GrpcServer] });
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
Socket.IO: `this.#io = new Server(inject(HttpServer))` in `onReady`, `await this.#io.close()` in `onDrain`, and list the class in `providers`.
|
|
496
|
+
|
|
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:
|
|
498
|
+
|
|
499
|
+
```ts
|
|
500
|
+
export const app = defineApp({
|
|
501
|
+
controllers,
|
|
502
|
+
http: {
|
|
503
|
+
securityHeaders: {
|
|
504
|
+
"content-security-policy": false, // or your policy
|
|
505
|
+
"cross-origin-resource-policy": "cross-origin",
|
|
506
|
+
"x-frame-options": false, // only when the frontend is embedded in iframes
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
use: [fromExpress(cors({ origin: env.SHELL_ORIGIN }))],
|
|
510
|
+
mount: { "/remotes/checkout": express.static("dist/checkout") },
|
|
511
|
+
});
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
A backend-for-frontend is just brisk controllers calling upstream services; throw `BadGatewayError` for bad upstream answers and `GatewayTimeoutError` for timeouts.
|
|
515
|
+
|
|
516
|
+
Custom or HTTPS server: `await app.listen(443, { server: https.createServer(tlsOptions) })`. Inside providers use `inject(HttpServer)` from `onReady`.
|
|
517
|
+
|
|
518
|
+
Serverless: `const instance = app.create(); await instance.init();`, then pass `instance.express` to the platform adapter.
|
|
519
|
+
|
|
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.
|
|
521
|
+
|
|
522
|
+
## Do and don't
|
|
523
|
+
|
|
524
|
+
- Do return every route by name from a controller's setup; the names are the typed client and the OpenAPI operation ids.
|
|
525
|
+
- Do throw error classes from services; don't wrap handlers in try/catch and don't call `next()`.
|
|
526
|
+
- Do destructure what you need from `ctx` (`({ params, user, log }) => ...`) rather than copying the whole context.
|
|
527
|
+
- Do keep services and repositories free of Express types; take plain values, return plain values.
|
|
528
|
+
- Do give every route that takes input a schema, and a `response` schema for anything returning stored data.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mohammad Raiyyan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|