@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,253 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Rate Limiting
|
|
3
|
+
description: Cap how many requests an actor can make in a time window and respond with 429 when they exceed it.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Rate Limiting
|
|
7
|
+
|
|
8
|
+
Rate limiting protects routes from abuse by counting requests per actor and
|
|
9
|
+
rejecting anything over the threshold. Zerotal ships two complementary limiters in
|
|
10
|
+
`@zerotal/core` — both built in, with nothing to install or register.
|
|
11
|
+
|
|
12
|
+
- **`ThrottleMiddleware`** — a quick inline limiter you attach to a route or the
|
|
13
|
+
global pipeline.
|
|
14
|
+
- **`RateLimiter`** — a **named**, reusable limiter you define once and apply by
|
|
15
|
+
name, with rich keying strategies and runtime inspection.
|
|
16
|
+
|
|
17
|
+
Both use an in-memory sliding window and respond with **429 Too Many Requests**,
|
|
18
|
+
setting `Retry-After` and `X-RateLimit-*` headers when the limit is exceeded.
|
|
19
|
+
|
|
20
|
+
## Getting Started
|
|
21
|
+
|
|
22
|
+
Rate limiting is built into `@zerotal/core` — nothing to install:
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { ThrottleMiddleware } from "zerotal";
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Which should I use?
|
|
29
|
+
|
|
30
|
+
- Reach for **`ThrottleMiddleware`** for a one-off limit on a single route or the
|
|
31
|
+
global pipeline — the options live right where you attach it.
|
|
32
|
+
- Reach for **`RateLimiter`** when the same limit is reused across many routes, or
|
|
33
|
+
when you want to query and reset it at runtime (e.g. clearing failed-login
|
|
34
|
+
counters after a successful sign-in).
|
|
35
|
+
|
|
36
|
+
## ThrottleMiddleware — inline
|
|
37
|
+
|
|
38
|
+
Attach it directly with the static `.with(options)` factory, which returns a
|
|
39
|
+
ready-to-use middleware class:
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
// in routes/web.ts (or wherever you register routes)
|
|
43
|
+
import { ThrottleMiddleware } from "zerotal";
|
|
44
|
+
|
|
45
|
+
// Global: 120 requests / minute per IP
|
|
46
|
+
app.use([ThrottleMiddleware.with({ maxAttempts: 120, windowSeconds: 60 })]);
|
|
47
|
+
|
|
48
|
+
// Per-route: 5 login attempts / minute
|
|
49
|
+
Router.post("/login", AuthController, "login", [
|
|
50
|
+
ThrottleMiddleware.with({ maxAttempts: 5, windowSeconds: 60 }),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
// Key by authenticated user instead of IP
|
|
54
|
+
ThrottleMiddleware.with({
|
|
55
|
+
maxAttempts: 1000,
|
|
56
|
+
windowSeconds: 3600,
|
|
57
|
+
keyResolver: (ctx) => String(ctx.user?.id ?? ctx.ip()),
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
| Option | Required | Default | Description |
|
|
62
|
+
| ---------------- | -------- | ----------- | ----------------------------------------------------------- |
|
|
63
|
+
| `maxAttempts` | yes | — | Max requests within the window. |
|
|
64
|
+
| `windowSeconds` | no | `60` | Window length in seconds. |
|
|
65
|
+
| `keyResolver` | no | client IP | Function returning the rate-limit key for a request. |
|
|
66
|
+
| `trustedProxies` | no | `undefined` | Number of trusted upstream proxies (see the warning below). |
|
|
67
|
+
|
|
68
|
+
> **Danger** — With `trustedProxies` left `undefined`, the limiter trusts the
|
|
69
|
+
> leftmost `X-Forwarded-For` entry, which a client behind a real proxy can forge
|
|
70
|
+
> to dodge the limit. Set `trustedProxies` to the exact number of proxies in
|
|
71
|
+
> front of your server (`0` when there is none) so the real client IP is used.
|
|
72
|
+
|
|
73
|
+
## RateLimiter — named limiters
|
|
74
|
+
|
|
75
|
+
Define a limiter once (typically in a `ServiceProvider.onBooted()`), then apply it
|
|
76
|
+
by name anywhere. Definitions are fluent, and `.register()` activates them:
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
// in a ServiceProvider's onBooted()
|
|
80
|
+
import { RateLimiter } from "zerotal";
|
|
81
|
+
|
|
82
|
+
// 1000 req/hour per authenticated user (falls back to IP when unauthenticated)
|
|
83
|
+
RateLimiter.for("api").limit(1000).every(3600).byUser().register();
|
|
84
|
+
|
|
85
|
+
// 5 login attempts per minute, per IP
|
|
86
|
+
RateLimiter.for("login").limit(5).every(60).byIp().register();
|
|
87
|
+
|
|
88
|
+
// 500 req/min keyed by an API-key header (unknown key → per IP)
|
|
89
|
+
RateLimiter.for("partner").limit(500).every(60).byApiKey("x-api-key").register();
|
|
90
|
+
|
|
91
|
+
// Custom key
|
|
92
|
+
RateLimiter.for("upload")
|
|
93
|
+
.limit(10)
|
|
94
|
+
.every(3600)
|
|
95
|
+
.by((ctx) => `user:${ctx.user?.id ?? "anon"}`)
|
|
96
|
+
.register();
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
> **Warning** — `.register()` is required. A definition that is never registered
|
|
100
|
+
> cannot be resolved by `RateLimiter.middleware()`, which throws if the name is
|
|
101
|
+
> unknown.
|
|
102
|
+
|
|
103
|
+
### Keying strategies
|
|
104
|
+
|
|
105
|
+
Each `.by*()` call sets how requests are bucketed. The default (no `.by*()` call)
|
|
106
|
+
is the client IP.
|
|
107
|
+
|
|
108
|
+
| Method | Keys on | Falls back to |
|
|
109
|
+
| -------------------- | ------------------------------------------- | ------------------------ |
|
|
110
|
+
| `.byUser()` | `ctx.user.id` | IP when unauthenticated |
|
|
111
|
+
| `.byApiKey(header?)` | `x-api-key` header (or a custom header) | IP when header is absent |
|
|
112
|
+
| `.byIp()` | Socket IP → `X-Forwarded-For` → `X-Real-IP` | `'unknown'` |
|
|
113
|
+
| `.by(fn)` | Return value of your function | — |
|
|
114
|
+
|
|
115
|
+
### Applying a named limiter
|
|
116
|
+
|
|
117
|
+
`RateLimiter.middleware(name)` returns the middleware instance for a registered
|
|
118
|
+
limiter, ready to drop into a route or group:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
// in routes/web.ts
|
|
122
|
+
import { RateLimiter } from "zerotal";
|
|
123
|
+
|
|
124
|
+
Router.post("/login", AuthController, "login", [RateLimiter.middleware("login")]);
|
|
125
|
+
|
|
126
|
+
Router.group({ prefix: "/api", middleware: [RateLimiter.middleware("api")] }, () => {
|
|
127
|
+
Router.get("/users", UserController, "index");
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Inspecting and resetting at runtime
|
|
132
|
+
|
|
133
|
+
Check or clear a limiter imperatively — e.g. reset failed-login counts after a
|
|
134
|
+
successful sign-in:
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
// in a controller
|
|
138
|
+
import { RateLimiter } from "zerotal";
|
|
139
|
+
|
|
140
|
+
if (await RateLimiter.tooManyAttempts("login", ctx)) {
|
|
141
|
+
return ctx.json({ message: "Too Many Requests" }, 429);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
RateLimiter.resetFor("login", ctx); // clear this actor's counter
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
> **Note** — `RateLimiter.tooManyAttempts()` records a hit and returns a promise,
|
|
148
|
+
> so `await` it. Unlike the middleware, it never sends the 429 itself — you decide
|
|
149
|
+
> how to respond.
|
|
150
|
+
|
|
151
|
+
## Response on limit
|
|
152
|
+
|
|
153
|
+
When the window is exceeded, both limiters return **429** with:
|
|
154
|
+
|
|
155
|
+
- `Retry-After` — seconds until the window resets.
|
|
156
|
+
- `X-RateLimit-Limit` / `X-RateLimit-Remaining` — the cap and what's left.
|
|
157
|
+
- `X-RateLimit-Reset` — the unix timestamp (seconds) when the window resets.
|
|
158
|
+
|
|
159
|
+
The 429 body is content-negotiated: an HTML page for web requests, a
|
|
160
|
+
`{ message: "Too Many Requests" }` JSON object for API requests, and a plain-text
|
|
161
|
+
line for CLI requests.
|
|
162
|
+
|
|
163
|
+
> **Warning** — Counters are in-memory and live in the process. With multiple
|
|
164
|
+
> instances behind a load balancer, each enforces the limit independently — fine
|
|
165
|
+
> for coarse protection. For a hard global cap across instances, gate the action
|
|
166
|
+
> with a shared store such as a [distributed lock](/docs/lock) or a cache-backed
|
|
167
|
+
> counter.
|
|
168
|
+
|
|
169
|
+
## Testing
|
|
170
|
+
|
|
171
|
+
Set your suite up once as described in [Testing](/docs/testing). A rate limiter
|
|
172
|
+
is only proven by the request that gets refused, so the test has to exhaust it.
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
// tests/http/throttle.test.ts
|
|
176
|
+
import { test, expect } from "bun:test";
|
|
177
|
+
import { createApp } from "../helpers.ts";
|
|
178
|
+
|
|
179
|
+
test("the sixth attempt in a minute is refused", async () => {
|
|
180
|
+
const app = await createApp();
|
|
181
|
+
|
|
182
|
+
for (let i = 0; i < 5; i++) {
|
|
183
|
+
(await app.post("/login", { email: "a@b.c", password: "wrong" })).assertStatus(422);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const blocked = await app.post("/login", { email: "a@b.c", password: "wrong" });
|
|
187
|
+
|
|
188
|
+
blocked.assertStatus(429);
|
|
189
|
+
blocked.assertHeader("Retry-After");
|
|
190
|
+
await app.close();
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Assert the headers, not just the status.** `X-RateLimit-Limit`,
|
|
195
|
+
`X-RateLimit-Remaining`, and `X-RateLimit-Reset` are what a well-behaved client
|
|
196
|
+
reads to back off. A limiter that returns `429` without them still fails the
|
|
197
|
+
clients it was meant to protect you from:
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
// tests/http/throttle.test.ts
|
|
201
|
+
blocked.assertHeader("X-RateLimit-Limit", "5");
|
|
202
|
+
blocked.assertHeader("X-RateLimit-Remaining", "0");
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**The counter is shared state**, so a limiter test contaminates whatever runs
|
|
206
|
+
next in the same window. Give each test a distinct key — a different route, IP
|
|
207
|
+
header, or user — rather than relying on ordering:
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
// tests/http/throttle.test.ts
|
|
211
|
+
await app.post("/login", { email: "a@b.c" }, { "X-Forwarded-For": "10.0.0.7" });
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
> **Warning** — A throttled response is `429` for JSON and an HTML page for a
|
|
215
|
+
> browser request. `assertStatus(429)` holds for both; `assertJson()` does not.
|
|
216
|
+
|
|
217
|
+
## References
|
|
218
|
+
|
|
219
|
+
### `RateLimiter` (static)
|
|
220
|
+
|
|
221
|
+
| Method | Signature | Description |
|
|
222
|
+
| ----------------- | ------------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
223
|
+
| `for` | `for(name: string): LimiterDefinition` | Begin a fluent definition. |
|
|
224
|
+
| `middleware` | `middleware(name: string): ThrottleMiddleware` | Get middleware for a registered limiter (throws if unknown). |
|
|
225
|
+
| `tooManyAttempts` | `tooManyAttempts(name: string, ctx: HttpContext): Promise<boolean>` | Record a hit; `true` if the actor is over the limit. |
|
|
226
|
+
| `resetFor` | `resetFor(name: string, ctx: HttpContext): void` | Reset the actor's counter for a limiter. |
|
|
227
|
+
| `clear` | `clear(): void` | Clear all registered limiters (useful in tests). |
|
|
228
|
+
|
|
229
|
+
### `LimiterDefinition` (fluent)
|
|
230
|
+
|
|
231
|
+
| Method | Signature | Description |
|
|
232
|
+
| ---------- | -------------------------------------------- | ---------------------------------------------------------- |
|
|
233
|
+
| `limit` | `limit(max: number): this` | Maximum requests in the window (default `60`). |
|
|
234
|
+
| `every` | `every(seconds: number): this` | Window duration in seconds (default `60`). |
|
|
235
|
+
| `byUser` | `byUser(): this` | Key by `ctx.user.id`; IP when unauthenticated. |
|
|
236
|
+
| `byApiKey` | `byApiKey(header?: string): this` | Key by header value (default `x-api-key`); IP when absent. |
|
|
237
|
+
| `byIp` | `byIp(): this` | Key by client IP (the explicit default). |
|
|
238
|
+
| `by` | `by(fn: (ctx: HttpContext) => string): this` | Key by your own resolver. |
|
|
239
|
+
| `register` | `register(): this` | Register the limiter with the global registry. |
|
|
240
|
+
|
|
241
|
+
### `ThrottleMiddleware`
|
|
242
|
+
|
|
243
|
+
| Member | Signature | Description |
|
|
244
|
+
| ---------- | ------------------------------------------------------------------------------ | ---------------------------------------- |
|
|
245
|
+
| `with` | `static with(options: Partial<ThrottleOptions>): new () => ThrottleMiddleware` | Build a middleware class from options. |
|
|
246
|
+
| `reset` | `reset(): void` | Clear all counters (useful in tests). |
|
|
247
|
+
| `resetKey` | `resetKey(ctx: HttpContext): void` | Clear the counter for one context's key. |
|
|
248
|
+
|
|
249
|
+
## Next steps
|
|
250
|
+
|
|
251
|
+
- [Middleware](/docs/middleware) — attaching middleware to routes and groups.
|
|
252
|
+
- [Lock](/docs/lock) — coordinating limits across multiple instances.
|
|
253
|
+
- [Authentication](/docs/authentication) — `ctx.user` used by `.byUser()`.
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Responses
|
|
3
|
+
description: Send JSON, HTML views, redirects, and custom responses back to the client from controllers and middleware.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Responses
|
|
7
|
+
|
|
8
|
+
Controllers and middleware build the outgoing response by calling helper methods
|
|
9
|
+
on the per-request `HttpContext`. After your action returns, the pipeline reads
|
|
10
|
+
the context's `response` and sends it to the client.
|
|
11
|
+
|
|
12
|
+
In a route handler you receive the request `HttpContext` directly — name it `ctx`
|
|
13
|
+
— and call `ctx.json(...)`, `ctx.view(...)`, `ctx.redirect(...)`, and friends.
|
|
14
|
+
Middleware receives the same context and uses the identical methods.
|
|
15
|
+
|
|
16
|
+
> **Note** — Handlers and middleware receive the same `HttpContext` object. Every
|
|
17
|
+
> helper below works on it; `ctx` is just the variable name used throughout.
|
|
18
|
+
|
|
19
|
+
## Getting Started
|
|
20
|
+
|
|
21
|
+
Responses are built into `@zerotal/core` — nothing to install. Every handler
|
|
22
|
+
receives an `HttpContext` carrying the helpers below:
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import type { HttpContext } from "zerotal";
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## JSON
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
// in a controller
|
|
32
|
+
ctx.json(data); // 200 OK — Content-Type: application/json
|
|
33
|
+
ctx.json(data, 201); // 201 Created
|
|
34
|
+
ctx.json({ errors }, 422); // 422 Unprocessable Content
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`json(data, status = 200)` serialises with `Response.json`, so anything
|
|
38
|
+
JSON-serialisable is accepted: plain objects, arrays, ORM models, `null`.
|
|
39
|
+
|
|
40
|
+
## HTML views
|
|
41
|
+
|
|
42
|
+
### Full document
|
|
43
|
+
|
|
44
|
+
`view()` accepts either pre-rendered markup, or a view component plus its props.
|
|
45
|
+
It prepends `<!DOCTYPE html>` and sets `Content-Type: text/html`:
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
// in a controller
|
|
49
|
+
import { WelcomePage } from "../../resources/views/WelcomePage.tsx";
|
|
50
|
+
|
|
51
|
+
ctx.view(WelcomePage(ctx, { title: "Hello" })); // pre-rendered markup
|
|
52
|
+
ctx.view(WelcomePage, { title: "Hello" }); // component + props
|
|
53
|
+
ctx.view(WelcomePage, { title: "Hello" }, 201); // component, props, status
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
When you pass a component, it receives the request `HttpContext` as its first
|
|
57
|
+
argument and your props as its second. Route params and model bindings reach the
|
|
58
|
+
component through `ctx.params`.
|
|
59
|
+
|
|
60
|
+
JSX syntax needs `"jsxImportSource": "zerotal"` in your `tsconfig.json` — set
|
|
61
|
+
once for the whole project, and already present in scaffolded apps. See the
|
|
62
|
+
[View](/docs/view) guide.
|
|
63
|
+
|
|
64
|
+
> **Tip** — Passing the component form (`ctx.view(WelcomePage, props)`) lets the
|
|
65
|
+
> framework hand the component the `HttpContext` for you, so you can read route
|
|
66
|
+
> params and model bindings from `ctx.params` without wiring them up by hand.
|
|
67
|
+
|
|
68
|
+
### Raw HTML
|
|
69
|
+
|
|
70
|
+
For htmx, Turbo Streams, or any partial render — no DOCTYPE prepended:
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
// in a controller
|
|
74
|
+
ctx.html('<p class="alert">Saved!</p>');
|
|
75
|
+
ctx.html(renderPartial(data), 200);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Markdown
|
|
79
|
+
|
|
80
|
+
Render a Markdown string to a full HTML page using Bun's built-in
|
|
81
|
+
`Bun.markdown.html()`. Tables, strikethrough, tasklists, autolinks, and heading
|
|
82
|
+
IDs are enabled by default:
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// in a controller
|
|
86
|
+
const content = await Bun.file("./docs/guide.md").text();
|
|
87
|
+
ctx.markdown(content);
|
|
88
|
+
ctx.markdown(content, { title: "Getting Started", headings: { ids: true } });
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
When no `title` option is given, the first heading in the document is used,
|
|
92
|
+
falling back to `"Docs"`.
|
|
93
|
+
|
|
94
|
+
## Redirects
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// in a controller
|
|
98
|
+
ctx.redirect("/dashboard"); // 302 Found
|
|
99
|
+
ctx.redirect("/dashboard", 303); // 303 See Other ← use after POST/PUT
|
|
100
|
+
ctx.redirect("/dashboard", 301); // 301 Permanent
|
|
101
|
+
ctx.redirect("/dashboard", 307); // 307 Temporary (preserves method)
|
|
102
|
+
ctx.redirect("/dashboard", 308); // 308 Permanent (preserves method)
|
|
103
|
+
|
|
104
|
+
ctx.back(); // 302 to Referer (safe — same origin only)
|
|
105
|
+
ctx.back(303); // 303 to Referer
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`back()` reads the `Referer` header and falls back to `/` when it is missing or
|
|
109
|
+
points to a different origin.
|
|
110
|
+
|
|
111
|
+
> **Tip** — POST-Redirect-GET: use `303` after mutating actions so browsers
|
|
112
|
+
> always issue a `GET` on the redirect target and form re-submission is
|
|
113
|
+
> prevented.
|
|
114
|
+
|
|
115
|
+
> **Danger** — `back()` only follows a `Referer` on the same origin; a
|
|
116
|
+
> cross-origin or forged value falls back to `/`. This prevents open-redirect
|
|
117
|
+
> attacks — never bypass it by reading the header yourself.
|
|
118
|
+
|
|
119
|
+
## File downloads
|
|
120
|
+
|
|
121
|
+
Return a `Response` directly with the appropriate headers:
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
// in a controller
|
|
125
|
+
const file = Bun.file("./exports/report.csv");
|
|
126
|
+
|
|
127
|
+
ctx.response = new Response(file as unknown as BodyInit, {
|
|
128
|
+
headers: {
|
|
129
|
+
"Content-Type": "text/csv",
|
|
130
|
+
"Content-Disposition": 'attachment; filename="report.csv"',
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Custom responses
|
|
136
|
+
|
|
137
|
+
Set `ctx.response` to any `Response` object — the pipeline sends it verbatim:
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
// in a controller
|
|
141
|
+
ctx.response = new Response("pong", { status: 200 });
|
|
142
|
+
|
|
143
|
+
ctx.response = new Response(JSON.stringify({ ok: true }), {
|
|
144
|
+
status: 200,
|
|
145
|
+
headers: { "Content-Type": "application/json", "X-Custom": "value" },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Stream a large body
|
|
149
|
+
const stream = new ReadableStream({ ... });
|
|
150
|
+
ctx.response = new Response(stream, {
|
|
151
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Appending headers to any response
|
|
156
|
+
|
|
157
|
+
Every helper assigns a fresh `Response` to `ctx.response`. To add a header,
|
|
158
|
+
rebuild it from the existing one:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
// in a controller, after setting ctx.response via any helper
|
|
162
|
+
const existing = ctx.response!;
|
|
163
|
+
const headers = new Headers(existing.headers);
|
|
164
|
+
headers.set("X-Request-Id", ctx.requestId);
|
|
165
|
+
|
|
166
|
+
ctx.response = new Response(existing.body, {
|
|
167
|
+
status: existing.status,
|
|
168
|
+
headers,
|
|
169
|
+
});
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Flash + redirect
|
|
173
|
+
|
|
174
|
+
Flash a message and redirect in one step — the flashed value is available via
|
|
175
|
+
`ctx.flashed()` on the next request. Flashing requires an active session
|
|
176
|
+
(see [Session](/docs/session)) and silently no-ops without one:
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
// in a controller
|
|
180
|
+
ctx.flash("success", "Post created!");
|
|
181
|
+
ctx.redirect("/posts", 303);
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
In [Inertia](/docs/inertia) apps use the `inertia()` helper with `back()` or
|
|
185
|
+
`redirect()` — no flash needed because Inertia preserves shared props across
|
|
186
|
+
redirects.
|
|
187
|
+
|
|
188
|
+
## Content negotiation
|
|
189
|
+
|
|
190
|
+
Use `wantsJson()` (true when the client sends `Accept: application/json`) to
|
|
191
|
+
respond differently based on what the client accepts:
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
// in a controller
|
|
195
|
+
async destroy(ctx: HttpContext): Promise<void> {
|
|
196
|
+
await post.delete();
|
|
197
|
+
|
|
198
|
+
if (ctx.wantsJson()) {
|
|
199
|
+
ctx.response = new Response(null, { status: 204 });
|
|
200
|
+
} else {
|
|
201
|
+
ctx.flash("success", "Post deleted.");
|
|
202
|
+
ctx.redirect("/posts", 303);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Router-level view shortcut
|
|
208
|
+
|
|
209
|
+
For routes that only render a view with static or request-computed props, skip
|
|
210
|
+
the controller entirely with `Router.view()`. A static props object is evaluated
|
|
211
|
+
once at registration; a factory function receives the `HttpContext` per request
|
|
212
|
+
and may be async:
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
// routes/web.ts
|
|
216
|
+
import { Router } from "zerotal";
|
|
217
|
+
|
|
218
|
+
Router.view("/about", AboutPage, { title: "About Us" });
|
|
219
|
+
|
|
220
|
+
Router.view("/dashboard", DashboardPage, async (ctx) => ({
|
|
221
|
+
user: ctx.user,
|
|
222
|
+
stats: await fetchStats(ctx.user!.id),
|
|
223
|
+
}));
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Which helper should I use?
|
|
227
|
+
|
|
228
|
+
- **`ctx.json()`** — API endpoints, fetch/XHR clients, Inertia validation
|
|
229
|
+
errors.
|
|
230
|
+
- **`ctx.view()`** — full server-rendered HTML pages from a JSX component.
|
|
231
|
+
- **`ctx.html()`** — HTML fragments for htmx or Turbo Streams (no DOCTYPE).
|
|
232
|
+
- **`ctx.markdown()`** — serve a `.md` file as a styled documentation page.
|
|
233
|
+
- **`ctx.redirect()` / `ctx.back()`** — after a mutation, or to send the user
|
|
234
|
+
elsewhere; pair with `flash()` for a one-request status message.
|
|
235
|
+
- **`ctx.response = new Response(...)`** — downloads, streams, custom status
|
|
236
|
+
codes (e.g. `204 No Content`), or any header set the helpers don't cover.
|
|
237
|
+
|
|
238
|
+
## Testing
|
|
239
|
+
|
|
240
|
+
Set your suite up once as described in [Testing](/docs/testing). Every request
|
|
241
|
+
through `TestApp` comes back as a `TestResponse`, whose assertions read as the
|
|
242
|
+
sentence you would have written in a comment.
|
|
243
|
+
|
|
244
|
+
**Assert the status by meaning, not by number**, so a failure says what went
|
|
245
|
+
wrong rather than what integer it saw:
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
// tests/http/posts.test.ts
|
|
249
|
+
import { test } from "bun:test";
|
|
250
|
+
import { createApp } from "../helpers.ts";
|
|
251
|
+
|
|
252
|
+
test("creating a post", async () => {
|
|
253
|
+
const app = await createApp();
|
|
254
|
+
|
|
255
|
+
const res = await app.actingAs(user).post("/posts", { title: "Hello" });
|
|
256
|
+
|
|
257
|
+
res.assertCreated(); // clearer than assertStatus(201)
|
|
258
|
+
res.assertJsonPath("data.title", "Hello");
|
|
259
|
+
await app.close();
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`assertOk`, `assertCreated`, `assertNoContent`, `assertNotFound`,
|
|
264
|
+
`assertUnauthorized`, `assertForbidden`, and `assertUnprocessable` all exist —
|
|
265
|
+
reach for `assertStatus(n)` only when the code has no name.
|
|
266
|
+
|
|
267
|
+
**Redirects and JSON need different assertions**, and picking the wrong one is
|
|
268
|
+
how a broken route passes. `assertRedirect` checks the `Location` header;
|
|
269
|
+
`assertJson` parses the body:
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
// tests/http/posts.test.ts
|
|
273
|
+
res.assertRedirect("/posts/hello"); // 3xx + Location
|
|
274
|
+
res.assertJson({ id: 1, title: "Hello" }); // exact body match
|
|
275
|
+
res.assertJsonPath("meta.total", 25); // one path, ignoring the rest
|
|
276
|
+
res.assertJsonCount(3, "data"); // array length at a path
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
**Prefer `assertJsonPath` to `assertJson`** for anything with a timestamp or an
|
|
280
|
+
id in it. An exact-match assertion on a whole body fails every time an unrelated
|
|
281
|
+
field is added, which trains people to update tests without reading them.
|
|
282
|
+
|
|
283
|
+
**Headers and cookies are part of the response contract** when a client depends
|
|
284
|
+
on them:
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
// tests/http/downloads.test.ts
|
|
288
|
+
res.assertHeader("Content-Type", "text/csv");
|
|
289
|
+
res.assertHeader("Content-Disposition");
|
|
290
|
+
res.assertCookie("session");
|
|
291
|
+
res.assertCookieMissing("remember_me");
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
> **Note** — `assertSee` and `assertDontSee` check the rendered body as text.
|
|
295
|
+
> They are for HTML responses; on a JSON body they will happily match a substring
|
|
296
|
+
> inside a field name and give you a passing test that proves nothing.
|
|
297
|
+
|
|
298
|
+
## References
|
|
299
|
+
|
|
300
|
+
Response helpers on `HttpContext`:
|
|
301
|
+
|
|
302
|
+
| Method | Signature | Description |
|
|
303
|
+
| ----------- | -------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
|
304
|
+
| `json` | `json(data: unknown, status = 200): void` | Serialise `data` as a JSON response. |
|
|
305
|
+
| `view` | `view(markup, status?)` / `view(component, props?, status?)` | Render a full HTML document (prepends `<!DOCTYPE html>`). |
|
|
306
|
+
| `html` | `html(markup: string \| { toString(): string }, status = 200): void` | Send a raw HTML string with no DOCTYPE. |
|
|
307
|
+
| `markdown` | `markdown(content: string, options?, status = 200): void` | Render Markdown to a full HTML page via `Bun.markdown.html()`. |
|
|
308
|
+
| `redirect` | `redirect(url: string, status: 301\|302\|303\|307\|308 = 302): void` | Set a `Location` redirect response. |
|
|
309
|
+
| `back` | `back(status: 301\|302\|303\|307\|308 = 302): void` | Redirect to the same-origin `Referer`, or `/`. |
|
|
310
|
+
| `flash` | `flash(key: string, value: unknown): void` | Stash a value for the next request (needs a session). |
|
|
311
|
+
| `flashed` | `flashed<T>(key: string): T \| undefined` | Read a value flashed in the previous request. |
|
|
312
|
+
| `wantsJson` | `wantsJson(): boolean` | True when `Accept` includes `application/json`. |
|
|
313
|
+
| `response` | `response: Response \| undefined` | The outgoing response; assign a `Response` directly for full control. |
|
|
314
|
+
|
|
315
|
+
Common status codes:
|
|
316
|
+
|
|
317
|
+
| Helper | Status | Typical use |
|
|
318
|
+
| ------------------------ | ------ | ------------------------------------------------ |
|
|
319
|
+
| `ctx.json(data)` | 200 | GET, success |
|
|
320
|
+
| `ctx.json(data, 201)` | 201 | POST, resource created |
|
|
321
|
+
| `ctx.json(null, 204)` | — | DELETE, no content (set `ctx.response` directly) |
|
|
322
|
+
| `ctx.json(errors, 422)` | 422 | Validation failed |
|
|
323
|
+
| `ctx.json(msg, 401)` | 401 | Unauthenticated |
|
|
324
|
+
| `ctx.json(msg, 403)` | 403 | Forbidden |
|
|
325
|
+
| `ctx.json(msg, 404)` | 404 | Not found |
|
|
326
|
+
| `ctx.redirect(url)` | 302 | General redirect |
|
|
327
|
+
| `ctx.redirect(url, 303)` | 303 | After POST/PUT/DELETE |
|
|
328
|
+
| `ctx.redirect(url, 301)` | 301 | Permanent redirect |
|
|
329
|
+
|
|
330
|
+
## Next steps
|
|
331
|
+
|
|
332
|
+
- [Requests Context](/docs/context) — read input from the incoming request.
|
|
333
|
+
- [View](/docs/view) — render JSX and Markdown pages.
|
|
334
|
+
- [Inertia](/docs/inertia) — return Inertia responses for SPA frontends.
|
|
335
|
+
- [Cookies](/docs/cookies) — attach cookies to outgoing responses.
|
|
336
|
+
- [HttpContext](/docs/context) — the full per-request object these helpers live on.
|