@shaferllc/keel 0.59.0 → 0.68.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/AGENTS.md +167 -0
- package/README.md +30 -1
- package/bin/keel-mcp.mjs +9 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +14 -2
- package/dist/core/auth.d.ts +47 -0
- package/dist/core/auth.js +77 -0
- package/dist/core/authorization.d.ts +9 -1
- package/dist/core/authorization.js +22 -2
- package/dist/core/cache.d.ts +82 -5
- package/dist/core/cache.js +181 -23
- package/dist/core/cli/stubs.d.ts +12 -0
- package/dist/core/cli/stubs.js +120 -0
- package/dist/core/container.d.ts +20 -0
- package/dist/core/container.js +52 -0
- package/dist/core/cors.d.ts +29 -0
- package/dist/core/cors.js +72 -0
- package/dist/core/crypto.d.ts +40 -4
- package/dist/core/crypto.js +66 -6
- package/dist/core/csrf.d.ts +25 -0
- package/dist/core/csrf.js +78 -0
- package/dist/core/database.d.ts +49 -4
- package/dist/core/database.js +89 -21
- package/dist/core/events.d.ts +129 -5
- package/dist/core/events.js +165 -7
- package/dist/core/health.d.ts +141 -0
- package/dist/core/health.js +226 -0
- package/dist/core/helpers.d.ts +15 -3
- package/dist/core/helpers.js +23 -3
- package/dist/core/index.d.ts +33 -18
- package/dist/core/index.js +16 -8
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +82 -4
- package/dist/core/logger.js +141 -23
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/model.d.ts +2 -0
- package/dist/core/model.js +16 -14
- package/dist/core/provider.d.ts +7 -0
- package/dist/core/provider.js +7 -0
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +304 -14
- package/dist/core/rate-limit.js +3 -0
- package/dist/core/relations.js +13 -13
- package/dist/core/request.d.ts +26 -0
- package/dist/core/request.js +77 -0
- package/dist/core/shield.d.ts +39 -0
- package/dist/core/shield.js +60 -0
- package/dist/core/social.d.ts +173 -0
- package/dist/core/social.js +337 -0
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +287 -7
- package/dist/core/tokens.d.ts +74 -0
- package/dist/core/tokens.js +155 -0
- package/dist/db/d1.d.ts +32 -0
- package/dist/db/d1.js +26 -0
- package/dist/db/libsql.d.ts +29 -0
- package/dist/db/libsql.js +32 -0
- package/dist/db/pg.d.ts +29 -0
- package/dist/db/pg.js +33 -0
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/docs/ai-manifest.json +2472 -0
- package/docs/ai.md +128 -0
- package/docs/architecture.md +331 -0
- package/docs/authentication.md +453 -0
- package/docs/authorization.md +167 -0
- package/docs/broadcasting.md +137 -0
- package/docs/broker.md +500 -0
- package/docs/cache.md +558 -0
- package/docs/configuration.md +311 -0
- package/docs/console.md +356 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +530 -0
- package/docs/debugging.md +129 -0
- package/docs/decorators.md +127 -0
- package/docs/errors.md +395 -0
- package/docs/events.md +496 -0
- package/docs/examples/architecture-app.ts +27 -0
- package/docs/examples/authentication.ts +61 -0
- package/docs/examples/authorization.ts +79 -0
- package/docs/examples/broadcasting.ts +60 -0
- package/docs/examples/broker-cache-validate.ts +34 -0
- package/docs/examples/broker-fault-tolerance.ts +29 -0
- package/docs/examples/broker-middleware.ts +27 -0
- package/docs/examples/broker.ts +203 -0
- package/docs/examples/cache.ts +222 -0
- package/docs/examples/configuration.ts +81 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +118 -0
- package/docs/examples/debugging.ts +41 -0
- package/docs/examples/decorators.ts +40 -0
- package/docs/examples/errors.ts +121 -0
- package/docs/examples/events.ts +204 -0
- package/docs/examples/factories.ts +84 -0
- package/docs/examples/hashing.ts +71 -0
- package/docs/examples/health.ts +94 -0
- package/docs/examples/helpers.ts +171 -0
- package/docs/examples/hooks.ts +54 -0
- package/docs/examples/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +92 -0
- package/docs/examples/mail.ts +160 -0
- package/docs/examples/middleware.ts +119 -0
- package/docs/examples/migrations.ts +126 -0
- package/docs/examples/models.ts +239 -0
- package/docs/examples/notification.ts +124 -0
- package/docs/examples/providers.ts +123 -0
- package/docs/examples/queues.ts +254 -0
- package/docs/examples/rate-limiting.ts +42 -0
- package/docs/examples/redis.ts +99 -0
- package/docs/examples/request-response.ts +197 -0
- package/docs/examples/routing.ts +186 -0
- package/docs/examples/scheduling.ts +62 -0
- package/docs/examples/sessions.ts +102 -0
- package/docs/examples/static-files.ts +63 -0
- package/docs/examples/storage.ts +132 -0
- package/docs/examples/templates.ts +58 -0
- package/docs/examples/testing.ts +66 -0
- package/docs/examples/transformer.ts +141 -0
- package/docs/examples/transformers.ts +49 -0
- package/docs/examples/url-builder.ts +86 -0
- package/docs/examples/validation.ts +102 -0
- package/docs/examples/views.tsx +62 -0
- package/docs/examples/vite.ts +106 -0
- package/docs/factories.md +166 -0
- package/docs/getting-started.md +290 -0
- package/docs/hashing.md +259 -0
- package/docs/health.md +225 -0
- package/docs/helpers.md +347 -0
- package/docs/hono.md +186 -0
- package/docs/hooks.md +118 -0
- package/docs/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +290 -0
- package/docs/mail.md +678 -0
- package/docs/middleware.md +425 -0
- package/docs/migrations.md +476 -0
- package/docs/models.md +810 -0
- package/docs/notifications.md +474 -0
- package/docs/providers.md +363 -0
- package/docs/queues.md +679 -0
- package/docs/rate-limiting.md +155 -0
- package/docs/redis.md +178 -0
- package/docs/request-response.md +953 -0
- package/docs/routing.md +804 -0
- package/docs/scheduling.md +110 -0
- package/docs/security.md +85 -0
- package/docs/sessions.md +354 -0
- package/docs/social-auth.md +174 -0
- package/docs/static-files.md +211 -0
- package/docs/storage.md +450 -0
- package/docs/templates.md +315 -0
- package/docs/testing.md +125 -0
- package/docs/transformers.md +381 -0
- package/docs/url-builder.md +295 -0
- package/docs/validation.md +288 -0
- package/docs/views.md +267 -0
- package/docs/vite.md +434 -0
- package/llms-full.txt +17694 -0
- package/llms.txt +116 -0
- package/package.json +38 -7
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Request Decorators
|
|
2
|
+
|
|
3
|
+
Attach named, computed values to the current request — `request.user`,
|
|
4
|
+
`request.tenant`, `request.locale` — resolved **lazily** and **memoized for the
|
|
5
|
+
life of the request**. Inspired by [Fastify's decorators](https://fastify.dev/docs/latest/Reference/Decorators/),
|
|
6
|
+
but without the footguns: you register a resolver once, and Keel computes it on
|
|
7
|
+
first access and caches it per request. No null-placeholder declaration, no
|
|
8
|
+
`onRequest` hook to remember, no shared-state leak between requests.
|
|
9
|
+
|
|
10
|
+
> Decorating the **application** is already the [service container's](./container.md)
|
|
11
|
+
> job — `bind` / `singleton` / `instance` / `make`, with `bound()` as
|
|
12
|
+
> `hasDecorator`. Decorators here are the per-*request* counterpart.
|
|
13
|
+
|
|
14
|
+
## Registering
|
|
15
|
+
|
|
16
|
+
Register decorators once at boot (typically in a service provider). A resolver
|
|
17
|
+
receives the request context and returns a value — sync or async:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { decorateRequest } from "@shaferllc/keel/core";
|
|
21
|
+
|
|
22
|
+
decorateRequest("locale", (c) => c.req.header("accept-language") ?? "en");
|
|
23
|
+
decorateRequest("user", async (c) => findUser(c.req.header("authorization")));
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Accessing
|
|
27
|
+
|
|
28
|
+
Read a decorator anywhere in the request with `decorated()`. It runs the resolver
|
|
29
|
+
on first access and caches the result for the rest of that request:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { decorated } from "@shaferllc/keel/core";
|
|
33
|
+
|
|
34
|
+
const locale = await decorated<string>("locale");
|
|
35
|
+
const user = await decorated<User | null>("user"); // computed once, then cached
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`decorated()` always returns a promise (resolvers may be async). A second request
|
|
39
|
+
starts with a fresh cache — nothing leaks between requests.
|
|
40
|
+
|
|
41
|
+
## Setting a value directly
|
|
42
|
+
|
|
43
|
+
When something upstream already resolved a value — say an auth middleware — set it
|
|
44
|
+
imperatively so downstream `decorated()` calls skip the resolver:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { setRequestValue, decorated } from "@shaferllc/keel/core";
|
|
48
|
+
|
|
49
|
+
// in middleware, after verifying the session:
|
|
50
|
+
setRequestValue("user", theAuthenticatedUser);
|
|
51
|
+
|
|
52
|
+
// later, in a controller:
|
|
53
|
+
const user = await decorated("user"); // returns the value set above, no re-lookup
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This is the clean version of Fastify's "declare a placeholder, set it in an
|
|
57
|
+
`onRequest` hook" pattern.
|
|
58
|
+
|
|
59
|
+
## Why lazy + memoized
|
|
60
|
+
|
|
61
|
+
Resolving the current user (or tenant, or locale, or a feature-flag set) is the
|
|
62
|
+
kind of thing every handler needs but nothing should compute twice. Registering a
|
|
63
|
+
resolver once and letting the framework memoize it per request means:
|
|
64
|
+
|
|
65
|
+
- handlers that never touch `request.user` never pay for the lookup;
|
|
66
|
+
- handlers that touch it repeatedly pay exactly once;
|
|
67
|
+
- there's no per-request wiring to forget.
|
|
68
|
+
|
|
69
|
+
## API reference
|
|
70
|
+
|
|
71
|
+
### `decorateRequest(name, resolver)`
|
|
72
|
+
|
|
73
|
+
`decorateRequest<T>(name: string, resolver: (c: Context) => T | Promise<T>): void`
|
|
74
|
+
|
|
75
|
+
Registers a request decorator. The resolver is called at most once per request,
|
|
76
|
+
on first access.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
decorateRequest("tenant", (c) => c.req.header("x-tenant") ?? "public");
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Notes:** throws if `name` is already registered (a collision guard). Register at
|
|
83
|
+
boot, not per request.
|
|
84
|
+
|
|
85
|
+
### `decorated(name)`
|
|
86
|
+
|
|
87
|
+
`decorated<T>(name: string): Promise<T>`
|
|
88
|
+
|
|
89
|
+
The memoized value of a decorator for the current request.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const tenant = await decorated<string>("tenant");
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Notes:** computes via the resolver on first access, caches for the rest of the
|
|
96
|
+
request. Throws if `name` was never registered. Must run inside a request (it
|
|
97
|
+
reads the current context).
|
|
98
|
+
|
|
99
|
+
### `setRequestValue(name, value)`
|
|
100
|
+
|
|
101
|
+
`setRequestValue<T>(name: string, value: T): void`
|
|
102
|
+
|
|
103
|
+
Sets a decorator's value for the current request, overriding the resolver.
|
|
104
|
+
|
|
105
|
+
**Notes:** later `decorated(name)` calls return this value without invoking the
|
|
106
|
+
resolver. Useful from middleware.
|
|
107
|
+
|
|
108
|
+
### `hasRequestDecorator(name)`
|
|
109
|
+
|
|
110
|
+
`hasRequestDecorator(name: string): boolean`
|
|
111
|
+
|
|
112
|
+
Whether a decorator has been registered.
|
|
113
|
+
|
|
114
|
+
### `clearRequestDecorators()`
|
|
115
|
+
|
|
116
|
+
`clearRequestDecorators(): void`
|
|
117
|
+
|
|
118
|
+
Unregisters all decorators — a test helper.
|
|
119
|
+
|
|
120
|
+
### Interfaces & types
|
|
121
|
+
|
|
122
|
+
#### `RequestResolver`
|
|
123
|
+
|
|
124
|
+
`type RequestResolver<T> = (c: Context) => T | Promise<T>`
|
|
125
|
+
|
|
126
|
+
The function registered with `decorateRequest`; receives the Hono `Context` and
|
|
127
|
+
returns the value (sync or async).
|
package/docs/errors.md
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
# Errors & Exceptions
|
|
2
|
+
|
|
3
|
+
Throw an exception anywhere — a handler, middleware, or a service deep in the
|
|
4
|
+
container — and Keel's HTTP kernel turns it into the right response. No
|
|
5
|
+
try/catch in every controller.
|
|
6
|
+
|
|
7
|
+
## HTTP exceptions
|
|
8
|
+
|
|
9
|
+
`HttpException` carries a status code and message. Throw it (or one of its
|
|
10
|
+
subclasses) to short-circuit a request with a specific status:
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import {
|
|
14
|
+
HttpException,
|
|
15
|
+
NotFoundException,
|
|
16
|
+
UnauthorizedException,
|
|
17
|
+
ForbiddenException,
|
|
18
|
+
} from "@shaferllc/keel/core";
|
|
19
|
+
|
|
20
|
+
throw new NotFoundException("Widget not found"); // 404
|
|
21
|
+
throw new UnauthorizedException(); // 401
|
|
22
|
+
throw new ForbiddenException(); // 403
|
|
23
|
+
throw new HttpException(429, "Slow down"); // any status
|
|
24
|
+
throw new HttpException(503, "Down for maintenance", { "Retry-After": "120" });
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### The full HTTP error family
|
|
28
|
+
|
|
29
|
+
Every common HTTP status has a named subclass with a fixed `status` and a stable
|
|
30
|
+
machine `code`. Each takes an optional message and an optional `data` bag (see
|
|
31
|
+
[below](#attaching-structured-data)):
|
|
32
|
+
|
|
33
|
+
| Class | Status | `code` |
|
|
34
|
+
|-------|--------|--------|
|
|
35
|
+
| `BadRequestException` | 400 | `E_BAD_REQUEST` |
|
|
36
|
+
| `UnauthorizedException` | 401 | `E_UNAUTHORIZED` |
|
|
37
|
+
| `PaymentRequiredException` | 402 | `E_PAYMENT_REQUIRED` |
|
|
38
|
+
| `ForbiddenException` | 403 | `E_FORBIDDEN` |
|
|
39
|
+
| `NotFoundException` | 404 | `E_NOT_FOUND` |
|
|
40
|
+
| `MethodNotAllowedException` | 405 | `E_METHOD_NOT_ALLOWED` |
|
|
41
|
+
| `NotAcceptableException` | 406 | `E_NOT_ACCEPTABLE` |
|
|
42
|
+
| `RequestTimeoutException` | 408 | `E_REQUEST_TIMEOUT` |
|
|
43
|
+
| `ConflictException` | 409 | `E_CONFLICT` |
|
|
44
|
+
| `LengthRequiredException` | 411 | `E_LENGTH_REQUIRED` |
|
|
45
|
+
| `ValidationException` | 422 | `E_VALIDATION` |
|
|
46
|
+
| `TooManyRequestsException` | 429 | `E_TOO_MANY_REQUESTS` |
|
|
47
|
+
| `ServerErrorException` | 500 | `E_SERVER_ERROR` |
|
|
48
|
+
| `NotImplementedException` | 501 | `E_NOT_IMPLEMENTED` |
|
|
49
|
+
| `BadGatewayException` | 502 | `E_BAD_GATEWAY` |
|
|
50
|
+
| `ServiceUnavailableException` | 503 | `E_SERVICE_UNAVAILABLE` |
|
|
51
|
+
|
|
52
|
+
`ValidationException` is special — it takes a per-field error map first (see
|
|
53
|
+
[Validation errors](#validation-errors)); the rest take `(message?, data?)`.
|
|
54
|
+
|
|
55
|
+
### Attaching structured data
|
|
56
|
+
|
|
57
|
+
Any exception can carry a `data` bag that lands in the JSON body under `data`:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
throw new ConflictException("Email already registered", { email: "a@b.com" });
|
|
61
|
+
// -> 409 { "error": "Email already registered", "status": 409,
|
|
62
|
+
// "code": "E_CONFLICT", "data": { "email": "a@b.com" } }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Every exception also has a `toJSON()` that returns exactly this body shape
|
|
66
|
+
(`{ error, status, code?, data? }`, plus `errors` for `ValidationException`), so
|
|
67
|
+
you can serialize one yourself outside the HTTP kernel — logging, a queue payload,
|
|
68
|
+
a websocket frame.
|
|
69
|
+
|
|
70
|
+
A controller that always throws can be typed `: never`:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
show(c: Ctx): never {
|
|
74
|
+
throw new NotFoundException();
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The third `headers` argument is emitted on the response — handy for a `503` with
|
|
79
|
+
`Retry-After`, or a `429` with rate-limit headers:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
throw new HttpException(429, "Slow down", { "Retry-After": "30" });
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
For terse, inline guards you don't need to construct an exception at all — the
|
|
86
|
+
[request](./request-response.md) object's `abort`, `abortIf`, and `abortUnless`
|
|
87
|
+
helpers throw a plain `HttpException` for you:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
request.abortUnless(user.isAdmin, "Forbidden", 403);
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## How responses are rendered
|
|
94
|
+
|
|
95
|
+
The kernel negotiates the response by `Accept` and by your `app.debug` config:
|
|
96
|
+
|
|
97
|
+
| Situation | Response |
|
|
98
|
+
|-----------|----------|
|
|
99
|
+
| Client accepts JSON | `{ "error": "...", "status": 404 }` |
|
|
100
|
+
| Client accepts HTML | A rendered error page |
|
|
101
|
+
| `app.debug = true`, unexpected error | Full message + **stack trace** (page + JSON) |
|
|
102
|
+
| `app.debug = false`, unexpected 500 | Generic `Internal Server Error`, internals hidden |
|
|
103
|
+
| Thrown `HttpException` | Its status + message (shown even in production) |
|
|
104
|
+
|
|
105
|
+
Unexpected errors (anything that isn't an `HttpException`) become `500`. In
|
|
106
|
+
production their message and stack are hidden so you never leak internals; the
|
|
107
|
+
intentional message on an `HttpException` is always shown. A subclass `code` and
|
|
108
|
+
any `data` bag are added to the JSON body (`{ error, status, code, data }`), and
|
|
109
|
+
any `headers` you passed are set on the response.
|
|
110
|
+
|
|
111
|
+
The title on both the JSON and HTML paths comes from
|
|
112
|
+
[`STATUS_TEXT`](#status_text) — the kernel looks the status up there (`STATUS_TEXT[status] ?? "Error"`),
|
|
113
|
+
so a custom status still gets a sensible label as long as it's in the map.
|
|
114
|
+
|
|
115
|
+
## Unmatched routes
|
|
116
|
+
|
|
117
|
+
Any request that doesn't match a route is turned into a `404` automatically —
|
|
118
|
+
same rendering as a thrown `NotFoundException`:
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
GET /does-not-exist → 404 { "error": "No route for GET /does-not-exist", "status": 404, "code": "E_NOT_FOUND" }
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## The debug error page
|
|
125
|
+
|
|
126
|
+
When `app.debug` is on and the client is a browser, the kernel renders a
|
|
127
|
+
readable error page with the status, message, request line, and a formatted
|
|
128
|
+
stack trace — so you see what broke without digging through logs. Turn debug off
|
|
129
|
+
(via `APP_DEBUG=false`) in production.
|
|
130
|
+
|
|
131
|
+
## Validation errors
|
|
132
|
+
|
|
133
|
+
`ValidationException` is a `422` that carries per-field messages, which appear in
|
|
134
|
+
the JSON body under `errors`:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { ValidationException } from "@shaferllc/keel/core";
|
|
138
|
+
|
|
139
|
+
throw new ValidationException({ email: ["The email is invalid."] });
|
|
140
|
+
// -> 422 { "error": "The given data was invalid.", "status": 422,
|
|
141
|
+
// "code": "E_VALIDATION", "errors": { "email": ["The email is invalid."] } }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Custom exceptions
|
|
145
|
+
|
|
146
|
+
Extend `HttpException` to model your domain errors. Add a `code` (surfaced in the
|
|
147
|
+
JSON body), and optionally make the exception render or report itself:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { HttpException } from "@shaferllc/keel/core";
|
|
151
|
+
import type { Context } from "hono";
|
|
152
|
+
|
|
153
|
+
export class PaymentRequiredException extends HttpException {
|
|
154
|
+
code = "E_PAYMENT_REQUIRED";
|
|
155
|
+
|
|
156
|
+
constructor() {
|
|
157
|
+
super(402, "Payment is required to continue.");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Optional: render this exception itself.
|
|
161
|
+
handle(c: Context) {
|
|
162
|
+
return c.json({ error: this.message, code: this.code, upgrade: "/billing" }, this.status);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Optional: called before rendering — log/report it.
|
|
166
|
+
report() {
|
|
167
|
+
metrics.increment("payment_required");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
throw new PaymentRequiredException();
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- **`code`** → added to the JSON error body (`{ error, status, code }`).
|
|
175
|
+
- **`handle(c)`** → if it returns a `Response`, the kernel uses it verbatim. If it
|
|
176
|
+
returns anything else, the kernel falls back to the default rendering.
|
|
177
|
+
- **`report()`** → always called (and awaited) first; failures there never mask
|
|
178
|
+
the original error.
|
|
179
|
+
|
|
180
|
+
Both hooks are duck-typed, not tied to a base class: the kernel calls any thrown
|
|
181
|
+
value that happens to have a `report` and/or `handle` method. The built-in
|
|
182
|
+
subclasses don't define either — they render through the default path.
|
|
183
|
+
|
|
184
|
+
## Coded errors with `createError`
|
|
185
|
+
|
|
186
|
+
When all you want is a coded error class — a stable `code`, a message, a status —
|
|
187
|
+
skip the boilerplate and mint one with `createError`. It's the ergonomic shortcut
|
|
188
|
+
for the common case, inspired by Fastify's `@fastify/error`:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
import { createError } from "@shaferllc/keel/core";
|
|
192
|
+
|
|
193
|
+
const InsufficientFunds = createError("E_FUNDS", "Balance too low: need %s", 402);
|
|
194
|
+
|
|
195
|
+
throw new InsufficientFunds("$40");
|
|
196
|
+
// -> 402 { "error": "Balance too low: need $40", "status": 402, "code": "E_FUNDS" }
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`%s` placeholders in the message are filled, in order, from the constructor
|
|
200
|
+
arguments. The result is a real `HttpException` subclass, so it renders through
|
|
201
|
+
the same path (the `code` lands in the JSON body) and passes
|
|
202
|
+
`instanceof HttpException`. Define your app's errors once and throw them anywhere:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
export const TenantSuspended = createError("E_TENANT_SUSPENDED", "Tenant %s is suspended.", 403);
|
|
206
|
+
export const RateExceeded = createError("E_RATE", "Slow down.", 429);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The **built-in** exceptions carry stable codes too, so `code` shows up without
|
|
210
|
+
any work: `NotFoundException` → `E_NOT_FOUND`, `UnauthorizedException` →
|
|
211
|
+
`E_UNAUTHORIZED`, `ForbiddenException` → `E_FORBIDDEN`, `ValidationException` →
|
|
212
|
+
`E_VALIDATION`.
|
|
213
|
+
|
|
214
|
+
Reach for a hand-written subclass (above) only when you need behavior — a
|
|
215
|
+
`handle(c)` renderer or a `report()` hook. For a plain coded error, `createError`
|
|
216
|
+
is all you need.
|
|
217
|
+
|
|
218
|
+
## Customizing the handler
|
|
219
|
+
|
|
220
|
+
Override the whole thing from your app's HTTP kernel with `onError()`:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
// app/Http/Kernel.ts
|
|
224
|
+
export class Kernel extends HttpKernel {
|
|
225
|
+
constructor(app: Application) {
|
|
226
|
+
super(app);
|
|
227
|
+
this.onError((err, c) => {
|
|
228
|
+
// report to your logging service, then render however you like
|
|
229
|
+
return c.json({ oops: true }, 500);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Or override the protected `renderException(err, c)` method to keep the default
|
|
236
|
+
routing but change the presentation.
|
|
237
|
+
|
|
238
|
+
> A registered `onError` handler takes precedence over an exception's own
|
|
239
|
+
> `handle(c)` — the custom handler wins, and self-handling is skipped. `report()`
|
|
240
|
+
> still runs first, either way.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## API reference
|
|
245
|
+
|
|
246
|
+
Every exception below is a subclass of `HttpException`, which extends the native
|
|
247
|
+
`Error`. You throw them; you never catch them yourself — the kernel does. All are
|
|
248
|
+
exported from `@shaferllc/keel/core`.
|
|
249
|
+
|
|
250
|
+
### `HttpException`
|
|
251
|
+
|
|
252
|
+
The base semantic HTTP error: a status code, a message, and optional response
|
|
253
|
+
headers. Throw it directly for any status that doesn't have a dedicated subclass.
|
|
254
|
+
|
|
255
|
+
#### `new HttpException(status, message?, headers?)`
|
|
256
|
+
|
|
257
|
+
`new HttpException(status: number, message?: string, headers?: Record<string, string>): HttpException`
|
|
258
|
+
|
|
259
|
+
Constructs an error carrying `status` and `message`. Omit `message` to fall back
|
|
260
|
+
to the status text.
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
throw new HttpException(409, "That email is taken");
|
|
264
|
+
throw new HttpException(503, "Down for maintenance", { "Retry-After": "120" });
|
|
265
|
+
throw new HttpException(429); // message defaults to "Too Many Requests"
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**Notes:** the message defaults to `STATUS_TEXT[status]`, then `"Error"` if the
|
|
269
|
+
status isn't in the map. Exposes three readonly-ish fields the kernel reads:
|
|
270
|
+
`status` (number), `headers` (optional, emitted on the response), and `code`
|
|
271
|
+
(optional `string`, added to the JSON body when set). It also sets `name` to
|
|
272
|
+
`"HttpException"`. There's no built-in `handle`/`report` — add those on a subclass
|
|
273
|
+
to self-render or self-report (see [Custom exceptions](#custom-exceptions)).
|
|
274
|
+
|
|
275
|
+
### `NotFoundException`
|
|
276
|
+
|
|
277
|
+
A `404`. Thrown automatically for unmatched routes, and by `Model.findOrFail`.
|
|
278
|
+
|
|
279
|
+
#### `new NotFoundException(message?)`
|
|
280
|
+
|
|
281
|
+
`new NotFoundException(message?: string): NotFoundException`
|
|
282
|
+
|
|
283
|
+
Constructs a `404`. Message defaults to `"Not Found"`.
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
throw new NotFoundException(); // "Not Found"
|
|
287
|
+
throw new NotFoundException("Widget 42 not found");
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
**Notes:** `status` is fixed at `404`; `name` is `"NotFoundException"`. The kernel
|
|
291
|
+
also throws this for any request that matches no route.
|
|
292
|
+
|
|
293
|
+
### `UnauthorizedException`
|
|
294
|
+
|
|
295
|
+
A `401` — the request isn't authenticated. Reach for it when there's no valid
|
|
296
|
+
session or credentials; use `ForbiddenException` when the user is known but not
|
|
297
|
+
allowed.
|
|
298
|
+
|
|
299
|
+
#### `new UnauthorizedException(message?)`
|
|
300
|
+
|
|
301
|
+
`new UnauthorizedException(message?: string): UnauthorizedException`
|
|
302
|
+
|
|
303
|
+
Constructs a `401`. Message defaults to `"Unauthorized"`.
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
throw new UnauthorizedException();
|
|
307
|
+
throw new UnauthorizedException("Session expired");
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
**Notes:** `status` is fixed at `401`; `name` is `"UnauthorizedException"`.
|
|
311
|
+
|
|
312
|
+
### `ForbiddenException`
|
|
313
|
+
|
|
314
|
+
A `403` — the request is authenticated but not permitted.
|
|
315
|
+
|
|
316
|
+
#### `new ForbiddenException(message?)`
|
|
317
|
+
|
|
318
|
+
`new ForbiddenException(message?: string): ForbiddenException`
|
|
319
|
+
|
|
320
|
+
Constructs a `403`. Message defaults to `"Forbidden"`.
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
throw new ForbiddenException();
|
|
324
|
+
throw new ForbiddenException("You can't edit this post");
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
**Notes:** `status` is fixed at `403`; `name` is `"ForbiddenException"`.
|
|
328
|
+
|
|
329
|
+
### `ValidationException`
|
|
330
|
+
|
|
331
|
+
A `422` carrying per-field error messages. The kernel adds them to the JSON body
|
|
332
|
+
under `errors`.
|
|
333
|
+
|
|
334
|
+
#### `new ValidationException(errors, message?)`
|
|
335
|
+
|
|
336
|
+
`new ValidationException(errors: Record<string, string[]>, message?: string): ValidationException`
|
|
337
|
+
|
|
338
|
+
Constructs a `422` from a map of field name → messages.
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
throw new ValidationException({
|
|
342
|
+
email: ["The email is invalid."],
|
|
343
|
+
password: ["Too short.", "Must contain a number."],
|
|
344
|
+
});
|
|
345
|
+
// -> 422 { "error": "The given data was invalid.", "status": 422,
|
|
346
|
+
// "code": "E_VALIDATION", "errors": { "email": [...], "password": [...] } }
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
**Notes:** `status` is fixed at `422`; `name` is `"ValidationException"`. Message
|
|
350
|
+
defaults to `"The given data was invalid."`. The field map is exposed as the
|
|
351
|
+
readonly `errors` property, which the kernel serializes into the response body.
|
|
352
|
+
Keel's [`validate()`](./validation.md) helper throws this for you on a failed
|
|
353
|
+
parse. `code` is `"E_VALIDATION"`.
|
|
354
|
+
|
|
355
|
+
### `createError(code, message, status?)`
|
|
356
|
+
|
|
357
|
+
`createError(code: string, message: string, status?: number): new (...args: (string | number)[]) => HttpException`
|
|
358
|
+
|
|
359
|
+
Mints a reusable, coded `HttpException` subclass. `message` may contain `%s`
|
|
360
|
+
placeholders, filled in order from the constructor arguments; `status` defaults
|
|
361
|
+
to `500`.
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
const InsufficientFunds = createError("E_FUNDS", "Balance too low: need %s", 402);
|
|
365
|
+
throw new InsufficientFunds("$40");
|
|
366
|
+
// -> 402 { "error": "Balance too low: need $40", "status": 402, "code": "E_FUNDS" }
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
**Notes:** the returned class extends `HttpException`, so it renders through the
|
|
370
|
+
default path (with `code` in the JSON body) and passes `instanceof HttpException`;
|
|
371
|
+
its `name` is the `code`. A missing argument leaves its `%s` in place. For an
|
|
372
|
+
error that needs a custom `handle(c)`/`report()`, subclass `HttpException`
|
|
373
|
+
directly instead.
|
|
374
|
+
|
|
375
|
+
### Constants
|
|
376
|
+
|
|
377
|
+
#### `STATUS_TEXT`
|
|
378
|
+
|
|
379
|
+
`const STATUS_TEXT: Record<number, string>`
|
|
380
|
+
|
|
381
|
+
Maps HTTP status codes to their reason phrases. Used to title error pages/bodies
|
|
382
|
+
and to supply the default message for `HttpException`.
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import { STATUS_TEXT } from "@shaferllc/keel/core";
|
|
386
|
+
|
|
387
|
+
STATUS_TEXT[404]; // "Not Found"
|
|
388
|
+
STATUS_TEXT[419]; // "Page Expired"
|
|
389
|
+
STATUS_TEXT[418] ?? "Error"; // not in the map
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
**Notes:** covers the statuses Keel uses (`400`, `401`, `403`, `404`, `405`,
|
|
393
|
+
`409`, `419`, `422`, `429`, `500`, `503`). Lookups for anything else are
|
|
394
|
+
`undefined`, which the kernel falls back to `"Error"` for. It's a plain mutable
|
|
395
|
+
object — you can add entries for custom statuses so they render with a label.
|