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