@raiyyan/brisk 0.0.0-stage → 0.1.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 +467 -0
- package/LICENSE +21 -0
- package/README.md +1417 -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 +127 -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/generate.d.ts +9 -0
- package/dist/src/cli/generate.d.ts.map +1 -0
- package/dist/src/cli/generate.js +39 -0
- package/dist/src/cli/generate.js.map +1 -0
- package/dist/src/cli/init.d.ts +19 -0
- package/dist/src/cli/init.d.ts.map +1 -0
- package/dist/src/cli/init.js +125 -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/system.d.ts +35 -0
- package/dist/src/cli/system.d.ts.map +1 -0
- package/dist/src/cli/system.js +83 -0
- package/dist/src/cli/system.js.map +1 -0
- package/dist/src/cli/templates/auth.d.ts +4 -0
- package/dist/src/cli/templates/auth.d.ts.map +1 -0
- package/dist/src/cli/templates/auth.js +90 -0
- package/dist/src/cli/templates/auth.js.map +1 -0
- package/dist/src/cli/templates/database.d.ts +4 -0
- package/dist/src/cli/templates/database.d.ts.map +1 -0
- package/dist/src/cli/templates/database.js +107 -0
- package/dist/src/cli/templates/database.js.map +1 -0
- package/dist/src/cli/templates/docker.d.ts +4 -0
- package/dist/src/cli/templates/docker.d.ts.map +1 -0
- package/dist/src/cli/templates/docker.js +102 -0
- package/dist/src/cli/templates/docker.js.map +1 -0
- package/dist/src/cli/templates/feature.d.ts +8 -0
- package/dist/src/cli/templates/feature.d.ts.map +1 -0
- package/dist/src/cli/templates/feature.js +145 -0
- package/dist/src/cli/templates/feature.js.map +1 -0
- package/dist/src/cli/templates/project.d.ts +24 -0
- package/dist/src/cli/templates/project.d.ts.map +1 -0
- package/dist/src/cli/templates/project.js +166 -0
- package/dist/src/cli/templates/project.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 +143 -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 +186 -0
- package/dist/src/di/container.d.ts.map +1 -0
- package/dist/src/di/container.js +208 -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 +200 -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 +178 -0
- package/dist/src/http/controller.d.ts.map +1 -0
- package/dist/src/http/controller.js +317 -0
- package/dist/src/http/controller.js.map +1 -0
- package/dist/src/http/error-handler.d.ts +59 -0
- package/dist/src/http/error-handler.d.ts.map +1 -0
- package/dist/src/http/error-handler.js +124 -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 +309 -0
- package/dist/src/http/instance.js.map +1 -0
- package/dist/src/http/middleware.d.ts +54 -0
- package/dist/src/http/middleware.d.ts.map +1 -0
- package/dist/src/http/middleware.js +101 -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 +123 -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 +119 -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/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 +65 -0
- package/dist/src/testing/test-client.d.ts.map +1 -0
- package/dist/src/testing/test-client.js +77 -0
- package/dist/src/testing/test-client.js.map +1 -0
- package/package.json +88 -3
package/AGENTS.md
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
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
|
+
- New app: install `@raiyyan/brisk` (and `typescript`, `@types/node`, `vitest` as dev dependencies), then run `npx brisk init`. It creates `src/env.ts`, `src/app.ts` (`export const app = defineApp(...)` with prefix `/api`, and `export type Api = typeof app`), `src/main.ts` (`await app.listen(env.PORT)`), `src/features/index.ts`, `.env.example` and a `tsconfig.json` that lets Node run `.ts` directly. It never overwrites existing files, adds `"type": "module"` and missing `dev`, `start`, `typecheck`, `test` and `brisk` scripts, then runs `brisk agents`.
|
|
20
|
+
- `brisk init` asks about each option, or takes flags: `--db none|sqlite|postgres` (a `Database` provider and a unique-violation mapper to 409 `ALREADY_EXISTS`; postgres uses `pg` + Drizzle), `--auth` (HS256 bearer tokens, `authenticate` and `requireRole` middlewares), `--docker`, `--ci` (GitHub Actions), `--web` (CORS, rate limiting, static `public/`, pretty dev logs), `--pm`, `--yes`/`-y` (defaults, no questions), `--no-install`. It installs missing packages with the detected package manager after writing files.
|
|
21
|
+
- Then: `cp .env.example .env`, `pnpm brisk g feature <name>`, `pnpm dev`.
|
|
22
|
+
- `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
|
+
|
|
24
|
+
## Not in your way
|
|
25
|
+
|
|
26
|
+
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
|
+
|
|
28
|
+
- `configureExpress(expressApp)`: full Express access, before JSON parsing and before brisk's routes.
|
|
29
|
+
- `instance.express`: the Express app itself (for adapters; serverless uses `app.create()`).
|
|
30
|
+
- `instance.server` and the `HttpServer` token: the Node server, for WebSockets and timeouts.
|
|
31
|
+
- `ListenOptions.server`: bring your own server (HTTPS, tuned timeouts).
|
|
32
|
+
- Provider lifecycle (`OnInit`, `OnReady`, `OnDrain`, `OnShutdown`): run anything alongside HTTP.
|
|
33
|
+
- `app.start()`: the same app without HTTP, for workers and schedulers.
|
|
34
|
+
- `ctx.res`: write a raw response from any handler.
|
|
35
|
+
|
|
36
|
+
## Project layout
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
src/
|
|
40
|
+
env.ts defineEnv(...)
|
|
41
|
+
app.ts export const app = defineApp({...}); export type Api = typeof app
|
|
42
|
+
main.ts await app.listen(env.PORT)
|
|
43
|
+
middlewares/<name>.ts defineMiddleware(...)
|
|
44
|
+
features/
|
|
45
|
+
index.ts export const controllers = { users: usersController } (keep the @brisk markers)
|
|
46
|
+
<name>/
|
|
47
|
+
<name>.schema.ts Zod request and response schemas
|
|
48
|
+
<name>.repository.ts data access only
|
|
49
|
+
<name>.service.ts business rules, throws error classes
|
|
50
|
+
<name>.controller.ts defineController(...): HTTP only, delegates to the service
|
|
51
|
+
test/<name>.test.ts
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Generate a feature with `pnpm brisk g feature <name>`; it writes the four files (singular entity names: `products` gives `Product`, `CreateProductBody`) and a test, registers the controller as `products: productsController` at the `// @brisk:imports` and `// @brisk:controllers` markers in `src/features/index.ts`, and runs the project's formatter (oxfmt or Prettier) on the result. `pnpm brisk g middleware <name>` writes a middleware. `pnpm brisk help` lists commands.
|
|
55
|
+
|
|
56
|
+
## Environment
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
export const env = defineEnv({
|
|
60
|
+
PORT: z.coerce.number().int().positive().default(3000),
|
|
61
|
+
DATABASE_URL: z.url(),
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
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).
|
|
66
|
+
|
|
67
|
+
## Controllers
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
export const usersController = defineController({ prefix: "/users", use: [authenticate] }, (route) => {
|
|
71
|
+
const users = inject(UsersService);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
me: route.get("/me", { response: UserResponse }, ({ user }) => users.get(user.id)),
|
|
75
|
+
get: route.get("/:id", { params: UserIdParams, response: UserResponse }, ({ params }) =>
|
|
76
|
+
users.get(params.id),
|
|
77
|
+
),
|
|
78
|
+
admin: route.group("/", { use: [requireRole("admin")] }, (admin) => ({
|
|
79
|
+
create: admin.post("/", { body: CreateUserBody, response: UserResponse }, ({ body }) =>
|
|
80
|
+
users.create(body),
|
|
81
|
+
),
|
|
82
|
+
remove: admin.delete("/:id", { params: UserIdParams }, ({ params }) => {
|
|
83
|
+
users.remove(params.id);
|
|
84
|
+
}),
|
|
85
|
+
})),
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- 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.
|
|
91
|
+
- 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.
|
|
92
|
+
- 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).
|
|
93
|
+
- 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.
|
|
94
|
+
- Express matches routes in declaration order: declare wildcard and optional-segment routes after the specific routes they would catch.
|
|
95
|
+
- Params, query and env values are strings. Booleans: `z.stringbool()`, never `z.coerce.boolean()` (it makes `"false"` true). Async refinements are supported.
|
|
96
|
+
- 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.
|
|
97
|
+
- The return value is the response:
|
|
98
|
+
- `undefined`: 204 (or `status`)
|
|
99
|
+
- a Node `Readable`: streamed with backpressure
|
|
100
|
+
- a `Buffer` / `Uint8Array`: sent as bytes
|
|
101
|
+
- anything else: JSON, through `response` when set
|
|
102
|
+
- success status: 201 for POST, 200 otherwise; `res.status(code)` in the handler overrides the default, and the `status` option overrides both
|
|
103
|
+
- writing to `res` yourself also works; brisk then sends nothing.
|
|
104
|
+
- `setup` runs once per instance, when it is built (`listen`, `start`, `create`, `createTestClient`); `inject()` works there and in handlers. Configuration errors (a route left out of the returned object, an invalid `status`) are thrown then, not at `defineApp`.
|
|
105
|
+
- Dates in a `response` schema: `z.date().transform((d) => d.toISOString()).pipe(z.iso.datetime())`, so the client type and OpenAPI say `string` (`date-time`).
|
|
106
|
+
|
|
107
|
+
## Middlewares
|
|
108
|
+
|
|
109
|
+
- A middleware may not return `req`, `res`, `requestId`, `params`, `query`, `body` or `headers` (compile error). Returning `log` replaces the request logger.
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
export const authenticate = defineMiddleware(async ({ req }) => {
|
|
113
|
+
const user = await verify(req.get("authorization"));
|
|
114
|
+
if (!user) throw new UnauthorizedError("Invalid token", { challenge: "Bearer" });
|
|
115
|
+
return { user };
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
export const requireRole = (role: Role) =>
|
|
119
|
+
defineMiddleware(async (ctx) => {
|
|
120
|
+
const { user } = await authenticate.run(ctx);
|
|
121
|
+
if (user.role !== role) throw new ForbiddenError();
|
|
122
|
+
return { user };
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Return an object to add typed fields to the handler's `ctx`, return nothing for a pure check, throw an error class to stop the request. Each middleware runs at most once per request: another middleware or a handler can `await other.run(ctx)` to reuse its result. Sync or async both work.
|
|
127
|
+
|
|
128
|
+
## Dependency injection
|
|
129
|
+
|
|
130
|
+
- A class with a no-argument constructor is its own token and a singleton per app: `readonly #repo = inject(UsersRepository)` in a field.
|
|
131
|
+
- `defineToken<T>(name, factory?)` names anything else: config, clients, functions. Without a factory it must be provided.
|
|
132
|
+
- `provide(token, value)` fixes a value: `defineApp({ providers: [provide(Clock, () => fixedDate)] })`, or `createTestClient(app, { providers: [...] })` to replace them in a test.
|
|
133
|
+
- A class listed in `providers` is created at boot even if nothing injects it (WebSocket gateways, consumers, jobs) so its lifecycle hooks run.
|
|
134
|
+
- `injectLazy(token)` returns `() => T` and creates on first call: for heavy clients or to break a circular dependency.
|
|
135
|
+
- `inject()` works in class fields, token factories, controller setup, middlewares, handlers, lifecycle hooks, and timers or loops started from them. Cycles are reported as `A -> B -> A`.
|
|
136
|
+
- `inject()` works for the whole request, including everything in `configureExpress` (GraphQL resolvers, tRPC procedures). It does not work in callbacks a library fires on its own outside a request (gRPC handlers, queue client callbacks, `ws` message listeners): resolve the dependency in a class field and use the field. Outside the app, use an instance's container: `instance.container.get(...)`. In unit tests with `new Container([...])`, call `container.get(...)`.
|
|
137
|
+
- `HttpServer` exists only once the instance listens: inject it in `onReady`, never in a field or `onInit` (that throws).
|
|
138
|
+
- Unit tests without HTTP: `new Container([provide(Clock, fake)]).get(UsersService)`. `Logger` has a silent default there, so no logger provider is needed.
|
|
139
|
+
|
|
140
|
+
## Lifecycle
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
boot providers' onInit (dependencies first) -> listen -> providers' onReady
|
|
144
|
+
request security headers -> request id -> configureExpress -> JSON body -> hooks.onRequest
|
|
145
|
+
-> controller/group/route middlewares -> Zod validation -> handler -> response -> hooks.onResponse
|
|
146
|
+
(any throw -> error handler -> hooks.onError)
|
|
147
|
+
shutdown SIGINT/SIGTERM -> stop accepting -> providers' onDrain (reverse)
|
|
148
|
+
-> in-flight requests finish with Connection: close (sockets still open after http.shutdownTimeoutMs are destroyed)
|
|
149
|
+
-> providers' onShutdown (reverse) -> exit 0
|
|
150
|
+
crash unhandled rejection / uncaught exception -> logged fatal -> shutdown -> exit 1
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
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.
|
|
154
|
+
|
|
155
|
+
## The app
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
// src/app.ts: declares the app; nothing runs at import time
|
|
159
|
+
export const app = defineApp({
|
|
160
|
+
prefix: "/api",
|
|
161
|
+
controllers, // { users: usersController }, from src/features/index.ts
|
|
162
|
+
providers: [AdminSeed],
|
|
163
|
+
logger: createLogger({ level: env.LOG_LEVEL, pretty: env.NODE_ENV === "development" }),
|
|
164
|
+
openapi: { title: "Shop API", version: "1.0.0" },
|
|
165
|
+
errors: { mappers: [uniqueViolation], exposeInternal: env.NODE_ENV === "development" },
|
|
166
|
+
configureExpress: (expressApp) => expressApp.use(cors({ origin: env.WEB_ORIGIN })),
|
|
167
|
+
});
|
|
168
|
+
export type Api = typeof app;
|
|
169
|
+
|
|
170
|
+
// src/main.ts
|
|
171
|
+
await app.listen(env.PORT);
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
| Option | Purpose |
|
|
175
|
+
| ------------------ | ---------------------------------------------------------------------------------------------------- |
|
|
176
|
+
| `prefix` | Mount path for all controllers, such as `/api` |
|
|
177
|
+
| `controllers` | Controllers by name: `{ users: usersController }`; the names are the typed client's top level |
|
|
178
|
+
| `providers` | `provide(token, value)` entries and classes to create at startup |
|
|
179
|
+
| `logger` | A pino logger (`createLogger({ level, pretty })`); injectable as `Logger` |
|
|
180
|
+
| `configureExpress` | `(expressApp) => void` with full Express access, before JSON parsing and before brisk routes |
|
|
181
|
+
| `hooks` | `onRequest`, `onResponse`, `onError` only; startup and shutdown work goes in provider classes |
|
|
182
|
+
| `openapi` | `{ title, version, description?, servers?, path? }`: serve the OpenAPI document |
|
|
183
|
+
| `errors` | `{ mappers, format, exposeInternal }`: translate library errors, change the error body, dev messages |
|
|
184
|
+
| `http` | `{ bodyLimit, trustProxy, securityHeaders, healthPath, shutdownTimeoutMs }` |
|
|
185
|
+
|
|
186
|
+
Name the `configureExpress` parameter `expressApp`: `app` would shadow the exported `app`.
|
|
187
|
+
|
|
188
|
+
- `app.listen(port, { host?, server?, manageProcess?, providers? })` builds a fresh instance, starts it and resolves with it. `app.start({ manageProcess?, providers? })` does the same without HTTP (workers). `app.create({ providers? })` returns an unstarted instance (serverless: `const instance = app.create(); await instance.init();`, then hand `instance.express` to the adapter).
|
|
189
|
+
- Every call builds a new, independent instance; `providers` there replace the app's own.
|
|
190
|
+
- `app.manifest` and `app.openapi()` work on the definition, without starting anything.
|
|
191
|
+
- The instance: `express`, `server`, `container`, `logger`, `manifest`, `openapi()`, `init()`, `shutdown()`.
|
|
192
|
+
- `manageProcess` (default `true`) installs signal and crash handling.
|
|
193
|
+
|
|
194
|
+
## Errors
|
|
195
|
+
|
|
196
|
+
Throw from anywhere; never catch just to respond.
|
|
197
|
+
|
|
198
|
+
| Class | Status | Extra |
|
|
199
|
+
| --------------------------- | ------ | ------------------------------------------------------------------ |
|
|
200
|
+
| `BadRequestError` | 400 | |
|
|
201
|
+
| `UnauthorizedError` | 401 | `{ challenge }` sends `WWW-Authenticate` |
|
|
202
|
+
| `ForbiddenError` | 403 | |
|
|
203
|
+
| `NotFoundError` | 404 | |
|
|
204
|
+
| `MethodNotAllowedError` | 405 | `(allowed, message?)` sends `Allow` |
|
|
205
|
+
| `NotAcceptableError` | 406 | |
|
|
206
|
+
| `RequestTimeoutError` | 408 | |
|
|
207
|
+
| `ConflictError` | 409 | |
|
|
208
|
+
| `GoneError` | 410 | |
|
|
209
|
+
| `PreconditionFailedError` | 412 | |
|
|
210
|
+
| `PayloadTooLargeError` | 413 | |
|
|
211
|
+
| `UnsupportedMediaTypeError` | 415 | |
|
|
212
|
+
| `UnprocessableEntityError` | 422 | |
|
|
213
|
+
| `TooManyRequestsError` | 429 | `{ retryAfter }` sends `Retry-After` |
|
|
214
|
+
| `InternalServerError` | 500 | message is sent; put details in `cause` |
|
|
215
|
+
| `NotImplementedError` | 501 | |
|
|
216
|
+
| `BadGatewayError` | 502 | |
|
|
217
|
+
| `ServiceUnavailableError` | 503 | `{ retryAfter }` sends `Retry-After` |
|
|
218
|
+
| `GatewayTimeoutError` | 504 | |
|
|
219
|
+
| `ValidationError` | 400 | `(location, issues)` or `ValidationError.fromZod(error, location)` |
|
|
220
|
+
| `HttpError` | any | `(status, message, options)` |
|
|
221
|
+
|
|
222
|
+
Every class takes `(message?, { code?, details?, cause?, headers? })`; `code` defaults from the status (`NOT_FOUND`). Domain errors are subclasses:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
export class EmailTakenError extends ConflictError {
|
|
226
|
+
constructor(email: string) {
|
|
227
|
+
super("Email already registered", { code: "EMAIL_TAKEN", details: { email } });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Error body: `{ "error": { "code", "message", "details", "requestId" } }`.
|
|
233
|
+
|
|
234
|
+
Handled automatically:
|
|
235
|
+
|
|
236
|
+
| Situation | Response |
|
|
237
|
+
| ----------------------------------------------- | ------------------------------------------------------------------ |
|
|
238
|
+
| Invalid params, query, body or headers | 400 `VALIDATION_FAILED`, `details.issues[{ path, message, code }]` |
|
|
239
|
+
| `ZodError` thrown anywhere in app code | 400 `VALIDATION_FAILED`, `location: "input"` |
|
|
240
|
+
| Malformed JSON | 400 `INVALID_JSON` |
|
|
241
|
+
| Body over `http.bodyLimit` | 413 `PAYLOAD_TOO_LARGE` |
|
|
242
|
+
| Body sent to a `body` route that no parser read | 415 `UNSUPPORTED_MEDIA_TYPE` |
|
|
243
|
+
| Known path, wrong method | 405 with `Allow`; `OPTIONS` gets 204 with `Allow` |
|
|
244
|
+
| Unknown path | 404 `ROUTE_NOT_FOUND` |
|
|
245
|
+
| Result breaks its `response` schema | 500 (a server bug) |
|
|
246
|
+
| Anything else | 500 generic message; full error and cause logged |
|
|
247
|
+
|
|
248
|
+
## Typed client
|
|
249
|
+
|
|
250
|
+
- `createClient(app, { baseUrl })` in Node (the definition or an instance); in a browser `createClient<Api>(manifest, { baseUrl, headers?, fetch? })` from `@raiyyan/brisk/client`, with the manifest written by `brisk client --out web/src/api.manifest.ts` (run it again after changing routes). `Api` is `typeof app` (`export type Api = typeof app` in `src/app.ts`), imported with `import type`.
|
|
251
|
+
- Calls: `await api.users.get({ params, query, body, headers })`. Inputs are typed by the route's schemas; every call accepts extra headers (such as `authorization`); a second argument is passed to `fetch` (`signal`).
|
|
252
|
+
- 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
|
+
- Non-2xx rejects with `ApiError` (`status`, `code`, `message`, `details`, `requestId`, `body`). Branch on `error.code`, not the message.
|
|
254
|
+
|
|
255
|
+
## OpenAPI
|
|
256
|
+
|
|
257
|
+
- `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
|
+
- `operationId` is `controller.route`, tags are controller names; request schemas are documented as clients send them, `response` as clients receive it.
|
|
259
|
+
- Transforms and `z.date()` are documented as "any": end response schemas in the type they produce (`.pipe(z.iso.datetime())`).
|
|
260
|
+
|
|
261
|
+
## Testing
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { app } from "../src/app.ts";
|
|
265
|
+
|
|
266
|
+
const api = await createTestClient(app, { providers: [provide(Clock, () => new Date("2026-01-01"))] });
|
|
267
|
+
afterAll(() => api.close());
|
|
268
|
+
|
|
269
|
+
it("validates the body", async () => {
|
|
270
|
+
const res = await api.post("/api/users", { headers: { authorization: "Bearer t" }, body: { email: "x" } });
|
|
271
|
+
expect(res.status).toBe(400);
|
|
272
|
+
expect(readError(res).details).toMatchObject({ location: "body" });
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`createTestClient(app, { providers? })` starts a fresh instance on a random port with the full lifecycle (`providers` replace the app's own) and returns `call` (the typed client: `await api.call.users.create({ body, headers })`, rejecting with `ApiError`), raw `get | post | put | patch | delete(path, { body, headers })`, `baseUrl`, `instance` (the running `AppInstance`: `api.instance.container.get(X)`) and `close()`. Use `call` for behavior and the raw methods for status codes, headers and malformed input. A raw string or `Uint8Array` body is sent as is. `res.body` is `unknown`: parse it with Zod or use `toMatchObject`. `readError(res)` returns the typed error object.
|
|
277
|
+
|
|
278
|
+
## Recipes
|
|
279
|
+
|
|
280
|
+
WebSocket gateway (`ws`, works the same with Socket.IO):
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
export class ChatGateway implements OnReady, OnDrain {
|
|
284
|
+
#wss: WebSocketServer | undefined;
|
|
285
|
+
onReady() {
|
|
286
|
+
this.#wss = new WebSocketServer({ server: inject(HttpServer) });
|
|
287
|
+
this.#wss.on("connection", (socket) => socket.on("message", (data) => socket.send(data)));
|
|
288
|
+
}
|
|
289
|
+
onDrain() {
|
|
290
|
+
for (const client of this.#wss?.clients ?? []) client.close(1001, "server shutting down");
|
|
291
|
+
this.#wss?.close();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
export const app = defineApp({ controllers, providers: [ChatGateway] });
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Plain Express (static files, SSE, uploads, webhooks, proxies) through `configureExpress`, which runs before JSON parsing:
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
export const app = defineApp({
|
|
301
|
+
controllers,
|
|
302
|
+
configureExpress: (expressApp) => {
|
|
303
|
+
expressApp.use(cors({ origin: env.WEB_ORIGIN }));
|
|
304
|
+
expressApp.use("/static", express.static("public"));
|
|
305
|
+
expressApp.post("/webhooks/stripe", express.raw({ type: "application/json" }), stripeWebhook);
|
|
306
|
+
expressApp.post("/upload", multer().single("file"), upload);
|
|
307
|
+
expressApp.use(express.urlencoded({ extended: false }));
|
|
308
|
+
expressApp.get("/events", (req, res) => {
|
|
309
|
+
res.writeHead(200, { "content-type": "text/event-stream" });
|
|
310
|
+
res.write(`data: ${getContext(req).requestId}\n\n`);
|
|
311
|
+
});
|
|
312
|
+
},
|
|
313
|
+
http: { securityHeaders: { "content-security-policy": "default-src 'self'" } },
|
|
314
|
+
});
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Requests there have a context (`getContext(req)`) and thrown errors use the app's error handler. A form body parsed there reaches a brisk route's `body` schema.
|
|
318
|
+
|
|
319
|
+
Worker thread pool for CPU-heavy work:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
export class ImagePool implements OnInit, OnShutdown {
|
|
323
|
+
#worker: Worker | undefined;
|
|
324
|
+
onInit() {
|
|
325
|
+
this.#worker = new Worker(new URL("./resize.worker.ts", import.meta.url));
|
|
326
|
+
}
|
|
327
|
+
run(input: Job): Promise<Result> {
|
|
328
|
+
/* postMessage and await the reply */
|
|
329
|
+
}
|
|
330
|
+
async onShutdown() {
|
|
331
|
+
await this.#worker?.terminate();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Scheduled job: start the timer in `onReady` (`inject()` works inside it), clear it in `onDrain`, list the class in `providers`.
|
|
337
|
+
|
|
338
|
+
Queue consumer, in its own process with the same code and providers:
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
// worker.ts
|
|
342
|
+
await defineApp({ providers: [EmailConsumer] }).start(); // no controllers, no HTTP; signals and graceful shutdown still apply
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
In `onDrain`, stop taking messages and await the one in progress.
|
|
346
|
+
|
|
347
|
+
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`.
|
|
348
|
+
|
|
349
|
+
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.
|
|
350
|
+
|
|
351
|
+
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
|
+
|
|
353
|
+
GraphQL with Yoga. Resolvers call `inject()`:
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
const yoga = createYoga({
|
|
357
|
+
schema: createSchema({
|
|
358
|
+
typeDefs: "type Query { user(id: ID!): User }",
|
|
359
|
+
resolvers: { Query: { user: (_, { id }) => inject(UsersService).get(id) } },
|
|
360
|
+
}),
|
|
361
|
+
});
|
|
362
|
+
export const app = defineApp({
|
|
363
|
+
controllers,
|
|
364
|
+
configureExpress: (expressApp) => expressApp.use(yoga.graphqlEndpoint, yoga),
|
|
365
|
+
http: { securityHeaders: { "content-security-policy": false } }, // GraphiQL needs scripts; or set a GraphiQL-compatible policy
|
|
366
|
+
});
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
GraphQL with Apollo Server. `configureExpress` runs before brisk's JSON parser, so the Apollo route adds `express.json()` itself:
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
await apollo.start();
|
|
373
|
+
export const app = defineApp({
|
|
374
|
+
controllers,
|
|
375
|
+
configureExpress: (expressApp) =>
|
|
376
|
+
expressApp.use("/graphql", express.json(), expressMiddleware(apollo, { context })),
|
|
377
|
+
});
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
tRPC. Procedures call `inject()`, and tRPC v11 accepts Zod schemas directly:
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
const t = initTRPC.create();
|
|
384
|
+
const router = t.router({
|
|
385
|
+
user: t.procedure
|
|
386
|
+
.input(z.object({ id: z.uuid() }))
|
|
387
|
+
.query(({ input }) => inject(UsersService).get(input.id)),
|
|
388
|
+
});
|
|
389
|
+
export const app = defineApp({
|
|
390
|
+
controllers,
|
|
391
|
+
configureExpress: (expressApp) => expressApp.use("/trpc", createExpressMiddleware({ router })),
|
|
392
|
+
});
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
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):
|
|
396
|
+
|
|
397
|
+
```ts
|
|
398
|
+
export class GrpcServer implements OnReady, OnDrain {
|
|
399
|
+
readonly #server = new grpc.Server();
|
|
400
|
+
readonly #users = inject(UsersService); // resolved here: gRPC callbacks run outside the app
|
|
401
|
+
onReady() {
|
|
402
|
+
this.#server.addService(usersServiceDefinition, {
|
|
403
|
+
getUser: (call, callback) => callback(null, this.#users.get(call.request.id)),
|
|
404
|
+
});
|
|
405
|
+
return new Promise<void>((resolve, reject) =>
|
|
406
|
+
this.#server.bindAsync("0.0.0.0:50051", grpc.ServerCredentials.createInsecure(), (error) =>
|
|
407
|
+
error ? reject(error) : resolve(),
|
|
408
|
+
),
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
onDrain() {
|
|
412
|
+
return new Promise<void>((resolve) => {
|
|
413
|
+
const force = setTimeout(() => {
|
|
414
|
+
this.#server.forceShutdown();
|
|
415
|
+
resolve();
|
|
416
|
+
}, 5000);
|
|
417
|
+
this.#server.tryShutdown(() => {
|
|
418
|
+
clearTimeout(force);
|
|
419
|
+
resolve();
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
export const app = defineApp({ controllers, providers: [GrpcServer] });
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Socket.IO: `this.#io = new Server(inject(HttpServer))` in `onReady`, `await this.#io.close()` in `onDrain`, and list the class in `providers`.
|
|
428
|
+
|
|
429
|
+
Microfrontends and serving frontends: serve static assets and remote entries with `express.static` in `configureExpress`. brisk's strict API headers block cross-origin asset loading, so relax them and allow the shell's origin:
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
export const app = defineApp({
|
|
433
|
+
controllers,
|
|
434
|
+
http: {
|
|
435
|
+
securityHeaders: {
|
|
436
|
+
"content-security-policy": false, // or your policy
|
|
437
|
+
"cross-origin-resource-policy": "cross-origin",
|
|
438
|
+
"x-frame-options": false, // only when the frontend is embedded in iframes
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
configureExpress: (expressApp) => {
|
|
442
|
+
expressApp.use(cors({ origin: env.SHELL_ORIGIN }));
|
|
443
|
+
expressApp.use("/remotes/checkout", express.static("dist/checkout"));
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
A backend-for-frontend is just brisk controllers calling upstream services; throw `BadGatewayError` for bad upstream answers and `GatewayTimeoutError` for timeouts.
|
|
449
|
+
|
|
450
|
+
Custom or HTTPS server: `await app.listen(443, { server: https.createServer(tlsOptions) })`. Inside providers use `inject(HttpServer)` from `onReady`.
|
|
451
|
+
|
|
452
|
+
Serverless: `const instance = app.create(); await instance.init();`, then pass `instance.express` to the platform adapter.
|
|
453
|
+
|
|
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.
|
|
455
|
+
|
|
456
|
+
## Do and don't
|
|
457
|
+
|
|
458
|
+
- Do return every route by name from a controller's setup; the names are the typed client and the OpenAPI operation ids.
|
|
459
|
+
- Do throw error classes from services; don't wrap handlers in try/catch and don't call `next()`.
|
|
460
|
+
- Do destructure what you need from `ctx` (`({ params, user, log }) => ...`) rather than copying the whole context.
|
|
461
|
+
- Do keep services and repositories free of Express types; take plain values, return plain values.
|
|
462
|
+
- Do give every route that takes input a schema, and a `response` schema for anything returning stored data.
|
|
463
|
+
- Do add plain Express pieces through `configureExpress`; don't add routes to `instance.express` afterwards (they would land after the 404 handler).
|
|
464
|
+
- Do use `provide()` in tests to swap dependencies; don't mutate singletons.
|
|
465
|
+
- 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
|
+
- 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
|
+
- 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.
|