@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/routing.md
ADDED
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Routing
|
|
3
|
+
description: Explicit routes, groups, file-based routes, model binding, domains, and testing — the whole routing surface on one page.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Routing
|
|
7
|
+
|
|
8
|
+
The router maps incoming HTTP requests to the controller, view, or closure that
|
|
9
|
+
should handle them, and is where you declare your application's URLs. Zerotal
|
|
10
|
+
offers two complementary styles — **explicit** routes declared in route files and
|
|
11
|
+
**file-based** routes discovered automatically from a directory tree. Both
|
|
12
|
+
register into the same `Router` singleton and can be mixed freely in one app.
|
|
13
|
+
|
|
14
|
+
Routing ships inside `@zerotal/core`, so there is no package to install or
|
|
15
|
+
provider to register — the `Router` is available as soon as your app boots.
|
|
16
|
+
|
|
17
|
+
## Getting Started
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
// routes/index.ts
|
|
21
|
+
import { Router, route } from "zerotal";
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Basic usage
|
|
25
|
+
|
|
26
|
+
Register a `GET` route to a controller action, or to an inline closure:
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
// routes/index.ts
|
|
30
|
+
import { Router } from "zerotal";
|
|
31
|
+
|
|
32
|
+
Router.get("/users", UserController, "index");
|
|
33
|
+
Router.get("/", (ctx) => ctx.html`<h1>Home</h1>`);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
A `GET /users` request now runs `UserController.index`, and `GET /` returns the
|
|
37
|
+
inline HTML.
|
|
38
|
+
|
|
39
|
+
## Which routing style should I use?
|
|
40
|
+
|
|
41
|
+
- **Explicit routes** (`routes/index.ts`) — best when you want every URL visible
|
|
42
|
+
in one place, fine-grained control over names and ordering, or resource/group
|
|
43
|
+
helpers. Start here.
|
|
44
|
+
- **File-based routes** (a directory tree) — best for large apps where the URL
|
|
45
|
+
map mirrors the filesystem, or for page-heavy frontends. Opt in per app.
|
|
46
|
+
|
|
47
|
+
The two compose: file routes register first during boot, then explicit routes run
|
|
48
|
+
and take precedence on duplicate `METHOD + path` keys. See
|
|
49
|
+
[route registration order](#route-registration-order) for the full precedence
|
|
50
|
+
rules.
|
|
51
|
+
|
|
52
|
+
## Explicit routes
|
|
53
|
+
|
|
54
|
+
### HTTP verbs
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
// routes/index.ts
|
|
58
|
+
Router.get("/users", UserController, "index");
|
|
59
|
+
Router.post("/users", UserController, "store");
|
|
60
|
+
Router.put("/users/:id", UserController, "update");
|
|
61
|
+
Router.patch("/users/:id", UserController, "update");
|
|
62
|
+
Router.delete("/users/:id", UserController, "destroy");
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Each call returns a `RouteRegistration` you can chain:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
// routes/index.ts
|
|
69
|
+
Router.get("/posts/:slug", PostController, "show").name("posts.show").bind("post", Post);
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
#### Per-route middleware
|
|
73
|
+
|
|
74
|
+
Pass middleware classes as the fourth argument:
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
// routes/index.ts
|
|
78
|
+
Router.get("/dashboard", DashboardController, "index", [AuthMiddleware]);
|
|
79
|
+
Router.post("/posts", PostController, "store", [AuthMiddleware, ThrottleMiddleware]);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
#### Inline closure handlers
|
|
83
|
+
|
|
84
|
+
Every verb method also accepts a closure instead of a `Controller`/action pair —
|
|
85
|
+
handy for small endpoints that don't warrant a controller. The handler receives
|
|
86
|
+
the same request `HttpContext` a controller action does; raw route params and
|
|
87
|
+
resolved model bindings live on `ctx.params`:
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
// routes/index.ts
|
|
91
|
+
import { Router, type HttpContext } from "zerotal";
|
|
92
|
+
|
|
93
|
+
Router.get("/", (ctx) => ctx.html`<h1>Home</h1>`);
|
|
94
|
+
Router.get("/health", (ctx) => ctx.json({ ok: true }));
|
|
95
|
+
|
|
96
|
+
// Raw route params arrive on ctx.params — type them with HttpContext<{ ... }>:
|
|
97
|
+
Router.get("/posts/:slug", (ctx: HttpContext<{ slug: string }>) =>
|
|
98
|
+
ctx.json({ slug: ctx.params.slug }),
|
|
99
|
+
);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Middleware is passed as the **third** argument in closure form (there's no action
|
|
103
|
+
name), and the returned `RouteRegistration` still chains `.name()` / `.bind()`:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
// routes/index.ts
|
|
107
|
+
Router.get("/admin", (ctx) => ctx.json({ ok: true }), [AuthMiddleware]);
|
|
108
|
+
Router.get("/posts/:post", (ctx: HttpContext<{ post: Post }>) =>
|
|
109
|
+
ctx.json({ post: ctx.params.post }),
|
|
110
|
+
).bind("post", Post);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
> **Note** — Zerotal distinguishes the two forms by the third argument: a string is
|
|
114
|
+
> treated as a controller action; anything else (a middleware array or nothing)
|
|
115
|
+
> means the second argument is a closure handler.
|
|
116
|
+
|
|
117
|
+
### Resource routes
|
|
118
|
+
|
|
119
|
+
`Router.resource()` registers all seven RESTful actions in one call:
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
// routes/index.ts
|
|
123
|
+
Router.resource("posts", PostController);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
| Method | Path | Action |
|
|
127
|
+
| -------- | ----------------- | --------- |
|
|
128
|
+
| `GET` | `/posts` | `index` |
|
|
129
|
+
| `GET` | `/posts/create` | `create` |
|
|
130
|
+
| `POST` | `/posts` | `store` |
|
|
131
|
+
| `GET` | `/posts/:id` | `show` |
|
|
132
|
+
| `GET` | `/posts/:id/edit` | `edit` |
|
|
133
|
+
| `PUT` | `/posts/:id` | `update` |
|
|
134
|
+
| `DELETE` | `/posts/:id` | `destroy` |
|
|
135
|
+
|
|
136
|
+
`PATCH /:id` is also registered and maps to `update`, so both `PUT` and `PATCH`
|
|
137
|
+
are accepted.
|
|
138
|
+
|
|
139
|
+
#### Filtering actions
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
// routes/index.ts
|
|
143
|
+
// Register only these actions
|
|
144
|
+
Router.resource("photos", PhotoController).only(["index", "show"]);
|
|
145
|
+
|
|
146
|
+
// Register everything except these
|
|
147
|
+
Router.resource("tags", TagController).except(["create", "edit"]);
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
#### Resource middleware
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
// routes/index.ts
|
|
154
|
+
Router.resource("comments", CommentController, [AuthMiddleware]);
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### View routes
|
|
158
|
+
|
|
159
|
+
Register a GET route that renders a `@zerotal/core` JSX component directly —
|
|
160
|
+
no controller class needed for simple pages:
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
// routes/index.ts
|
|
164
|
+
import { AboutPage } from "../resources/views/AboutPage.tsx";
|
|
165
|
+
import { DashboardPage } from "../resources/views/DashboardPage.tsx";
|
|
166
|
+
|
|
167
|
+
// Static props — evaluated once at registration
|
|
168
|
+
Router.view("/about", AboutPage, { title: "About Us" });
|
|
169
|
+
|
|
170
|
+
// Dynamic props — factory is called on each request
|
|
171
|
+
Router.view("/dashboard", DashboardPage, (ctx) => ({
|
|
172
|
+
user: ctx.user,
|
|
173
|
+
greeting: `Hello, ${ctx.user?.name ?? "guest"}`,
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
// No props
|
|
177
|
+
Router.view("/privacy", PrivacyPage);
|
|
178
|
+
|
|
179
|
+
// With middleware
|
|
180
|
+
Router.view("/settings", SettingsPage, (ctx) => ({ user: ctx.user }), [AuthMiddleware]);
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The route chains `.name()` and `.withLayout()`:
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
// routes/index.ts
|
|
187
|
+
Router.view("/about", AboutPage).name("about").withLayout(AppLayout);
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Static file serving
|
|
191
|
+
|
|
192
|
+
Serve a local directory under a URL prefix:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
// routes/index.ts
|
|
196
|
+
Router.static("/assets", "./public/assets");
|
|
197
|
+
Router.static("/uploads", "public/uploads");
|
|
198
|
+
|
|
199
|
+
// With cache headers
|
|
200
|
+
Router.static("/assets", "./public", {
|
|
201
|
+
headers: { "Cache-Control": "public, max-age=31536000, immutable" },
|
|
202
|
+
});
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Any `GET /assets/logo.svg` resolves to `./public/assets/logo.svg`, served with
|
|
206
|
+
Bun's native file streaming. Returns 404 for missing files. By default every file
|
|
207
|
+
is pre-registered at boot as a static `Response` (zero JS per request); pass
|
|
208
|
+
`eager: false` to fall back to a per-request lookup.
|
|
209
|
+
|
|
210
|
+
### Markdown file serving
|
|
211
|
+
|
|
212
|
+
Serve a directory of `.md` files as rendered HTML pages:
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
// routes/index.ts
|
|
216
|
+
Router.markdown("/docs", "./docs");
|
|
217
|
+
|
|
218
|
+
// With parser options and a fallback title
|
|
219
|
+
Router.markdown("/docs", "./docs", {
|
|
220
|
+
title: "Zerotal Docs",
|
|
221
|
+
headings: { ids: true },
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
URL mapping: `GET /docs/orm` → `./docs/orm.md` (also tries `./docs/orm/index.md`
|
|
226
|
+
for bare directory paths). Uses `Bun.markdown.html()` with GFM extensions
|
|
227
|
+
(tables, strikethrough, tasklists, autolinks) enabled by default.
|
|
228
|
+
|
|
229
|
+
### Raw routes
|
|
230
|
+
|
|
231
|
+
Register a handler that receives the raw `Request` and bypasses the entire global
|
|
232
|
+
middleware pipeline — no `HttpContext`, no session, no auth. Useful for internal
|
|
233
|
+
health checks or asset endpoints:
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
// routes/index.ts
|
|
237
|
+
Router.raw("GET", "/__ping", () => new Response("pong"));
|
|
238
|
+
Router.raw("GET", "/health", async () => {
|
|
239
|
+
const ok = await db
|
|
240
|
+
.query("SELECT 1")
|
|
241
|
+
.then(() => true)
|
|
242
|
+
.catch(() => false);
|
|
243
|
+
return Response.json({ ok });
|
|
244
|
+
});
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
> **Warning** — Raw routes skip every middleware (session, CSRF, auth). Only use
|
|
248
|
+
> them for endpoints that must not run the pipeline. They are compiled last and
|
|
249
|
+
> take precedence over same-path pipeline routes.
|
|
250
|
+
|
|
251
|
+
### Flow routes
|
|
252
|
+
|
|
253
|
+
Added by the `@zerotal/flow` package:
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
// routes/index.ts
|
|
257
|
+
import { Router } from "zerotal";
|
|
258
|
+
|
|
259
|
+
Router.flow("/dashboard", DashboardPage);
|
|
260
|
+
Router.flow("/chat", ChatPage, [AuthMiddleware]);
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
See the [Flow](/docs/flow) guide for full details.
|
|
264
|
+
|
|
265
|
+
## Route groups
|
|
266
|
+
|
|
267
|
+
Groups apply a shared prefix and/or middleware stack to a set of routes. Groups
|
|
268
|
+
nest — prefix and middleware accumulate.
|
|
269
|
+
|
|
270
|
+
```typescript
|
|
271
|
+
// routes/index.ts
|
|
272
|
+
Router.group({ prefix: "/api/v1" }, () => {
|
|
273
|
+
Router.get("/users", UserController, "index");
|
|
274
|
+
Router.post("/users", UserController, "store");
|
|
275
|
+
// → GET /api/v1/users, POST /api/v1/users
|
|
276
|
+
});
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
// routes/index.ts
|
|
281
|
+
Router.group({ prefix: "/api/v1", middleware: AuthMiddleware }, () => {
|
|
282
|
+
Router.resource("posts", PostController);
|
|
283
|
+
});
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
A group is bookkeeping applied while its callback runs, not a runtime wrapper.
|
|
287
|
+
Routes register themselves with the accumulated prefix and middleware already
|
|
288
|
+
baked in, so grouping costs nothing per request. The surrounding state is restored
|
|
289
|
+
even when the callback throws, so one broken group cannot leak its prefix into the
|
|
290
|
+
routes declared after it.
|
|
291
|
+
|
|
292
|
+
### Named middleware groups
|
|
293
|
+
|
|
294
|
+
Define a group of middleware classes under a string key, then reference it by
|
|
295
|
+
name:
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
// in a ServiceProvider or bootstrap
|
|
299
|
+
Router.middlewareGroup("api", [ThrottleMiddleware, JsonMiddleware]);
|
|
300
|
+
Router.middlewareGroup("web", [SessionMiddleware, CsrfMiddleware]);
|
|
301
|
+
|
|
302
|
+
// Reference by name
|
|
303
|
+
Router.group({ prefix: "/api", middleware: "api" }, () => {
|
|
304
|
+
Router.resource("posts", PostController);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Mix names and classes
|
|
308
|
+
Router.group({ middleware: ["web", AuthMiddleware] }, () => {
|
|
309
|
+
Router.get("/dashboard", DashboardController, "index");
|
|
310
|
+
});
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Naming a stack once and referring to it keeps the definition in one place, which
|
|
314
|
+
matters most when the stack changes: adding a middleware to the `web` group applies
|
|
315
|
+
it everywhere that group is used instead of requiring an edit at every call site.
|
|
316
|
+
|
|
317
|
+
### Nested groups
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
// routes/index.ts
|
|
321
|
+
Router.group({ prefix: "/admin" }, () => {
|
|
322
|
+
Router.group({ middleware: [AuthMiddleware, AdminMiddleware] }, () => {
|
|
323
|
+
Router.get("/dashboard", AdminController, "dashboard").name("admin.dashboard");
|
|
324
|
+
Router.resource("users", AdminUserController);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
The three options compose differently as groups nest, which is worth knowing before
|
|
330
|
+
relying on it:
|
|
331
|
+
|
|
332
|
+
| Option | Nesting behaviour |
|
|
333
|
+
| ------------ | -------------------------------------------------------------------- |
|
|
334
|
+
| `prefix` | Concatenates outer then inner — `/admin` + `/users` → `/admin/users` |
|
|
335
|
+
| `middleware` | Appends, outermost first, so the outer stack runs earlier |
|
|
336
|
+
| `domain` | Replaces — an inner `domain` overrides the outer one entirely |
|
|
337
|
+
|
|
338
|
+
Because middleware order follows nesting depth, an outer `AuthMiddleware` runs
|
|
339
|
+
before an inner `AdminMiddleware`. Put the checks that should fail fastest — or
|
|
340
|
+
that the inner ones depend on, such as resolving the current user — in the outer
|
|
341
|
+
group.
|
|
342
|
+
|
|
343
|
+
### Domain & subdomain routing
|
|
344
|
+
|
|
345
|
+
The `domain` option scopes a group of routes to a specific host. Dynamic `:label`
|
|
346
|
+
segments are captured and exposed on the context via `ctx.subdomains`.
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
// routes/index.ts
|
|
350
|
+
import { Router } from "zerotal";
|
|
351
|
+
|
|
352
|
+
// Static host
|
|
353
|
+
Router.group({ domain: "admin.app.com" }, () => {
|
|
354
|
+
Router.get("/", AdminController, "dashboard");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Dynamic subdomain — captured as ctx.subdomains.tenant
|
|
358
|
+
Router.group({ domain: ":tenant.app.com" }, () => {
|
|
359
|
+
Router.get("/", DashboardController, "index");
|
|
360
|
+
});
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
// app/controllers/DashboardController.ts
|
|
365
|
+
class DashboardController {
|
|
366
|
+
index(ctx: HttpContext) {
|
|
367
|
+
ctx.subdomains; // { tenant: 'acme' } for acme.app.com
|
|
368
|
+
ctx.subdomain("tenant"); // 'acme' (or null)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Routes that share a path across different hosts are dispatched by host at request
|
|
374
|
+
time.
|
|
375
|
+
|
|
376
|
+
> **Warning** — Register specific domains before wildcards — the first matching
|
|
377
|
+
> host wins, just like route ordering. A domain-only path returns `404` for a
|
|
378
|
+
> host that doesn't match and has no plain (domain-less) fallback. `domain`
|
|
379
|
+
> composes with `prefix` and `middleware` in the same group.
|
|
380
|
+
|
|
381
|
+
#### With multi-tenancy
|
|
382
|
+
|
|
383
|
+
`@zerotal/tenancy`'s `SubdomainResolver` resolves the tenant _model_ from the same
|
|
384
|
+
subdomain a domain group captures, so the two compose directly:
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
// routes/index.ts
|
|
388
|
+
Router.group({ domain: ":tenant.app.com" }, () => {
|
|
389
|
+
Router.get("/dashboard", DashboardController, "index");
|
|
390
|
+
// ctx.subdomains.tenant === the resolved tenant slug; TenantContext holds the model
|
|
391
|
+
});
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Configuring the resolver is covered in [Multi-tenancy](/docs/tenancy).
|
|
395
|
+
|
|
396
|
+
## Named routes
|
|
397
|
+
|
|
398
|
+
Assign a name and generate URLs from it:
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
// routes/index.ts
|
|
402
|
+
Router.get("/posts/:slug", PostController, "show").name("posts.show");
|
|
403
|
+
|
|
404
|
+
// Generate the URL with route()
|
|
405
|
+
route("posts.show", { slug: "hello-world" }); // → '/posts/hello-world'
|
|
406
|
+
route("search", {}, { q: "zerotal", page: 2 }); // → '/search?q=zerotal&page=2'
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
`route(name, params, query)` takes three arguments, and each one means one thing:
|
|
410
|
+
params fill `:segments`, query values become the query string. A key in `params`
|
|
411
|
+
that matches no segment is an error, not a query param.
|
|
412
|
+
|
|
413
|
+
Naming is what lets a path change without a sweep through templates: the URL lives
|
|
414
|
+
in one place and every link asks for it by name. A dotted convention —
|
|
415
|
+
`posts.show`, `admin.users.edit` — keeps names sorted and readable as the table
|
|
416
|
+
grows, and mirrors the grouping the routes already sit in.
|
|
417
|
+
|
|
418
|
+
> **Warning** — `route()` throws if the route name is unknown, a required param
|
|
419
|
+
> is missing, or a param matches no `:segment`.
|
|
420
|
+
|
|
421
|
+
Throwing rather than returning a broken string is deliberate: a typo surfaces the
|
|
422
|
+
first time the code runs instead of shipping a link to a 404. Run
|
|
423
|
+
[`bun zt route:list`](#the-routelist-command) to see every registered name.
|
|
424
|
+
|
|
425
|
+
A catch-all route (`/docs/*`, or `[...slug].ts` under file-based routing) reaches
|
|
426
|
+
the router as `*` — the segment's name is gone by then — so its value is passed
|
|
427
|
+
under the `"*"` key, as a path or as segments:
|
|
428
|
+
|
|
429
|
+
```typescript
|
|
430
|
+
route("docs.show", { "*": "guides/intro" }); // → '/docs/guides/intro'
|
|
431
|
+
route("docs.show", { "*": ["guides", "intro"] }); // → '/docs/guides/intro'
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### Typed route names
|
|
435
|
+
|
|
436
|
+
Run `bun zt route:types` and the names above stop being strings the compiler has
|
|
437
|
+
to take on faith:
|
|
438
|
+
|
|
439
|
+
```typescript
|
|
440
|
+
route("psots.show", { slug }); // ✗ not assignable to RouteName
|
|
441
|
+
route("posts.show"); // ✗ Expected 2 arguments, but got 1
|
|
442
|
+
route("posts.show", {}); // ✗ Property 'slug' is missing
|
|
443
|
+
route("posts.show", { slugg: "x" }); // ✗ Did you mean to write 'slug'?
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
The command boots the app, reads the routes it registered, and writes
|
|
447
|
+
`types/routes.generated.ts` — a name → pattern map plus a one-line augmentation
|
|
448
|
+
that `route()` reads:
|
|
449
|
+
|
|
450
|
+
```typescript
|
|
451
|
+
export const ROUTES = {
|
|
452
|
+
home: "/",
|
|
453
|
+
"posts.show": "/posts/:slug",
|
|
454
|
+
} as const;
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
It boots rather than scanning the routes directory because a route name comes
|
|
458
|
+
from three places and only one of them is a file path: the file-router's
|
|
459
|
+
convention, a route file's `export const meta = { GET: { name } }`, and
|
|
460
|
+
programmatic registrations — including those a package's provider makes. A
|
|
461
|
+
scanner would see the first and quietly miss the other two. Params are derived
|
|
462
|
+
from the pattern, so adding a segment changes one string and every call site
|
|
463
|
+
updates with it.
|
|
464
|
+
|
|
465
|
+
**Commit the generated file.** `zt dev` rewrites it on every restart, so it stays
|
|
466
|
+
current while you work, but editors and CI need it without booting the app. In CI:
|
|
467
|
+
|
|
468
|
+
```bash
|
|
469
|
+
bun zt route:types --check # fails when the file no longer matches the routes
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Until you run the command, the registry is empty and `route()` behaves exactly as
|
|
473
|
+
it always did — every name accepted, nothing checked.
|
|
474
|
+
|
|
475
|
+
**When the name is not known at compile time** — read from config, chosen by a
|
|
476
|
+
package — use the escape hatch, which does the same work with no checking:
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
route.dynamic(config("app.home_route"), { id });
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
It is a separate function rather than an overload on `route()` for a reason: an
|
|
483
|
+
overload that accepts every string is matched by every string, and would make
|
|
484
|
+
the checked signature above decorative.
|
|
485
|
+
|
|
486
|
+
Typed names flow through the helpers built on `route()` too — `redirect().to()`,
|
|
487
|
+
`Url.route()`, `Uri.route()`, and Flow's `redirectRoute()`.
|
|
488
|
+
|
|
489
|
+
### route() in the browser
|
|
490
|
+
|
|
491
|
+
The server's `route()` reads the live router, which only exists in the server
|
|
492
|
+
process. In a browser bundle, import it from `zerotal/routes` instead and
|
|
493
|
+
hand it the generated table once, at your entry point:
|
|
494
|
+
|
|
495
|
+
```typescript
|
|
496
|
+
// resources/js/app.js
|
|
497
|
+
import { defineRoutes } from "zerotal/routes";
|
|
498
|
+
import { ROUTES } from "../../types/routes.generated";
|
|
499
|
+
|
|
500
|
+
defineRoutes(ROUTES);
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
From there the call is the one you already know:
|
|
504
|
+
|
|
505
|
+
```typescript
|
|
506
|
+
import { route } from "zerotal/routes";
|
|
507
|
+
|
|
508
|
+
route("posts.show", { slug }); // → '/posts/hello'
|
|
509
|
+
route("posts.index", {}, { page: 2 }); // → '/posts?page=2'
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
Same names, same params, same errors. Both helpers are typed as one
|
|
513
|
+
`RouteBuilder` interface and share one URL builder, so a link rendered on the
|
|
514
|
+
server and the same call made in a component cannot disagree about encoding — and
|
|
515
|
+
because `types/routes.generated.ts` augments the one registry, a name that
|
|
516
|
+
type-checks in a controller type-checks in a component.
|
|
517
|
+
|
|
518
|
+
`defineRoutes()` takes the generated `ROUTES` object or any `RouteTable`
|
|
519
|
+
(a name → pattern map). Calling it again replaces the table, which is what makes
|
|
520
|
+
hot reload work. `resetRoutes()` clears it again, for tests that assert on the
|
|
521
|
+
unconfigured error.
|
|
522
|
+
|
|
523
|
+
If your app renders through SSR, call `defineRoutes()` in the SSR entry too — the
|
|
524
|
+
page components run in both processes.
|
|
525
|
+
|
|
526
|
+
For a link that only exists when some package is installed, ask first rather than
|
|
527
|
+
catching a throw:
|
|
528
|
+
|
|
529
|
+
```typescript
|
|
530
|
+
import { hasRoute, route } from "zerotal/routes";
|
|
531
|
+
|
|
532
|
+
{hasRoute("admin.index") && <a href={route("admin.index")}>Admin</a>}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
**In Flow**, the table arrives on its own. `/__flow/runtime.js` is built by the
|
|
536
|
+
framework rather than by your app, so it cannot import your generated file;
|
|
537
|
+
Flow serialises the table onto the runtime it serves instead, and exposes the
|
|
538
|
+
helper to Alpine expressions as `$route`:
|
|
539
|
+
|
|
540
|
+
```html
|
|
541
|
+
<a :href="$route('posts.show', { slug: post.slug })">Read</a>
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
Nothing to install, and the names are the same ones the server rendered with.
|
|
545
|
+
|
|
546
|
+
## File-based routing
|
|
547
|
+
|
|
548
|
+
Map a directory tree to routes: each file under the routes directory becomes an
|
|
549
|
+
endpoint, with `_middleware.ts` for shared middleware.
|
|
550
|
+
|
|
551
|
+
Opt in with `app.fileBasedRouting()` in `bootstrap/app.ts`, passing a map of
|
|
552
|
+
named route groups (each `web`/`api` group brings its own default prefix and
|
|
553
|
+
middleware) to directories:
|
|
554
|
+
|
|
555
|
+
```typescript
|
|
556
|
+
// bootstrap/app.ts
|
|
557
|
+
import { Application, basePath } from "zerotal";
|
|
558
|
+
import providers from "./providers";
|
|
559
|
+
|
|
560
|
+
const app = Application.create({ providers }).fileBasedRouting({
|
|
561
|
+
web: basePath("app/routes"),
|
|
562
|
+
});
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
The scanner walks the directory and converts file paths to URL paths
|
|
566
|
+
automatically.
|
|
567
|
+
|
|
568
|
+
### File → URL mapping
|
|
569
|
+
|
|
570
|
+
| File path | URL |
|
|
571
|
+
| ------------------------- | ---------------------- |
|
|
572
|
+
| `index.ts` | `/` |
|
|
573
|
+
| `about.ts` | `/about` |
|
|
574
|
+
| `users/index.ts` | `/users` |
|
|
575
|
+
| `users/[id].ts` | `/users/:id` |
|
|
576
|
+
| `(admin)/users/index.ts` | `/users` |
|
|
577
|
+
| `api/users/[id]/posts.ts` | `/api/users/:id/posts` |
|
|
578
|
+
| `blog/[...slug].ts` | `/blog/*` |
|
|
579
|
+
|
|
580
|
+
Rules in order: `(group)` directory segments are stripped (no URL contribution);
|
|
581
|
+
`[...slug]` becomes `*` (catch-all); `[param]` becomes `:param` (dynamic
|
|
582
|
+
segment); `index` becomes the directory URL.
|
|
583
|
+
|
|
584
|
+
### Handler exports
|
|
585
|
+
|
|
586
|
+
A file may export handlers for one or more HTTP verbs:
|
|
587
|
+
|
|
588
|
+
```typescript
|
|
589
|
+
// app/routes/users/[id].ts
|
|
590
|
+
import type { HttpContext } from "zerotal";
|
|
591
|
+
|
|
592
|
+
export async function GET(ctx: HttpContext): Promise<void> {
|
|
593
|
+
const user = await User.findOrFail(Number(ctx.params.id));
|
|
594
|
+
ctx.json(user);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export async function DELETE(ctx: HttpContext): Promise<void> {
|
|
598
|
+
const user = await User.findOrFail(Number(ctx.params.id));
|
|
599
|
+
await user.delete();
|
|
600
|
+
ctx.response = new Response(null, { status: 204 });
|
|
601
|
+
}
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
`export default` is a GET alias when no explicit `GET` export exists:
|
|
605
|
+
|
|
606
|
+
```typescript
|
|
607
|
+
// app/routes/api/v2/status.ts
|
|
608
|
+
import type { HttpContext } from "zerotal";
|
|
609
|
+
|
|
610
|
+
export default async function (ctx: HttpContext): Promise<void> {
|
|
611
|
+
ctx.json({ status: "ok", uptime: Math.floor(process.uptime()) });
|
|
612
|
+
}
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
### Auto-generated route names
|
|
616
|
+
|
|
617
|
+
File routes are automatically named following the RESTful convention:
|
|
618
|
+
|
|
619
|
+
| URL | Method | Auto-name |
|
|
620
|
+
| ---------------- | -------- | ------------------- |
|
|
621
|
+
| `/` | `GET` | `home` |
|
|
622
|
+
| `/about` | `GET` | `about` |
|
|
623
|
+
| `/about` | `POST` | `about.store` |
|
|
624
|
+
| `/api/users` | `GET` | `api.users.index` |
|
|
625
|
+
| `/api/users` | `POST` | `api.users.store` |
|
|
626
|
+
| `/api/users/:id` | `GET` | `api.users.show` |
|
|
627
|
+
| `/api/users/:id` | `PUT` | `api.users.update` |
|
|
628
|
+
| `/api/users/:id` | `DELETE` | `api.users.destroy` |
|
|
629
|
+
|
|
630
|
+
Override with the `meta` export:
|
|
631
|
+
|
|
632
|
+
```typescript
|
|
633
|
+
// app/routes/api/v2/posts/[id].ts
|
|
634
|
+
export const meta = {
|
|
635
|
+
GET: { name: "api.v2.posts.show" },
|
|
636
|
+
DELETE: { name: "api.v2.posts.destroy" },
|
|
637
|
+
};
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
### Directory middleware
|
|
641
|
+
|
|
642
|
+
Place a `_middleware.ts` file in any directory to protect all routes under it.
|
|
643
|
+
The scanner walks from the root down to the file's directory and stacks
|
|
644
|
+
middleware outermost-first:
|
|
645
|
+
|
|
646
|
+
```typescript
|
|
647
|
+
// app/routes/api/v2/me/_middleware.ts
|
|
648
|
+
import { RequireAuthMiddleware } from "../../../middleware/RequireAuthMiddleware.ts";
|
|
649
|
+
|
|
650
|
+
export const middleware = [RequireAuthMiddleware];
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
For `app/routes/api/v2/me/posts.ts` the stack would be:
|
|
654
|
+
|
|
655
|
+
1. `app/routes/_middleware.ts` (if present)
|
|
656
|
+
2. `app/routes/api/_middleware.ts` (if present)
|
|
657
|
+
3. `app/routes/api/v2/_middleware.ts` (if present)
|
|
658
|
+
4. `app/routes/api/v2/me/_middleware.ts` ← adds `RequireAuthMiddleware`
|
|
659
|
+
|
|
660
|
+
#### Route groups in file trees
|
|
661
|
+
|
|
662
|
+
Use `(group)` directory names to apply shared `_middleware.ts` without affecting
|
|
663
|
+
URLs:
|
|
664
|
+
|
|
665
|
+
```text
|
|
666
|
+
app/flow/pages/
|
|
667
|
+
(auth)/ ← GuestMiddleware
|
|
668
|
+
login.tsx → /login
|
|
669
|
+
register.tsx → /register
|
|
670
|
+
(protected)/ ← AuthMiddleware
|
|
671
|
+
dashboard.tsx → /dashboard
|
|
672
|
+
profile.tsx → /profile
|
|
673
|
+
index.tsx → /
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
### Flow component files
|
|
677
|
+
|
|
678
|
+
When `@zerotal/flow` is installed, the file scanner recognises Flow
|
|
679
|
+
`Component` class exports and registers them automatically without any extra
|
|
680
|
+
configuration. Verb handlers (`POST`, `DELETE`, etc.) in the same file still
|
|
681
|
+
register normally alongside the page:
|
|
682
|
+
|
|
683
|
+
```typescript
|
|
684
|
+
// app/flow/pages/(protected)/dashboard.tsx
|
|
685
|
+
import type { HttpContext } from "zerotal";
|
|
686
|
+
|
|
687
|
+
export default class DashboardPage extends Component {
|
|
688
|
+
/* ... */
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Optional: form handler lives in the same file
|
|
692
|
+
export async function POST(ctx: HttpContext): Promise<void> {
|
|
693
|
+
/* ... */
|
|
694
|
+
}
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
## Route-model binding
|
|
698
|
+
|
|
699
|
+
Resolve a route parameter straight to a model instance before the controller
|
|
700
|
+
runs, with an automatic 404 when the record is missing.
|
|
701
|
+
|
|
702
|
+
### Implicit binding
|
|
703
|
+
|
|
704
|
+
Because every model under `app/models/` is
|
|
705
|
+
[auto-registered](/docs/conventions#models-appmodels), binding is automatic: a
|
|
706
|
+
route param whose name matches a model resolves to a loaded instance with no
|
|
707
|
+
configuration. `:user` resolves via `User`, `:post` via `Post`, `:blogPost` via
|
|
708
|
+
`BlogPost` (and a plural `:users` resolves to `User` too, via singularization).
|
|
709
|
+
|
|
710
|
+
```typescript
|
|
711
|
+
// routes/index.ts — nothing to declare:
|
|
712
|
+
Router.get("/users/:user", UserController, "show");
|
|
713
|
+
|
|
714
|
+
// app/controllers/UserController.ts — :user is already a loaded User
|
|
715
|
+
// (or a 404 was thrown first):
|
|
716
|
+
async show(ctx: HttpContext<{ user: User }>) {
|
|
717
|
+
return ctx.json({ user: ctx.params.user });
|
|
718
|
+
}
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
Resolution uses `Model.findOrFail(value)` on the primary key; a missing record
|
|
722
|
+
throws `ModelNotFoundError` (404) before the controller runs. Implicit binding
|
|
723
|
+
only fires for params whose name maps to a model — `:id`, `:slug`, `:page`, and
|
|
724
|
+
other non-model params stay raw strings.
|
|
725
|
+
|
|
726
|
+
#### The model owns its lookup
|
|
727
|
+
|
|
728
|
+
A model that resolves by something other than its primary key says so once, on the
|
|
729
|
+
model, rather than at every route that mentions it. Declare
|
|
730
|
+
`static resolveRouteBinding` and it is used wherever that model binds:
|
|
731
|
+
|
|
732
|
+
```typescript
|
|
733
|
+
// app/models/User.ts
|
|
734
|
+
@table("users")
|
|
735
|
+
export class User extends Model {
|
|
736
|
+
static override async resolveRouteBinding(value: string, ctx: HttpContext, param: string) {
|
|
737
|
+
// One model can answer for several segments — branch on the param, not the URL.
|
|
738
|
+
if (param === "username") return this.where("username", value).firstOrFail();
|
|
739
|
+
return this.findOrFail(value);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
It receives the matched value, the request context, and the **name of the segment**
|
|
745
|
+
that matched. Branching on `param` is what lets `/users/:user` and
|
|
746
|
+
`/users/:username/posts` resolve differently without the model knowing any route's
|
|
747
|
+
shape; `ctx` is there for lookups that depend on the request, such as scoping to the
|
|
748
|
+
current tenant.
|
|
749
|
+
|
|
750
|
+
Because it is a static, a subclass inherits it, and everything that binds a model —
|
|
751
|
+
controllers, [Flow pages](/docs/flow/routing), file routes — goes through it. Return
|
|
752
|
+
anything you like; throwing `ModelNotFoundError` (as `firstOrFail`/`findOrFail` do)
|
|
753
|
+
is what produces the 404.
|
|
754
|
+
|
|
755
|
+
### When bindings resolve
|
|
756
|
+
|
|
757
|
+
Bindings resolve **after every middleware on the route, immediately before the
|
|
758
|
+
controller**. That ordering is a guarantee, not an implementation detail:
|
|
759
|
+
|
|
760
|
+
- A middleware that short-circuits — an auth guard returning 401, a tenant
|
|
761
|
+
scope returning 403 — does so **before any binding query runs**. A protected
|
|
762
|
+
route therefore answers the same way whether or not the record exists, rather
|
|
763
|
+
than leaking that difference as 404-vs-401 to an unauthenticated caller.
|
|
764
|
+
- Middleware runs with the raw string still on `ctx.params`. If a middleware
|
|
765
|
+
needs the record itself, load it there — it cannot rely on the binding.
|
|
766
|
+
- A `ModelNotFoundError` unwinds back out through the middleware, so their
|
|
767
|
+
`finally` blocks (session persistence, for example) still run.
|
|
768
|
+
|
|
769
|
+
#### Opting out and customising
|
|
770
|
+
|
|
771
|
+
Two static properties on the model control implicit binding:
|
|
772
|
+
|
|
773
|
+
```typescript
|
|
774
|
+
// app/models/User.ts
|
|
775
|
+
@table("users")
|
|
776
|
+
export class User extends Model {
|
|
777
|
+
// Never bind this model implicitly (the :user param stays a raw string):
|
|
778
|
+
static implicitBinding = false;
|
|
779
|
+
|
|
780
|
+
// Or claim a different param name than the class name:
|
|
781
|
+
static implicitBindingKey = "author"; // now :author resolves via User, and :user does not
|
|
782
|
+
}
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
`implicitBindingKey` **replaces** the name convention — a model that claims
|
|
786
|
+
`:author` no longer answers to `:user`.
|
|
787
|
+
|
|
788
|
+
> **Tip** — Need to resolve by something other than the primary key (e.g. a
|
|
789
|
+
> slug)? Prefer [`static resolveRouteBinding`](#the-model-owns-its-lookup) — it
|
|
790
|
+
> applies everywhere the model binds. Reach for an explicit
|
|
791
|
+
> [per-route binding](#per-route-binding) when the override belongs to one route
|
|
792
|
+
> rather than to the model; explicit bindings always win over implicit ones.
|
|
793
|
+
|
|
794
|
+
### Explicit binding
|
|
795
|
+
|
|
796
|
+
There are exactly two ways to change how a param resolves, and they differ only in
|
|
797
|
+
scope: put it on the **model** when it is how that model always resolves, or on the
|
|
798
|
+
**route** when it belongs to that one route.
|
|
799
|
+
|
|
800
|
+
```typescript
|
|
801
|
+
// in a controller — either way, it is already resolved
|
|
802
|
+
async show(ctx: HttpContext) {
|
|
803
|
+
const user = ctx.model<User>('user'); // no DB call needed here
|
|
804
|
+
return ctx.json({ user });
|
|
805
|
+
}
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
If the record is not found, a `ModelNotFoundError` (404) is thrown before the
|
|
809
|
+
controller runs.
|
|
810
|
+
|
|
811
|
+
#### Per-route binding
|
|
812
|
+
|
|
813
|
+
Overrides the model's own resolution for a single route:
|
|
814
|
+
|
|
815
|
+
```typescript
|
|
816
|
+
// routes/index.ts
|
|
817
|
+
Router.get("/posts/:post", PostController, "show").name("posts.show").bind("post", Post);
|
|
818
|
+
|
|
819
|
+
// Custom per-route resolver
|
|
820
|
+
Router.get("/articles/:article", ArticleController, "show").bind("article", (value) =>
|
|
821
|
+
Article.where("slug", value).firstOrFail(),
|
|
822
|
+
);
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
#### Receiving bindings in controllers
|
|
826
|
+
|
|
827
|
+
The resolved instance is available two ways — via `ctx.model()`, or on
|
|
828
|
+
`ctx.params` under the param's name:
|
|
829
|
+
|
|
830
|
+
```typescript
|
|
831
|
+
// in a controller — via ctx.model()
|
|
832
|
+
async show(ctx: HttpContext) {
|
|
833
|
+
const post = ctx.model<Post>('post');
|
|
834
|
+
ctx.json(post);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// via ctx.params
|
|
838
|
+
async show(ctx: HttpContext<{ post: Post }>) {
|
|
839
|
+
ctx.json(ctx.params.post);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// mixed — model + raw route param
|
|
843
|
+
async comments(ctx: HttpContext<{ post: Post; tab: string }>) {
|
|
844
|
+
const { post, tab } = ctx.params;
|
|
845
|
+
ctx.json({ post, tab, comments: await post.comments().all() });
|
|
846
|
+
}
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
`ctx.params` is one part of the request context every handler receives — reading
|
|
850
|
+
input, sending responses, headers, flash, and after-response hooks all live on the
|
|
851
|
+
same object. See [HttpContext](/docs/context) for that surface.
|
|
852
|
+
|
|
853
|
+
## Route registration order
|
|
854
|
+
|
|
855
|
+
1. `app.fileBasedRouting()` directories are scanned and registered during boot.
|
|
856
|
+
2. Explicit route files run and register their routes.
|
|
857
|
+
3. `Router.raw()` routes are compiled last and take precedence over same-path
|
|
858
|
+
pipeline routes.
|
|
859
|
+
|
|
860
|
+
Within explicit routes, last registration wins for duplicate `METHOD + path`
|
|
861
|
+
keys — registering the same path twice overwrites the first.
|
|
862
|
+
|
|
863
|
+
For resource routes, register literal paths **before** dynamic ones when there is
|
|
864
|
+
a naming conflict:
|
|
865
|
+
|
|
866
|
+
```typescript
|
|
867
|
+
// routes/index.ts
|
|
868
|
+
// Correct — /posts/create is matched before /posts/:slug
|
|
869
|
+
Router.get("/posts/create", PostController, "showCreate");
|
|
870
|
+
Router.get("/posts/:slug", PostController, "show");
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
## Route inspection
|
|
874
|
+
|
|
875
|
+
### The route:list command
|
|
876
|
+
|
|
877
|
+
Print a table of all registered routes:
|
|
878
|
+
|
|
879
|
+
```bash
|
|
880
|
+
# in your project root
|
|
881
|
+
bun zt route:list
|
|
882
|
+
|
|
883
|
+
# Filter by method
|
|
884
|
+
bun zt route:list --method GET
|
|
885
|
+
bun zt route:list -m DELETE
|
|
886
|
+
|
|
887
|
+
# Filter by path substring
|
|
888
|
+
bun zt route:list --path /api
|
|
889
|
+
bun zt route:list -p /users
|
|
890
|
+
|
|
891
|
+
# Show only named routes
|
|
892
|
+
bun zt route:list --name
|
|
893
|
+
|
|
894
|
+
# Include middleware column
|
|
895
|
+
bun zt route:list --verbose
|
|
896
|
+
bun zt route:list -v
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
### The route:types command
|
|
900
|
+
|
|
901
|
+
Write the generated name → pattern map that makes [`route()` typed](#typed-route-names):
|
|
902
|
+
|
|
903
|
+
```bash
|
|
904
|
+
# writes types/routes.generated.ts — commit it
|
|
905
|
+
bun zt route:types
|
|
906
|
+
|
|
907
|
+
# CI: fail when the committed file no longer matches the routes
|
|
908
|
+
bun zt route:types --check
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
Both forms boot the app, so a route a provider registers is included.
|
|
912
|
+
|
|
913
|
+
### Programmatic inspection
|
|
914
|
+
|
|
915
|
+
```typescript
|
|
916
|
+
// anywhere after boot
|
|
917
|
+
Router.routes; // ReadonlyMap<string, RouteDefinition>
|
|
918
|
+
Router.namedRoutes; // ReadonlyMap<string, string> (name → path)
|
|
919
|
+
|
|
920
|
+
// Middleware attached to a specific route
|
|
921
|
+
Router.middlewareFor("GET", "/dashboard"); // MiddlewareClass[]
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
## Testing
|
|
925
|
+
|
|
926
|
+
Use `HttpContext.fake()` to unit-test controllers and route handlers without a
|
|
927
|
+
running server:
|
|
928
|
+
|
|
929
|
+
```typescript
|
|
930
|
+
// src/tests/PostController.test.ts
|
|
931
|
+
import { HttpContext } from "zerotal";
|
|
932
|
+
|
|
933
|
+
const ctx = HttpContext.fake("http://localhost/posts/42", { method: "GET" });
|
|
934
|
+
ctx.params = { id: "42" };
|
|
935
|
+
|
|
936
|
+
await new PostController().show(ctx);
|
|
937
|
+
// assert ctx.response
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
For full integration tests, boot the real app with `createTestApp()` from
|
|
941
|
+
`@zerotal/testing` and exercise it over real requests:
|
|
942
|
+
|
|
943
|
+
```typescript
|
|
944
|
+
// src/tests/PostTest.ts
|
|
945
|
+
import { beforeAll, afterAll, it } from "bun:test";
|
|
946
|
+
import { createTestApp, type TestApp } from "@zerotal/testing";
|
|
947
|
+
import { app } from "../bootstrap/app.ts";
|
|
948
|
+
|
|
949
|
+
let testApp: TestApp;
|
|
950
|
+
beforeAll(async () => (testApp = await createTestApp(() => app)));
|
|
951
|
+
afterAll(() => testApp.close());
|
|
952
|
+
|
|
953
|
+
it("returns the post", async () => {
|
|
954
|
+
const post = await Post.create({ title: "Hello" });
|
|
955
|
+
const res = await testApp.get(`/posts/${post.id}`);
|
|
956
|
+
res.assertStatus(200);
|
|
957
|
+
res.assertJson({ title: "Hello" });
|
|
958
|
+
});
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
See [HTTP Tests](/docs/testing/http) for the full client and assertion surface.
|
|
962
|
+
|
|
963
|
+
## References
|
|
964
|
+
|
|
965
|
+
### Router methods
|
|
966
|
+
|
|
967
|
+
| Method | Signature | Description |
|
|
968
|
+
| -------------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
969
|
+
| `Router.get` / `post` / `put` / `patch` / `delete` | `(path, Controller, action, mw?)` or `(path, handler, mw?)` | Register a route for an HTTP verb (controller action or closure handler). Returns a `RouteRegistration`. |
|
|
970
|
+
| `Router.resource` | `(name, Controller, mw?): ResourceRouteBuilder` | Register the seven RESTful routes; chain `.only()` / `.except()`. |
|
|
971
|
+
| `Router.view` | `(path, Component, props?, mw?): ViewRegistration` | Render a JSX view with no controller. |
|
|
972
|
+
| `Router.static` | `(prefix, dir, options?)` | Serve a directory of static files. |
|
|
973
|
+
| `Router.markdown` | `(prefix, dir, options?)` | Serve `.md` files as rendered HTML. |
|
|
974
|
+
| `Router.raw` | `(method, path, handler)` | Handle the raw `Request`, bypassing the middleware pipeline. |
|
|
975
|
+
| `Router.flow` | `(path, Page, mw?)` | Register a Flow page (added by `@zerotal/flow`). |
|
|
976
|
+
| `Router.group` | `(options, fn)` | Share a `prefix`, `middleware`, and/or `domain` across routes. |
|
|
977
|
+
| `Router.middlewareGroup` | `(name, [...])` | Define a reusable, named middleware stack. |
|
|
978
|
+
|
|
979
|
+
### Registration chaining
|
|
980
|
+
|
|
981
|
+
Every route registration returns a chainable handle:
|
|
982
|
+
|
|
983
|
+
| Chain | Signature | Effect |
|
|
984
|
+
| --------------------------------- | --------------------------------------------- | ---------------------------------------------------------- |
|
|
985
|
+
| `.name(name)` | `(name): RouteRegistration` | Name the route for `route()` URL generation. |
|
|
986
|
+
| `.bind(param, target)` | `(param, modelOrResolver): RouteRegistration` | Per-route model binding; overrides the global one. |
|
|
987
|
+
| `.withLayout(layout)` | `(layout): ViewRegistration` | Wrap a view route's output in a layout (view routes only). |
|
|
988
|
+
| `.only([...])` / `.except([...])` | `(actions): this` | Limit which resource actions are registered. |
|
|
989
|
+
|
|
990
|
+
### URLs, inspection and CLI
|
|
991
|
+
|
|
992
|
+
| API | Signature | Description |
|
|
993
|
+
| -------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------- |
|
|
994
|
+
| `route(name, params?, query?)` | `(name, params?, query?): string` | Build a URL from a named route. Params are exact; query values go last. |
|
|
995
|
+
| `route.dynamic(name, params?, query?)` | `(name, params?, query?): string` | The same, for a name only known at runtime — no compile-time checking. |
|
|
996
|
+
| `Router.routes` / `Router.namedRoutes` | `ReadonlyMap` | Read the registered route and name maps. |
|
|
997
|
+
| `Router.middlewareFor(method, path)` | `(method, path): MiddlewareClass[]` | List the middleware attached to a route. |
|
|
998
|
+
| `HttpContext.fake(url?, init?)` | `(url?, init?): HttpContext` | Build a fake context for unit tests. |
|
|
999
|
+
| `bun zt route:list` | — | Print every route (`-m` method, `-p` path, `--name`, `-v` middleware). |
|
|
1000
|
+
| `bun zt route:types` | `--check` | Write `types/routes.generated.ts`; `--check` fails when it is stale. |
|
|
1001
|
+
|
|
1002
|
+
## Next steps
|
|
1003
|
+
|
|
1004
|
+
- [Controllers](/docs/controllers) — move route logic out of closures.
|
|
1005
|
+
- [Middleware](/docs/middleware) — protect and transform requests.
|
|
1006
|
+
- [HttpContext](/docs/context) — the request/response object actions receive.
|
|
1007
|
+
- [Multi-tenancy](/docs/tenancy) — resolve a tenant from the subdomain a group scopes to.
|
|
1008
|
+
- [HTTP Tests](/docs/testing/http) — boot the real app and assert over requests.
|