@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. 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.