@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
package/docs/context.md
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Requests Context
|
|
3
|
+
description: Every handler and middleware receives the per-request HttpContext directly — route params, typed input helpers, uploads, headers, response building, flash, and after-response hooks on one object.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Requests Context
|
|
7
|
+
|
|
8
|
+
Every route handler and every middleware receives one argument: the request
|
|
9
|
+
`HttpContext`. There is no separate request object — the incoming `Request` is a
|
|
10
|
+
property on the context (`ctx.request`), and everything you'd reach for to read it
|
|
11
|
+
hangs off the same argument. It holds the raw request, the response being built,
|
|
12
|
+
the matched route params and resolved model bindings (on `ctx.params`), and the
|
|
13
|
+
helpers for reading input and shaping the reply.
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
// in a controller
|
|
17
|
+
import type { HttpContext } from "zerotal";
|
|
18
|
+
|
|
19
|
+
export class PostController {
|
|
20
|
+
async show(ctx: HttpContext) {
|
|
21
|
+
return ctx.json({ id: ctx.params.id });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
You rarely construct one yourself — the framework creates it per request, stores it
|
|
27
|
+
in request-scoped (`AsyncLocalStorage`) storage, and hands it to your code. For
|
|
28
|
+
tests, build one with [`HttpContext.fake()`](#testing).
|
|
29
|
+
|
|
30
|
+
## Destructuring the context
|
|
31
|
+
|
|
32
|
+
Every method on the context is bound to its instance, so you can destructure
|
|
33
|
+
exactly what a handler needs — methods included — without losing `this`:
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
// routes: Router.get('/posts/:post/:tab', PostController, 'show')
|
|
37
|
+
|
|
38
|
+
async show({ view, params: { post, tab } }: HttpContext<{ post: Post; tab: string }>) {
|
|
39
|
+
return view(ShowPost, { post, tab });
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`view` is a bound method here, `post` is a resolved model binding, and `tab` is a
|
|
44
|
+
raw route param — all pulled straight off the one argument. Name the whole argument
|
|
45
|
+
instead (`async show(ctx: HttpContext)`) when you prefer `ctx.view(...)` /
|
|
46
|
+
`ctx.params.post`; both styles work.
|
|
47
|
+
|
|
48
|
+
> **Warning** — Computed getters (`took`, `subdomains`) are _not_ bound methods, so
|
|
49
|
+
> destructuring them takes a one-time snapshot at destructure time. `took` (elapsed
|
|
50
|
+
> ms) in particular will be frozen — read `ctx.took` directly when you want it live.
|
|
51
|
+
|
|
52
|
+
## Typed params
|
|
53
|
+
|
|
54
|
+
`HttpContext` is generic over the shape of `params`. With no type argument,
|
|
55
|
+
`ctx.params` is `Record<string, string>` — the raw matched segments. Pass a type
|
|
56
|
+
argument to describe what the route resolves, including model bindings:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
// raw params only — every value is a string
|
|
60
|
+
async index(ctx: HttpContext) {
|
|
61
|
+
const page = ctx.params.page; // string | undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// typed params + a resolved model binding
|
|
65
|
+
async show(ctx: HttpContext<{ post: Post; tab: string }>) {
|
|
66
|
+
ctx.params.post; // Post (resolved binding)
|
|
67
|
+
ctx.params.tab; // string (raw param)
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The framework hands every handler the same runtime object; the type argument is a
|
|
72
|
+
declaration _you_ make about what this route resolves — exactly as you'd annotate
|
|
73
|
+
any function parameter.
|
|
74
|
+
|
|
75
|
+
## Route-model bindings
|
|
76
|
+
|
|
77
|
+
A binding turns a raw `:param` string into a loaded model instance before your
|
|
78
|
+
handler runs. The framework folds the instance onto `ctx.params` under the param's
|
|
79
|
+
name (and exposes it via `ctx.model<T>()`).
|
|
80
|
+
|
|
81
|
+
Usually there is nothing to declare: a param whose name matches an auto-registered
|
|
82
|
+
model binds on its own, so `:post` resolves through `Post` by primary key.
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// routes/web.ts — :post is already bound to Post
|
|
86
|
+
Router.get("/posts/:post", PostController, "show");
|
|
87
|
+
|
|
88
|
+
// Declare one only to override the default — a different key, or a custom lookup:
|
|
89
|
+
Router.get("/posts/:post", PostController, "show").bind("post", (slug) =>
|
|
90
|
+
Post.query().where("slug", slug).firstOrFail(),
|
|
91
|
+
);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
// in PostController — ctx.params.post is the resolved Post, not the raw id
|
|
96
|
+
async show(ctx: HttpContext<{ post: Post }>) {
|
|
97
|
+
return ctx.json(ctx.params.post);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// the same instance, read explicitly
|
|
101
|
+
async show(ctx: HttpContext) {
|
|
102
|
+
const post = ctx.model<Post>("post");
|
|
103
|
+
return ctx.json(post);
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Resolution runs before the middleware pipeline, so a missing record produces a 404
|
|
108
|
+
rendered by the exception handler rather than a half-run handler. An unbound param
|
|
109
|
+
stays a raw string on `ctx.params`. The full binding surface — implicit binding,
|
|
110
|
+
custom resolvers, scoped bindings — lives in [Routing](/docs/routing).
|
|
111
|
+
|
|
112
|
+
## Reading input
|
|
113
|
+
|
|
114
|
+
The scalar helpers check route params first, then the query string, and coerce to
|
|
115
|
+
the type the method name promises. Each takes an optional fallback.
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
// in a controller
|
|
119
|
+
ctx.query("page", "1"); // string | undefined — query string only
|
|
120
|
+
ctx.string("sort", "asc"); // string | undefined — param then query
|
|
121
|
+
ctx.integer("id"); // number | undefined — param then query, parsed as int
|
|
122
|
+
ctx.boolean("active", false); // boolean — param then query, coerced
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Number parsing
|
|
126
|
+
|
|
127
|
+
`integer()` reads the param-then-query value and parses it with `parseInt(raw, 10)`.
|
|
128
|
+
It returns the `fallback` (or `undefined` when you pass none) in two cases: the key
|
|
129
|
+
is absent, or the value is not a valid integer (`NaN`). Because parsing is base-10,
|
|
130
|
+
`"08"` reads as `8` and a trailing-garbage value like `"42abc"` parses to `42` —
|
|
131
|
+
validate with a [form request](/docs/validator) when you need to reject malformed
|
|
132
|
+
input rather than coerce it.
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
// in a controller — GET /posts?page=3
|
|
136
|
+
ctx.integer("page"); // 3
|
|
137
|
+
ctx.integer("page", 1); // 3 (fallback unused)
|
|
138
|
+
ctx.integer("missing", 1); // 1 (absent → fallback)
|
|
139
|
+
ctx.integer("missing"); // undefined (absent, no fallback)
|
|
140
|
+
ctx.integer("count"); // GET ?count=abc → undefined (NaN → fallback)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Boolean coercion
|
|
144
|
+
|
|
145
|
+
`boolean()` reads the param-then-query value and returns `true` only when it is one
|
|
146
|
+
of `'1'`, `'true'`, `'yes'`, or `'on'` (compared case-insensitively). Any other
|
|
147
|
+
present value is `false`; an absent value returns the fallback, which defaults to
|
|
148
|
+
`false`.
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
// in a controller
|
|
152
|
+
ctx.boolean("active"); // ?active=true / ?active=1 / ?active=ON → true
|
|
153
|
+
ctx.boolean("active"); // ?active=0 / ?active=no / ?active= → false
|
|
154
|
+
ctx.boolean("active"); // absent → false (default fallback)
|
|
155
|
+
ctx.boolean("active", true); // absent → true (custom fallback)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
> **Note** — These coercion helpers are for raw string params. A param bound to a
|
|
159
|
+
> model holds the model instance on `ctx.params`, so read it as `ctx.params.post` or
|
|
160
|
+
> `ctx.model<Post>("post")`, never `ctx.integer("post")`.
|
|
161
|
+
|
|
162
|
+
### The request body
|
|
163
|
+
|
|
164
|
+
`body()` parses and caches the body (JSON, form-urlencoded, or multipart fields) so
|
|
165
|
+
repeated calls are free, and returns `{}` on an absent or invalid body:
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
const data = await ctx.body<{ title: string; body: string }>();
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`input()` reads a single merged value in priority order — route params → cached
|
|
172
|
+
body → query string — without awaiting:
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
ctx.input("id"); // route :id or ?id=
|
|
176
|
+
ctx.input("q", "all"); // with fallback
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
> **Warning** — `input()` only sees body data if `await ctx.body()` (or a
|
|
180
|
+
> FormRequest) ran earlier in the lifecycle; otherwise it falls through to the query
|
|
181
|
+
> string. For guaranteed body access, `await ctx.body()` first.
|
|
182
|
+
|
|
183
|
+
Read a header with `header()` (case-insensitive) or pull a Bearer token with
|
|
184
|
+
`bearerToken()`:
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
ctx.header("x-forwarded-for"); // string | null
|
|
188
|
+
ctx.bearerToken(); // string | null — strips the "Bearer " prefix
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Uploaded files
|
|
192
|
+
|
|
193
|
+
`file()` returns the first `UploadedFile` for a form field (or `null`); `files()`
|
|
194
|
+
returns all of them. Both parse and cache the multipart body on first call.
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
const avatar = await ctx.file("avatar"); // UploadedFile | null
|
|
198
|
+
const attachments = await ctx.files("attachments"); // UploadedFile[]
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
An `UploadedFile` describes the upload and knows how to persist itself. Validate
|
|
202
|
+
before storing, then hand it a disk — `store()` returns the stored path:
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
// in a controller — single file
|
|
206
|
+
const avatar = await ctx.file("avatar");
|
|
207
|
+
|
|
208
|
+
if (avatar) {
|
|
209
|
+
avatar.originalName; // original filename as sent by the browser
|
|
210
|
+
avatar.size; // bytes
|
|
211
|
+
avatar.mimeType; // MIME type, e.g. 'image/jpeg'
|
|
212
|
+
avatar.extension(); // 'jpg', 'png', etc.
|
|
213
|
+
|
|
214
|
+
const valid = avatar.isValid({
|
|
215
|
+
maxSize: 2 * 1024 * 1024, // 2 MB
|
|
216
|
+
mimes: ["image/jpeg", "image/png"],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const path = await avatar.store("avatars", Storage.disk("s3"));
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
// in a controller — multiple files
|
|
225
|
+
for (const file of await ctx.files("attachments")) {
|
|
226
|
+
await file.store("uploads", Storage.disk());
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
> **Danger** — `originalName` is supplied by the client and may be untrusted.
|
|
231
|
+
> `store()` defaults to a `<uuid>.<ext>` filename precisely so attacker-controlled
|
|
232
|
+
> names never reach your filesystem — sanitise the original before displaying it.
|
|
233
|
+
|
|
234
|
+
See [Storage](/docs/storage) for configuring the disks `store()` writes to.
|
|
235
|
+
|
|
236
|
+
## Building the response
|
|
237
|
+
|
|
238
|
+
The response helpers set `ctx.response` for you and return nothing — they are
|
|
239
|
+
terminal. You can also `return` a value from a controller; see
|
|
240
|
+
[Responses](/docs/responses) for the full set.
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
ctx.json({ user }); // 200 application/json
|
|
244
|
+
ctx.json({ errors }, 422); // custom status
|
|
245
|
+
ctx.view(WelcomeView, { name }); // full HTML document (prepends <!DOCTYPE html>)
|
|
246
|
+
ctx.html("<p>fragment</p>"); // HTML fragment, no DOCTYPE
|
|
247
|
+
ctx.redirect("/dashboard"); // 302
|
|
248
|
+
ctx.redirect("/dashboard", 303); // 303 — use after POST/PUT/DELETE
|
|
249
|
+
ctx.back(); // redirect to the Referer (same-origin only)
|
|
250
|
+
ctx.back(303); // same, with a 303 status
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
`view()` accepts either pre-rendered markup, or a **view component plus its props**.
|
|
254
|
+
A view component receives the request `HttpContext` first and your props second:
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
// resources/views/Welcome.tsx
|
|
258
|
+
export default function Welcome(ctx: HttpContext, { title }: { title: string }) {
|
|
259
|
+
return (
|
|
260
|
+
<html>
|
|
261
|
+
<body>
|
|
262
|
+
<h1>{title}</h1>
|
|
263
|
+
<p>{ctx.url.pathname}</p>
|
|
264
|
+
</body>
|
|
265
|
+
</html>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// in a controller
|
|
270
|
+
ctx.view(Welcome, { title: "Hello" });
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Route params and model bindings reach the component through `ctx.params`; the second
|
|
274
|
+
argument is strictly the props you pass. `markdown()` renders a Markdown string to a
|
|
275
|
+
full HTML document via `Bun.markdown.html()`.
|
|
276
|
+
|
|
277
|
+
> **Danger** — `back()` falls back to `/` whenever the `Referer` is missing or points
|
|
278
|
+
> to a different origin, preventing open-redirect attacks through a forged `Referer`
|
|
279
|
+
> header. Don't replace it with a raw `redirect(referer)`.
|
|
280
|
+
|
|
281
|
+
## Middleware
|
|
282
|
+
|
|
283
|
+
Middleware is a class with a `handle(ctx, next)` method. It receives the same
|
|
284
|
+
`HttpContext` and either calls `next()` to pass control down the pipeline or
|
|
285
|
+
short-circuits by producing a response:
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
// app/middleware/EnsureActive.ts
|
|
289
|
+
import type { HttpContext, NextFn } from "zerotal";
|
|
290
|
+
|
|
291
|
+
export class EnsureActive {
|
|
292
|
+
async handle(ctx: HttpContext, next: NextFn): Promise<Response | void> {
|
|
293
|
+
if (!ctx.boolean("active")) return ctx.redirect("/inactive");
|
|
294
|
+
return next();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Handlers and middleware now receive the exact same object, so there's a single
|
|
300
|
+
mental model and a single way to read params. See [Middleware](/docs/middleware) for
|
|
301
|
+
the pipeline, ordering, and registration.
|
|
302
|
+
|
|
303
|
+
## The authenticated user
|
|
304
|
+
|
|
305
|
+
`ctx.user` is **not** a built-in field. When `PersistUserMiddleware` (from
|
|
306
|
+
[`@zerotal/auth`](/docs/authentication)) runs, it reads the session's user id, loads
|
|
307
|
+
the matching record, and assigns it to `ctx.user`; `AuthMiddleware` reads it to guard
|
|
308
|
+
routes. It is `undefined` for guests.
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
if (!ctx.user) throw new UnauthorizedError();
|
|
312
|
+
return ctx.json({ email: ctx.user.email });
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
The resolver that loads the user from the session id is configured from your
|
|
316
|
+
registered auth model — see [Authentication](/docs/authentication).
|
|
317
|
+
|
|
318
|
+
## Client IP
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
ctx.ip(); // string | null — socket-level IP from Bun's server.requestIP()
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Returns `null` when no Bun server reference was injected (e.g. in unit tests created
|
|
325
|
+
with `HttpContext.fake()`).
|
|
326
|
+
|
|
327
|
+
> **Warning** — `ctx.ip()` is the socket IP. Behind a trusted proxy it returns the
|
|
328
|
+
> proxy's address, not the end-user's. Read the forwarded header yourself with
|
|
329
|
+
> `ctx.header("x-forwarded-for")`, or let [`ThrottleMiddleware`](/docs/middleware)
|
|
330
|
+
> resolve proxy-aware IPs via its `trustedProxies` option.
|
|
331
|
+
|
|
332
|
+
## URL & matching helpers
|
|
333
|
+
|
|
334
|
+
```typescript
|
|
335
|
+
ctx.path(); // "/posts" — pathname only
|
|
336
|
+
ctx.fullUrl(); // "https://app.test/posts?page=2"
|
|
337
|
+
ctx.host(); // "app.test"
|
|
338
|
+
ctx.is("/admin/*"); // glob match against the path (* excludes /, ** includes it)
|
|
339
|
+
ctx.subdomain("tenant"); // string | null — from Router.group({ domain })
|
|
340
|
+
ctx.subdomains; // { tenant: "acme" }
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
`isJson()` / `wantsJson()` inspect the `Content-Type` / `Accept` headers — handy in
|
|
344
|
+
an exception handler, or anywhere one route serves both browsers and API clients:
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
// in a controller or middleware
|
|
348
|
+
if (ctx.wantsJson()) {
|
|
349
|
+
ctx.json({ message: "Unauthorized" }, 401);
|
|
350
|
+
} else {
|
|
351
|
+
ctx.redirect("/login", 303);
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
## Flash data
|
|
356
|
+
|
|
357
|
+
Flash writes a value to the session for the **next** request only — ideal for
|
|
358
|
+
post-redirect success and error messages. It requires `SessionMiddleware` and
|
|
359
|
+
silently no-ops without it.
|
|
360
|
+
|
|
361
|
+
```typescript
|
|
362
|
+
// before redirecting
|
|
363
|
+
ctx.flash("success", "Post saved!");
|
|
364
|
+
return ctx.redirect("/posts", 303);
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
// on the next request
|
|
369
|
+
const msg = ctx.flashed<string>("success"); // 'Post saved!'
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
The redirect response builders (`redirect().withSuccess(...)`) wrap this pattern more
|
|
373
|
+
fluently — see [Responses](/docs/responses).
|
|
374
|
+
|
|
375
|
+
## After-response callbacks
|
|
376
|
+
|
|
377
|
+
Register work to run **after** the response has been sent — fire-and-forget side
|
|
378
|
+
effects that shouldn't delay the client. `afterResponse()` returns `this`, so calls
|
|
379
|
+
chain.
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
ctx.afterResponse(async () => {
|
|
383
|
+
await analytics.track(ctx.requestId, ctx.url.pathname);
|
|
384
|
+
});
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
A callback's errors are logged and swallowed, so one failure never affects the
|
|
388
|
+
response or the other callbacks.
|
|
389
|
+
|
|
390
|
+
> **Note** — `afterResponse()` acquires the request-scoped container reference
|
|
391
|
+
> synchronously at registration time, so the scope cannot be flushed before your
|
|
392
|
+
> callback gets a chance to run.
|
|
393
|
+
|
|
394
|
+
## Accessing the context anywhere
|
|
395
|
+
|
|
396
|
+
You don't have to thread `ctx` through every function. From anywhere in the async
|
|
397
|
+
call chain, `request()` returns the current request's `HttpContext`:
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
import { request } from "zerotal";
|
|
401
|
+
|
|
402
|
+
request(); // the HttpContext — throws outside a request
|
|
403
|
+
request("page", "1"); // shorthand for request().input("page", "1")
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
The rule is two lines: take `ctx` where it's handed to you (handlers, hooks,
|
|
407
|
+
middleware); call `request()` anywhere else. Code that also runs **outside** a
|
|
408
|
+
request — a service shared with CLI commands or queue workers — uses
|
|
409
|
+
`HttpContext.tryGet()`, which returns `undefined` instead of throwing.
|
|
410
|
+
|
|
411
|
+
## Asking once per request
|
|
412
|
+
|
|
413
|
+
`RequestContext.remember(key, factory)` runs `factory` at most once per request
|
|
414
|
+
for a given key and hands every later caller the same answer:
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
import { RequestContext } from "zerotal";
|
|
418
|
+
|
|
419
|
+
const settings = await RequestContext.remember(`household:${id}:settings`, () =>
|
|
420
|
+
Settings.query().where("household_id", id).first(),
|
|
421
|
+
);
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
This is the other half of the [N+1 detector](/docs/database#n1-detection): the
|
|
425
|
+
detector tells you a query ran too many times, and when the answer is the same
|
|
426
|
+
every time, "ask once" is the fix rather than eager loading.
|
|
427
|
+
|
|
428
|
+
Two behaviours are deliberate, and both are the ones a hand-rolled version
|
|
429
|
+
usually gets wrong:
|
|
430
|
+
|
|
431
|
+
- **The promise is cached, not the resolved value.** Cache after the `await` and
|
|
432
|
+
a `Promise.all` of ten readers all miss — none has resolved when the others
|
|
433
|
+
look. Caching the promise makes the first caller's in-flight work the answer
|
|
434
|
+
for the other nine.
|
|
435
|
+
- **A rejected promise is evicted.** Otherwise one transient failure poisons
|
|
436
|
+
every later read in the same request, including the retry.
|
|
437
|
+
|
|
438
|
+
Outside a request it is a pass-through. A queue worker has no request to scope
|
|
439
|
+
to, and quietly sharing a value across jobs would be worse than not caching.
|
|
440
|
+
|
|
441
|
+
`RequestContext.forget(key)` drops a value when a write invalidates a read taken
|
|
442
|
+
earlier in the same request.
|
|
443
|
+
|
|
444
|
+
## Testing
|
|
445
|
+
|
|
446
|
+
`HttpContext.fake()` builds a context without a live server — perfect for unit
|
|
447
|
+
testing controllers and middleware:
|
|
448
|
+
|
|
449
|
+
```typescript
|
|
450
|
+
const ctx = HttpContext.fake("http://localhost/posts?page=2", {
|
|
451
|
+
method: "GET",
|
|
452
|
+
headers: { Authorization: "Bearer token" },
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
await new PostController().index(ctx);
|
|
456
|
+
expect(ctx.response?.status).toBe(200);
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
Pass a `body` to exercise handlers that read one, and assign `ctx.params` directly
|
|
460
|
+
when the handler expects route params a real match would have provided:
|
|
461
|
+
|
|
462
|
+
```typescript
|
|
463
|
+
// in a test — a POST with a JSON body
|
|
464
|
+
const ctx = HttpContext.fake("http://localhost/posts", {
|
|
465
|
+
method: "POST",
|
|
466
|
+
headers: { "Content-Type": "application/json" },
|
|
467
|
+
body: JSON.stringify({ title: "Hello", body: "World" }),
|
|
468
|
+
});
|
|
469
|
+
ctx.params = { id: "42" };
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
The signature is `HttpContext.fake(url?, init?, container?)`; all three are optional.
|
|
473
|
+
See [Testing](/docs/testing/index) for the full harness, and
|
|
474
|
+
[HTTP testing](/docs/testing/http) for the higher-level request helpers.
|
|
475
|
+
|
|
476
|
+
## References
|
|
477
|
+
|
|
478
|
+
### `HttpContext<TParams>`
|
|
479
|
+
|
|
480
|
+
```typescript
|
|
481
|
+
class HttpContext<TParams extends Record<string, unknown> = Record<string, string>> {
|
|
482
|
+
constructor(request: Request, container: ScopedResolver);
|
|
483
|
+
}
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
The per-request object every handler and middleware receives. `TParams` types
|
|
487
|
+
`ctx.params` — defaulting to `Record<string, string>` (raw params), or the bag of
|
|
488
|
+
raw params and resolved model bindings you declare for a route.
|
|
489
|
+
|
|
490
|
+
#### Properties
|
|
491
|
+
|
|
492
|
+
| Property | Type | Description |
|
|
493
|
+
| ----------- | ----------------------- | ------------------------------------------------------------ |
|
|
494
|
+
| `request` | `Request` | The raw Bun/Web API `Request` object. |
|
|
495
|
+
| `response` | `Response \| undefined` | Set by handlers; read by the framework to send the reply. |
|
|
496
|
+
| `url` | `URL` | Parsed URL (`pathname`, `searchParams`, `origin`, …). |
|
|
497
|
+
| `params` | `TParams` | Route params, plus resolved model bindings under their name. |
|
|
498
|
+
| `requestId` | `string` | UUID generated per request (`crypto.randomUUID()`). |
|
|
499
|
+
| `startedAt` | `number` | `performance.now()` timestamp captured at construction. |
|
|
500
|
+
| `locale` | `string` | Current locale (default `'en'`); set by `I18nMiddleware`. |
|
|
501
|
+
| `took` | `number` (getter) | Whole milliseconds elapsed since `startedAt`. |
|
|
502
|
+
| `container` | `ScopedResolver` | The request-scoped container for this request. |
|
|
503
|
+
|
|
504
|
+
#### Input methods
|
|
505
|
+
|
|
506
|
+
| Method | Signature | Description |
|
|
507
|
+
| ------------- | ---------------------------------------- | ------------------------------------------------ |
|
|
508
|
+
| `query` | `(key, fallback?): string \| undefined` | Read a query-string param. |
|
|
509
|
+
| `string` | `(key, fallback?): string \| undefined` | Read a param then query as a string. |
|
|
510
|
+
| `integer` | `(key, fallback?): number \| undefined` | Read a param then query, parsed base-10. |
|
|
511
|
+
| `boolean` | `(key, fallback = false): boolean` | Coerce a param/query to a boolean. |
|
|
512
|
+
| `input` | `<T>(key, fallback?): T` | Merged read: params → cached body → query. |
|
|
513
|
+
| `body` | `<T>(): Promise<T>` | Parse and cache the request body. |
|
|
514
|
+
| `header` | `(key, fallback = null): string \| null` | Read a request header (case-insensitive). |
|
|
515
|
+
| `bearerToken` | `(): string \| null` | Extract the `Authorization: Bearer` token. |
|
|
516
|
+
| `file` | `(field): Promise<UploadedFile \| null>` | First uploaded file for a form field. |
|
|
517
|
+
| `files` | `(field): Promise<UploadedFile[]>` | All uploaded files for a form field. |
|
|
518
|
+
| `model` | `<T>(name): T` | Resolved route-model binding; throws if unbound. |
|
|
519
|
+
|
|
520
|
+
#### Response methods
|
|
521
|
+
|
|
522
|
+
| Method | Signature | Description |
|
|
523
|
+
| ---------- | -------------------------------------------- | --------------------------------------------------- |
|
|
524
|
+
| `json` | `(data, status = 200): void` | Set a JSON response. |
|
|
525
|
+
| `view` | `(componentOrMarkup, props?, status?): void` | Render a full HTML document (`<!DOCTYPE html>`). |
|
|
526
|
+
| `markdown` | `(content, options?, status = 200): void` | Render Markdown to a full HTML page. |
|
|
527
|
+
| `html` | `(markup, status = 200): void` | Set a raw HTML fragment (no DOCTYPE). |
|
|
528
|
+
| `redirect` | `(url, status = 302): void` | Set a redirect (`301 \| 302 \| 303 \| 307 \| 308`). |
|
|
529
|
+
| `back` | `(status = 302): void` | Redirect to the same-origin `Referer`, else `/`. |
|
|
530
|
+
|
|
531
|
+
#### Request, session & lifecycle methods
|
|
532
|
+
|
|
533
|
+
| Method | Signature | Description |
|
|
534
|
+
| --------------- | -------------------------- | ------------------------------------------------------ |
|
|
535
|
+
| `path` | `(): string` | Request pathname (no query). |
|
|
536
|
+
| `fullUrl` | `(): string` | Full URL including query string. |
|
|
537
|
+
| `host` | `(): string` | Host portion of the URL. |
|
|
538
|
+
| `is` | `(pattern): boolean` | Glob-match the path (`*` excludes `/`, `**` includes). |
|
|
539
|
+
| `subdomain` | `(name): string \| null` | A single subdomain param from a `domain` group. |
|
|
540
|
+
| `isJson` | `(): boolean` | True when the body is `application/json`. |
|
|
541
|
+
| `wantsJson` | `(): boolean` | True when `Accept` includes `application/json`. |
|
|
542
|
+
| `ip` | `(): string \| null` | Socket-level client IP, or `null`. |
|
|
543
|
+
| `flash` | `(key, value): void` | Write a value to the session for the next request. |
|
|
544
|
+
| `flashed` | `<T>(key): T \| undefined` | Read a value flashed in the previous request. |
|
|
545
|
+
| `afterResponse` | `(callback): this` | Run a callback after the response is sent. |
|
|
546
|
+
|
|
547
|
+
#### Static methods
|
|
548
|
+
|
|
549
|
+
| Method | Signature | Description |
|
|
550
|
+
| -------- | ---------------------------------------- | ----------------------------------------------------- |
|
|
551
|
+
| `tryGet` | `(): HttpContext \| undefined` | The active context, or `undefined` outside a request. |
|
|
552
|
+
| `fake` | `(url?, init?, container?): HttpContext` | Build a context for unit tests. |
|
|
553
|
+
|
|
554
|
+
> **Note** — `requestId` is echoed back to the client in the `X-Request-Id` response
|
|
555
|
+
> header by [`LoggerMiddleware`](/docs/logger), not by the framework core. The id
|
|
556
|
+
> itself always exists on the context.
|
|
557
|
+
|
|
558
|
+
### `UploadedFile`
|
|
559
|
+
|
|
560
|
+
Returned by `file()` and `files()`.
|
|
561
|
+
|
|
562
|
+
| Member | Signature | Description |
|
|
563
|
+
| ---------------- | ------------------------------------------------------------------------------------------ | ------------------------------------- |
|
|
564
|
+
| `originalName` | `originalName: string` | Client-supplied filename (untrusted). |
|
|
565
|
+
| `mimeType` | `mimeType: string` | Browser-reported MIME type. |
|
|
566
|
+
| `size` | `size: number` | File size in bytes. |
|
|
567
|
+
| `extension` | `extension(): string` | Lowercase extension without the dot. |
|
|
568
|
+
| `isValid` | `isValid(options?: FileValidationOptions): boolean` | Check `maxSize` / `mimes` rules. |
|
|
569
|
+
| `store` | `store(directory: string, disk: StorageDisk, filename?: string): Promise<string>` | Write to a disk, returns the path. |
|
|
570
|
+
| `storeAndGetUrl` | `storeAndGetUrl(directory: string, disk: StorageDisk, filename?: string): Promise<string>` | Store and return the public URL. |
|
|
571
|
+
|
|
572
|
+
## Next steps
|
|
573
|
+
|
|
574
|
+
- [Controllers](/docs/controllers) — where the context is most often used.
|
|
575
|
+
- [Validator](/docs/validator) — validate the input you read here, with `FormRequest`.
|
|
576
|
+
- [Responses](/docs/responses) — the full response-building API.
|
|
577
|
+
- [Middleware](/docs/middleware) — how the context flows through the pipeline.
|
|
578
|
+
- [Routing](/docs/routing) — route params and route-model binding.
|
|
579
|
+
- [Storage](/docs/storage) — the disks `UploadedFile.store()` writes to.
|