@zerotal/arch 1.7.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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Middleware
|
|
3
|
+
description: Inspect, transform, or short-circuit HTTP requests in a layered pipeline between the server and your controller.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Middleware
|
|
7
|
+
|
|
8
|
+
Middleware sits in the HTTP pipeline between the server and your controller.
|
|
9
|
+
Each piece of middleware receives the request context, can inspect or modify it,
|
|
10
|
+
then either passes control to the next layer or short-circuits with a response.
|
|
11
|
+
|
|
12
|
+
Middleware ships in `@zerotal/core`, so there is nothing to install or register
|
|
13
|
+
— import the types and write a class.
|
|
14
|
+
|
|
15
|
+
## Getting Started
|
|
16
|
+
|
|
17
|
+
The middleware pipeline is built into `@zerotal/core` — nothing to install:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import type { HttpContext, NextFn } from "zerotal";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Writing middleware
|
|
24
|
+
|
|
25
|
+
Implement the `Pipe<HttpContext>` interface — a single `handle` method that
|
|
26
|
+
receives the request `HttpContext` directly and reaches the request/response
|
|
27
|
+
helpers on it. A middleware does exactly one of three things: **continue** by
|
|
28
|
+
returning `next()`, **short-circuit** by returning a `Response`, or **wrap** by
|
|
29
|
+
awaiting `next()` (which resolves to the downstream `Response`) and returning a
|
|
30
|
+
transformed one. `next()` takes no arguments:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// app/middleware/LogRequestMiddleware.ts
|
|
34
|
+
import type { Pipe, NextFn, HttpContext } from "zerotal";
|
|
35
|
+
|
|
36
|
+
export class LogRequestMiddleware implements Pipe<HttpContext> {
|
|
37
|
+
async handle(ctx: HttpContext, next: NextFn): Promise<Response | void> {
|
|
38
|
+
console.log(`→ ${ctx.request.method} ${ctx.path()}`);
|
|
39
|
+
|
|
40
|
+
const response = await next(); // ← inner middleware + controller run here
|
|
41
|
+
|
|
42
|
+
console.log(`← ${response?.status} (${ctx.took}ms)`);
|
|
43
|
+
return response;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The `Pipe` and `NextFn` types come straight from the package:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// the signatures, for reference
|
|
52
|
+
interface Pipe<T> {
|
|
53
|
+
handle(payload: T, next: NextFn): Promise<Response | void>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type NextFn = () => Promise<Response | void>;
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Short-circuiting
|
|
60
|
+
|
|
61
|
+
Return a `Response` without calling `next` to stop the pipeline:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// app/middleware/MaintenanceMiddleware.ts
|
|
65
|
+
import type { Pipe, NextFn, HttpContext } from "zerotal";
|
|
66
|
+
|
|
67
|
+
export class MaintenanceMiddleware implements Pipe<HttpContext> {
|
|
68
|
+
async handle(ctx: HttpContext, next: NextFn): Promise<Response | void> {
|
|
69
|
+
if (Bun.env.MAINTENANCE_MODE === "true") {
|
|
70
|
+
return new Response("Down for maintenance", { status: 503 }); // ← does NOT call next
|
|
71
|
+
}
|
|
72
|
+
return next();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
> **Note** — You can also set `ctx.response` and `return` (void) instead of returning the `Response`
|
|
78
|
+
> directly — both are equivalent. `ctx.response` is the canonical store; a `void` return
|
|
79
|
+
> leaves whatever it holds untouched, so it can never erase a response a deeper pipe set.
|
|
80
|
+
|
|
81
|
+
### Wrapping
|
|
82
|
+
|
|
83
|
+
Code after `await next()` runs on the way out — after the controller has responded. `next()`
|
|
84
|
+
resolves to the downstream `Response` (or `undefined` if none was produced). Use this for
|
|
85
|
+
saving session data or appending headers. Because some responses (e.g. `Response.redirect()`)
|
|
86
|
+
have immutable headers, reconstruct rather than mutate — the `withHeaders` helper does this:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
// app/middleware/TimingHeaderMiddleware.ts
|
|
90
|
+
import type { Pipe, NextFn, HttpContext } from "zerotal";
|
|
91
|
+
import { withHeaders } from "zerotal";
|
|
92
|
+
|
|
93
|
+
export class TimingHeaderMiddleware implements Pipe<HttpContext> {
|
|
94
|
+
async handle(ctx: HttpContext, next: NextFn): Promise<Response | void> {
|
|
95
|
+
const response = await next();
|
|
96
|
+
if (!response) return;
|
|
97
|
+
return withHeaders(response, { "Server-Timing": `total;dur=${ctx.took}` });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Attaching middleware to routes
|
|
103
|
+
|
|
104
|
+
There are five ways to attach middleware. Reach for the one that matches the scope you want:
|
|
105
|
+
|
|
106
|
+
- **Per-route** — one specific route needs the middleware.
|
|
107
|
+
- **Route groups** — a handful of related routes share it.
|
|
108
|
+
- **Named groups** — the same stack (`web`, `api`) is reused across many groups.
|
|
109
|
+
- **File-based** (`_middleware.ts`) — every route file under a directory inherits it.
|
|
110
|
+
- **Auto-discovered** — a class in `app/middleware/` referenced by its name as a string.
|
|
111
|
+
|
|
112
|
+
For middleware that must run on _every_ request, see [Global middleware](#global-middleware).
|
|
113
|
+
|
|
114
|
+
### Per-route
|
|
115
|
+
|
|
116
|
+
Pass an array of middleware classes as the fourth argument to any route
|
|
117
|
+
registration method:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
// routes/index.ts
|
|
121
|
+
Router.get("/dashboard", DashboardController, "index", [AuthMiddleware]);
|
|
122
|
+
Router.post("/posts", PostController, "store", [AuthMiddleware, ThrottleMiddleware]);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Route groups
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
// routes/index.ts
|
|
129
|
+
Router.group({ middleware: AuthMiddleware }, () => {
|
|
130
|
+
Router.get("/dashboard", DashboardController, "index");
|
|
131
|
+
Router.resource("posts", PostController);
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Auto-discovered middleware
|
|
136
|
+
|
|
137
|
+
Middleware classes under `app/middleware/` are auto-registered at boot as a **named group under
|
|
138
|
+
their class name** — reference them by string in routes without importing:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
// app/middleware/EnsureSubscribed.ts → referenceable as "EnsureSubscribed"
|
|
142
|
+
Router.group({ middleware: ["EnsureSubscribed"] }, () => {
|
|
143
|
+
/* … */
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
They are **not** global by default; set `static global = true` on the class to add it to the
|
|
148
|
+
global pipeline. See [Conventions](/docs/conventions#middleware-appmiddleware).
|
|
149
|
+
|
|
150
|
+
### Named middleware groups
|
|
151
|
+
|
|
152
|
+
Define a group once, reference it by name everywhere:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
// in a ServiceProvider.onRegister()
|
|
156
|
+
Router.middlewareGroup("api", [ThrottleMiddleware, BearerTokenMiddleware]);
|
|
157
|
+
Router.middlewareGroup("web", [SessionMiddleware, CsrfMiddleware]);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
// routes/index.ts
|
|
162
|
+
Router.group({ prefix: "/api/v1", middleware: "api" }, () => {
|
|
163
|
+
Router.resource("posts", PostController);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
Router.group({ middleware: ["web", AuthMiddleware] }, () => {
|
|
167
|
+
Router.get("/dashboard", DashboardController, "index");
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### File-based route middleware
|
|
172
|
+
|
|
173
|
+
Drop a `_middleware.ts` file into any directory under your file routes folder.
|
|
174
|
+
It applies to every route file in that directory and all subdirectories:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
// app/routes/admin/_middleware.ts
|
|
178
|
+
import { AuthMiddleware } from "@zerotal/auth";
|
|
179
|
+
import { AdminMiddleware } from "../../middleware/AdminMiddleware.ts";
|
|
180
|
+
|
|
181
|
+
export const middleware = [AuthMiddleware, AdminMiddleware];
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Middleware stacks from outer directories are prepended automatically —
|
|
185
|
+
you get `root/_middleware → admin/_middleware → route handler` in one pipeline.
|
|
186
|
+
|
|
187
|
+
## Global middleware
|
|
188
|
+
|
|
189
|
+
Register middleware that runs on every request in `bootstrap/app.ts`:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// bootstrap/app.ts
|
|
193
|
+
export default Application.create({ providers }).use([
|
|
194
|
+
DevtoolsInjectionMiddleware,
|
|
195
|
+
RequestIdMiddleware,
|
|
196
|
+
]);
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Framework providers auto-register their own middleware (e.g. `SessionMiddleware`
|
|
200
|
+
from `@zerotal/session`, `AuthMiddleware` from `@zerotal/auth`) — you do not need to
|
|
201
|
+
add them manually.
|
|
202
|
+
|
|
203
|
+
## Built-in middleware
|
|
204
|
+
|
|
205
|
+
The package ships several middleware you can drop straight into `app.use([...])`
|
|
206
|
+
or a route's middleware array. Each extends `BaseMiddleware`, so `.with({ … })`
|
|
207
|
+
bakes options into a zero-argument class.
|
|
208
|
+
|
|
209
|
+
### CorsMiddleware
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
// bootstrap/app.ts
|
|
213
|
+
import { CorsMiddleware } from "zerotal";
|
|
214
|
+
|
|
215
|
+
// Permissive (default — allow any origin)
|
|
216
|
+
app.use([CorsMiddleware]);
|
|
217
|
+
|
|
218
|
+
// Restrict to one origin
|
|
219
|
+
app.use([CorsMiddleware.with({ origin: "https://app.example.com", credentials: true })]);
|
|
220
|
+
|
|
221
|
+
// Dynamic origin check
|
|
222
|
+
app.use([CorsMiddleware.with({ origin: (o) => o.endsWith(".mycompany.com") })]);
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
| Option | Default | Description |
|
|
226
|
+
| ---------------- | -------------- | ------------------------------------------------------------ |
|
|
227
|
+
| `origin` | `'*'` | Allowed origins — string, string[], or `(origin) => boolean` |
|
|
228
|
+
| `methods` | all verbs | Allowed HTTP methods |
|
|
229
|
+
| `allowedHeaders` | common headers | Allowed request headers |
|
|
230
|
+
| `exposedHeaders` | `[]` | Headers JS may read from the response |
|
|
231
|
+
| `credentials` | `false` | Allow cookies / auth in cross-origin requests |
|
|
232
|
+
| `maxAge` | `600` | Preflight cache duration (seconds) |
|
|
233
|
+
|
|
234
|
+
> **Danger** — Setting `origin: '*'` together with `credentials: true` is rejected by browsers
|
|
235
|
+
> and leaks cross-origin responses. Name an explicit origin (or use the function form) whenever
|
|
236
|
+
> you allow credentials.
|
|
237
|
+
|
|
238
|
+
### ThrottleMiddleware
|
|
239
|
+
|
|
240
|
+
Rate-limits requests with an in-memory sliding window counter. Returns `429`
|
|
241
|
+
with `Retry-After` and `X-RateLimit-*` headers when the limit is exceeded.
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
// bootstrap/app.ts (global) and routes/index.ts (per-route)
|
|
245
|
+
import { ThrottleMiddleware } from "zerotal";
|
|
246
|
+
|
|
247
|
+
// Global: 120 requests / minute
|
|
248
|
+
app.use([ThrottleMiddleware.with({ maxAttempts: 120, windowSeconds: 60 })]);
|
|
249
|
+
|
|
250
|
+
// Per-route: 5 login attempts / minute
|
|
251
|
+
Router.post("/login", AuthController, "login", [
|
|
252
|
+
ThrottleMiddleware.with({ maxAttempts: 5, windowSeconds: 60 }),
|
|
253
|
+
]);
|
|
254
|
+
|
|
255
|
+
// By authenticated user ID instead of IP
|
|
256
|
+
ThrottleMiddleware.with({
|
|
257
|
+
maxAttempts: 1000,
|
|
258
|
+
windowSeconds: 3600,
|
|
259
|
+
keyResolver: (ctx) => String(ctx.user?.id ?? ctx.ip()),
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
| Option | Default | Description |
|
|
264
|
+
| ---------------- | ----------- | ------------------------------------- |
|
|
265
|
+
| `maxAttempts` | (required) | Max requests in the window |
|
|
266
|
+
| `windowSeconds` | `60` | Window length in seconds |
|
|
267
|
+
| `keyResolver` | IP address | Function returning the rate-limit key |
|
|
268
|
+
| `trustedProxies` | `undefined` | Number of trusted upstream proxies |
|
|
269
|
+
|
|
270
|
+
### RateLimiter — named limiters
|
|
271
|
+
|
|
272
|
+
For limits reused across routes (and queryable/resettable at runtime), define a
|
|
273
|
+
**named limiter** once at boot (e.g. in a `ServiceProvider`), then apply it by name.
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
// in a ServiceProvider (boot time)
|
|
277
|
+
import { RateLimiter } from "zerotal";
|
|
278
|
+
|
|
279
|
+
// 1000 req/hour per authenticated user (falls back to IP when unauthenticated)
|
|
280
|
+
RateLimiter.for("api").limit(1000).every(3600).byUser().register();
|
|
281
|
+
|
|
282
|
+
// 5 login attempts per minute, per IP
|
|
283
|
+
RateLimiter.for("login").limit(5).every(60).byIp().register();
|
|
284
|
+
|
|
285
|
+
// 500 req/min keyed by an API-key header (unknown key -> per IP)
|
|
286
|
+
RateLimiter.for("partner").limit(500).every(60).byApiKey("x-api-key").register();
|
|
287
|
+
|
|
288
|
+
// Custom key
|
|
289
|
+
RateLimiter.for("upload")
|
|
290
|
+
.limit(10)
|
|
291
|
+
.every(3600)
|
|
292
|
+
.by((ctx) => `user:${ctx.user?.id ?? "anon"}`)
|
|
293
|
+
.register();
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Apply as route middleware with `RateLimiter.middleware(name)`:
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
// routes/index.ts
|
|
300
|
+
Router.post("/login", AuthController, "login", [RateLimiter.middleware("login")]);
|
|
301
|
+
|
|
302
|
+
Router.group({ prefix: "/api", middleware: [RateLimiter.middleware("api")] }, () => {
|
|
303
|
+
Router.get("/users", UserController, "index");
|
|
304
|
+
});
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
| Method | Keys on | Falls back to |
|
|
308
|
+
| -------------------- | --------------------------------------------- | ------------------------ |
|
|
309
|
+
| `.byUser()` | `ctx.user.id` | IP when unauthenticated |
|
|
310
|
+
| `.byApiKey(header?)` | `x-api-key` header (or custom) | IP when header is absent |
|
|
311
|
+
| `.byIp()` | Socket IP -> `X-Forwarded-For` -> `X-Real-IP` | `'unknown'` |
|
|
312
|
+
| `.by(fn)` | Return value of your function | - |
|
|
313
|
+
|
|
314
|
+
Check or reset a limiter manually — e.g. clear failed login attempts after a
|
|
315
|
+
successful sign-in:
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
// in a controller action — `ctx` is the HttpContext the action receives
|
|
319
|
+
if (await RateLimiter.tooManyAttempts("login", ctx)) {
|
|
320
|
+
return ctx.json({ message: "Too Many Requests" }, 429);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
RateLimiter.resetFor("login", ctx); // clear the counter for this actor
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
> **Tip** — `ThrottleMiddleware.with({ … })` is inline and per-attachment; a named `RateLimiter`
|
|
327
|
+
> is defined once and can be reused, queried with `tooManyAttempts()`, and cleared with
|
|
328
|
+
> `resetFor()`. Use the inline form for one-off routes, the named form when the same limit
|
|
329
|
+
> appears in several places. See [Rate limiting](/docs/rate-limiting) for the full surface.
|
|
330
|
+
|
|
331
|
+
### SecureHeadersMiddleware
|
|
332
|
+
|
|
333
|
+
Adds `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, and a
|
|
334
|
+
basic `Content-Security-Policy` to every response:
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
// bootstrap/app.ts
|
|
338
|
+
import { SecureHeadersMiddleware } from "zerotal";
|
|
339
|
+
app.use([SecureHeadersMiddleware]);
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
It is registered for you as kernel middleware, so an app gets these headers
|
|
343
|
+
without asking. Configure them under `app.secureHeaders` in `config/app.ts` — see
|
|
344
|
+
[Configuration](/docs/config-system).
|
|
345
|
+
|
|
346
|
+
**Static files get the same headers**, even though no middleware runs for them.
|
|
347
|
+
Files under `public/` are handed to Bun as pre-registered responses and served
|
|
348
|
+
without entering JavaScript, so the pipeline never sees them; the header set is
|
|
349
|
+
baked into those responses at registration time instead. A per-directory header
|
|
350
|
+
passed to `Router.static()` still wins, so a mount that is deliberately
|
|
351
|
+
embeddable stays that way.
|
|
352
|
+
|
|
353
|
+
> **Tip** — Run [`bun zt doctor --url=…`](/docs/deployment) after deploying. A
|
|
354
|
+
> header your app sets and your proxy also sets is invisible from inside the
|
|
355
|
+
> process, and browsers disagree about which copy applies.
|
|
356
|
+
|
|
357
|
+
### WebhookMiddleware
|
|
358
|
+
|
|
359
|
+
Verifies HMAC-SHA256 signatures on incoming webhook requests:
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
// routes/index.ts
|
|
363
|
+
import { WebhookMiddleware } from "zerotal";
|
|
364
|
+
|
|
365
|
+
Router.post("/webhooks/stripe", StripeController, "handle", [
|
|
366
|
+
WebhookMiddleware.with({
|
|
367
|
+
secret: Bun.env.STRIPE_WEBHOOK_SECRET!,
|
|
368
|
+
header: "stripe-signature",
|
|
369
|
+
algorithm: "sha256",
|
|
370
|
+
}),
|
|
371
|
+
]);
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
> **Danger** — Keep the webhook `secret` in an environment variable, never hard-coded. A leaked
|
|
375
|
+
> secret lets anyone forge valid signatures and call your webhook endpoint.
|
|
376
|
+
|
|
377
|
+
## Middleware with constructor injection
|
|
378
|
+
|
|
379
|
+
Decorate the class with `@inject(...)`, listing its dependency tokens in
|
|
380
|
+
constructor order; the container resolves them and passes them in:
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
// app/middleware/AuditMiddleware.ts
|
|
384
|
+
import { inject } from "zerotal";
|
|
385
|
+
import type { Pipe, NextFn, HttpContext } from "zerotal";
|
|
386
|
+
|
|
387
|
+
@inject(AuditLogger)
|
|
388
|
+
export class AuditMiddleware implements Pipe<HttpContext> {
|
|
389
|
+
constructor(private logger: AuditLogger) {}
|
|
390
|
+
|
|
391
|
+
async handle(ctx: HttpContext, next: NextFn): Promise<Response | void> {
|
|
392
|
+
const response = await next();
|
|
393
|
+
await this.logger.record(ctx.request.method, ctx.path(), ctx.user?.id);
|
|
394
|
+
return response;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
## Execution order
|
|
400
|
+
|
|
401
|
+
Middleware nests like layers of an onion: outer layers run first on the way in,
|
|
402
|
+
and last on the way out (after `await next()`).
|
|
403
|
+
|
|
404
|
+
```text
|
|
405
|
+
Global (app.use) ← outermost, first in / last out
|
|
406
|
+
Provider auto-middleware ← Session, Auth, etc.
|
|
407
|
+
Switch middleware ← CORS, Throttle, SecureHeaders
|
|
408
|
+
Group middleware ← Router.group({ middleware })
|
|
409
|
+
Route middleware ← Router.get(path, C, a, [M])
|
|
410
|
+
Controller action ← innermost
|
|
411
|
+
← route mw unwind
|
|
412
|
+
← group mw unwind
|
|
413
|
+
← switch mw unwind
|
|
414
|
+
← provider mw unwind ← session saved here
|
|
415
|
+
← global mw unwind
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
## Testing
|
|
419
|
+
|
|
420
|
+
Set your suite up once as described in [Testing](/docs/testing). Middleware has
|
|
421
|
+
two behaviours worth proving, and they are easy to confuse: what it does when it
|
|
422
|
+
lets a request **through**, and what it does when it **stops** one.
|
|
423
|
+
|
|
424
|
+
**Test the stop first**, because it is the reason the middleware exists:
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
// tests/http/middleware.test.ts
|
|
428
|
+
import { test } from "bun:test";
|
|
429
|
+
import { createApp } from "../helpers.ts";
|
|
430
|
+
|
|
431
|
+
test("a guest is turned away from a protected route", async () => {
|
|
432
|
+
const app = await createApp();
|
|
433
|
+
|
|
434
|
+
const res = await app.get("/dashboard");
|
|
435
|
+
|
|
436
|
+
res.assertRedirect("/login");
|
|
437
|
+
await app.close();
|
|
438
|
+
});
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
**Then prove it lets the right request through**, otherwise a middleware that
|
|
442
|
+
rejects everything passes the first test perfectly:
|
|
443
|
+
|
|
444
|
+
```typescript
|
|
445
|
+
// tests/http/middleware.test.ts
|
|
446
|
+
const res = await app.actingAs(user).get("/dashboard");
|
|
447
|
+
|
|
448
|
+
res.assertOk();
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
**A middleware that transforms rather than blocks** is tested through its effect.
|
|
452
|
+
Register a probe route in the `setup` callback — `createTestApp(bootstrap, setup)`
|
|
453
|
+
runs it before the server starts, so the route compiles into the router:
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
// tests/http/middleware.test.ts
|
|
457
|
+
const app = await createApp(() => {
|
|
458
|
+
Router.get("/probe", () => ({ locale: Context.get("locale") })).middleware([LocaleMiddleware]);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const res = await app.get("/probe", { "Accept-Language": "fr" });
|
|
462
|
+
|
|
463
|
+
res.assertJsonPath("locale", "fr");
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
> **Warning** — Middleware ordering is behaviour, not configuration. If auth must
|
|
467
|
+
> run before a rate limiter (so anonymous floods are cheap) or after it (so
|
|
468
|
+
> logins are throttled), write the test that fails when the order flips —
|
|
469
|
+
> reordering the array is a one-line change nobody reviews closely.
|
|
470
|
+
|
|
471
|
+
## References
|
|
472
|
+
|
|
473
|
+
Imported from `zerotal`.
|
|
474
|
+
|
|
475
|
+
| Member | Signature | Description |
|
|
476
|
+
| ---------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
477
|
+
| `Pipe<T>` | `interface Pipe<T> { handle(payload: T, next: NextFn): Promise<Response \| void> }` | The contract every middleware implements (`T` is `HttpContext`). |
|
|
478
|
+
| `NextFn` | `type NextFn = () => Promise<Response \| void>` | Passes control downstream; resolves to the downstream `Response`. |
|
|
479
|
+
| `HttpContext` | `class HttpContext<TParams>` | The request context passed to `handle` — read params from `ctx.params`. |
|
|
480
|
+
| `withHeaders` | `withHeaders(res: Response, headers: Record<string, string>): Response` | Returns a copy of `res` with headers added (safe on immutable responses). |
|
|
481
|
+
| `BaseMiddleware` | `class BaseMiddleware<O> { static with(options: Partial<O>): new () => … }` | Base class providing the `.with()` option-baking helper. |
|
|
482
|
+
|
|
483
|
+
Built-in middleware classes (`MiddlewareClass.with(options)` where noted):
|
|
484
|
+
|
|
485
|
+
| Class | Configure with | Purpose |
|
|
486
|
+
| ------------------------- | ----------------------------- | -------------------------------------------------- |
|
|
487
|
+
| `CorsMiddleware` | `.with(CorsOptions)` | Cross-origin resource sharing headers. |
|
|
488
|
+
| `ThrottleMiddleware` | `.with(ThrottleOptions)` | In-memory sliding-window rate limiting. |
|
|
489
|
+
| `SecureHeadersMiddleware` | `.with(SecureHeadersOptions)` | Security response headers (CSP, frame options, …). |
|
|
490
|
+
| `WebhookMiddleware` | `.with(WebhookOptions)` | HMAC signature verification for webhooks. |
|
|
491
|
+
|
|
492
|
+
Named rate limiters via `RateLimiter`:
|
|
493
|
+
|
|
494
|
+
| Method | Signature | Description |
|
|
495
|
+
| ---------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------- |
|
|
496
|
+
| `RateLimiter.for` | `for(name: string): LimiterDefinition` | Start defining a named limiter. |
|
|
497
|
+
| `RateLimiter.middleware` | `middleware(name: string): ThrottleMiddleware` | Get the middleware for a registered limiter. |
|
|
498
|
+
| `RateLimiter.tooManyAttempts` | `tooManyAttempts(name: string, ctx: HttpContext): Promise<boolean>` | Record a hit and report whether the limit is hit. |
|
|
499
|
+
| `RateLimiter.resetFor` | `resetFor(name: string, ctx: HttpContext): void` | Clear the counter for this actor's key. |
|
|
500
|
+
| `LimiterDefinition.limit`/`.every` | `limit(max): this` / `every(seconds): this` | Set the window size and length. |
|
|
501
|
+
| `LimiterDefinition.byUser`/`.byIp`/`.byApiKey`/`.by` | `byUser(): this` / `byApiKey(header?): this` / `by(fn): this` | Choose the key strategy. |
|
|
502
|
+
| `LimiterDefinition.register` | `register(): this` | Register the limiter with the global registry. |
|
|
503
|
+
|
|
504
|
+
## Next steps
|
|
505
|
+
|
|
506
|
+
- [Routing](/docs/routing) — attach middleware to routes and groups.
|
|
507
|
+
- [Lifecycle](/docs/lifecycle) — where middleware sits in the request flow.
|
|
508
|
+
- [Controllers](/docs/controllers) — move route logic out of closures.
|
|
509
|
+
- [Rate limiting](/docs/rate-limiting) — named limiters in depth.
|