@orieljs/framework 0.1.0-alpha.5 → 0.1.0-alpha.7
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/NOTICE +7 -0
- package/README.md +10 -1
- package/TRADEMARKS.md +33 -0
- package/dist/{chunk-WNJP46B7.js → chunk-4MWG3B7G.js} +1785 -626
- package/dist/chunk-4MWG3B7G.js.map +1 -0
- package/dist/chunk-7ZQZYDOC.js +3253 -0
- package/dist/chunk-7ZQZYDOC.js.map +1 -0
- package/dist/{chunk-BSQKOLSK.js → chunk-GVNLZZTN.js} +1 -1
- package/dist/chunk-GVNLZZTN.js.map +1 -0
- package/dist/{chunk-A5XRLEWM.js → chunk-VOYW3R5J.js} +4 -4
- package/dist/chunk-VOYW3R5J.js.map +1 -0
- package/dist/contracts.d.ts +535 -17
- package/dist/contracts.js +1 -1
- package/dist/index.d-WQpfdIbG.d.ts +630 -0
- package/dist/index.d.ts +2202 -5952
- package/dist/index.js +4900 -944
- package/dist/index.js.map +1 -1
- package/dist/{jsx-runtime-WKTl3EEw.d.ts → jsx-runtime-6um4Mwfb.d.ts} +97 -51
- package/dist/jsx-runtime.d.ts +2 -2
- package/dist/jsx-runtime.js +3 -3
- package/dist/{testing-BErengYY.d.ts → testing-CKwfWJGn.d.ts} +313 -994
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +19 -5
- package/docs/authentication.md +487 -0
- package/docs/authorization.md +435 -0
- package/docs/cache.md +647 -0
- package/docs/collections.md +2270 -0
- package/docs/configuration.md +334 -0
- package/docs/console.md +651 -0
- package/docs/container.md +430 -0
- package/docs/controllers.md +406 -0
- package/docs/csrf.md +126 -0
- package/docs/database-testing.md +144 -0
- package/docs/database.md +236 -0
- package/docs/deployment.md +276 -0
- package/docs/eloquent-collections.md +218 -0
- package/docs/eloquent-factories.md +255 -0
- package/docs/eloquent-mutators.md +254 -0
- package/docs/eloquent-observers.md +145 -0
- package/docs/eloquent-relationships.md +557 -0
- package/docs/eloquent.md +494 -0
- package/docs/encryption.md +104 -0
- package/docs/errors.md +318 -0
- package/docs/events.md +336 -0
- package/docs/facades.md +278 -0
- package/docs/hashing.md +118 -0
- package/docs/helpers.md +1281 -0
- package/docs/http-tests.md +386 -0
- package/docs/inertia.md +485 -0
- package/docs/installation.md +209 -0
- package/docs/lifecycle.md +234 -0
- package/docs/live-tests.md +226 -0
- package/docs/live.md +910 -0
- package/docs/logging.md +255 -0
- package/docs/mail.md +619 -0
- package/docs/middleware.md +384 -0
- package/docs/migrations.md +416 -0
- package/docs/mocking.md +264 -0
- package/docs/navigation.json +273 -0
- package/docs/notifications.md +225 -0
- package/docs/pagination.md +247 -0
- package/docs/passwords.md +207 -0
- package/docs/providers.md +187 -0
- package/docs/queries.md +600 -0
- package/docs/queues.md +330 -0
- package/docs/requests.md +533 -0
- package/docs/responses.md +444 -0
- package/docs/routing.md +738 -0
- package/docs/scheduling.md +277 -0
- package/docs/seeding.md +122 -0
- package/docs/session.md +409 -0
- package/docs/starter-kits.md +396 -0
- package/docs/storage.md +192 -0
- package/docs/structure.md +264 -0
- package/docs/testing.md +165 -0
- package/docs/time-travel.md +138 -0
- package/docs/validation.md +1792 -0
- package/docs/verification.md +152 -0
- package/docs/views.md +566 -0
- package/docs/vite.md +322 -0
- package/package.json +34 -32
- package/dist/chunk-A5XRLEWM.js.map +0 -1
- package/dist/chunk-BSQKOLSK.js.map +0 -1
- package/dist/chunk-PM5YTG57.js +0 -880
- package/dist/chunk-PM5YTG57.js.map +0 -1
- package/dist/chunk-WNJP46B7.js.map +0 -1
- package/dist/index.d-DOlTeG-8.d.ts +0 -99
package/docs/errors.md
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# Error handling
|
|
2
|
+
|
|
3
|
+
When a request ends in an error, Oriel logs it if it should and answers with an error page or JSON.
|
|
4
|
+
`withExceptions()` changes both.
|
|
5
|
+
|
|
6
|
+
## Introduction
|
|
7
|
+
|
|
8
|
+
A new app handles errors with no setup: an unexpected error that a route or a middleware throws is reported to your
|
|
9
|
+
log, and the user gets a response with the error's status. To change how errors are reported and rendered, give
|
|
10
|
+
`withExceptions()` in `bootstrap/app.ts` a function that receives the configurator, as the rest of this page shows.
|
|
11
|
+
|
|
12
|
+
## Configuration
|
|
13
|
+
|
|
14
|
+
`debug` in `config/app.ts` decides how much an error response shows. It reads `APP_DEBUG` from your `.env` (see [Debug
|
|
15
|
+
mode](configuration.md#debug-mode)), and is `false` unless the variable says otherwise:
|
|
16
|
+
|
|
17
|
+
```ts title="config/app.ts"
|
|
18
|
+
import { env } from '@orieljs/framework'
|
|
19
|
+
|
|
20
|
+
export default {
|
|
21
|
+
name: env('APP_NAME', 'Oriel'),
|
|
22
|
+
env: env('APP_ENV', 'production'),
|
|
23
|
+
debug: env('APP_DEBUG', false),
|
|
24
|
+
url: env('APP_URL', 'http://localhost'),
|
|
25
|
+
timezone: 'UTC',
|
|
26
|
+
locale: env('APP_LOCALE', 'en'),
|
|
27
|
+
cipher: 'AES-256-GCM',
|
|
28
|
+
key: env('APP_KEY'),
|
|
29
|
+
previous_keys: String(env('APP_PREVIOUS_KEYS', '')).split(',').filter(Boolean),
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A new app's `.env` sets `APP_DEBUG=true`, for your machine. With it, a server error shows the debug page: the error's
|
|
34
|
+
class and message, the request's method and URL, the lines of your code around where it was thrown, the stack, and
|
|
35
|
+
the errors that caused it. A request that expects JSON gets the message, the error's name, the file, the line and
|
|
36
|
+
the stack. Errors with an HTTP status, such as a 404, get their usual page either way.
|
|
37
|
+
|
|
38
|
+
> [!WARNING]
|
|
39
|
+
> In production, `APP_DEBUG` must be `false`. The debug page shows your code and your error messages, which can hold
|
|
40
|
+
> configuration values and other secrets, to anyone who triggers an error.
|
|
41
|
+
|
|
42
|
+
## Handling exceptions
|
|
43
|
+
|
|
44
|
+
### Reporting exceptions
|
|
45
|
+
|
|
46
|
+
Reporting an error writes it to your [log](logging.md), at the `error` level, with the error itself as the entry's
|
|
47
|
+
`exception` context. `report()` registers a function that receives each error to report, to send it somewhere else
|
|
48
|
+
too, such as an error tracking service:
|
|
49
|
+
|
|
50
|
+
```ts title="bootstrap/app.ts"
|
|
51
|
+
// @filename: app/Exceptions/InvalidOrderException.ts
|
|
52
|
+
export class InvalidOrderException extends Error {}
|
|
53
|
+
// ---cut---
|
|
54
|
+
import { dirname } from 'node:path'
|
|
55
|
+
import { Application } from '@orieljs/framework'
|
|
56
|
+
import { InvalidOrderException } from '../app/Exceptions/InvalidOrderException'
|
|
57
|
+
import providers from './providers'
|
|
58
|
+
|
|
59
|
+
export function createApplication(): Promise<Application> {
|
|
60
|
+
return Application.configure({ basePath: dirname(import.meta.dirname) })
|
|
61
|
+
.withRouting({ web: 'routes/web.ts', console: 'routes/console.ts', health: '/up' })
|
|
62
|
+
.withExceptions((exceptions) => {
|
|
63
|
+
exceptions.report((error) => {
|
|
64
|
+
if (error instanceof InvalidOrderException) {
|
|
65
|
+
// ...
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
.withProviders(providers)
|
|
70
|
+
.create()
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The error is still logged afterwards. Return `false` from the function to stop that, and the functions registered
|
|
75
|
+
after it:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
declare const exceptions: ExceptionsConfigurator
|
|
79
|
+
declare class InvalidOrderException extends Error {}
|
|
80
|
+
// ---cut---
|
|
81
|
+
exceptions.report((error) => {
|
|
82
|
+
if (error instanceof InvalidOrderException) {
|
|
83
|
+
// ...
|
|
84
|
+
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
> [!NOTE]
|
|
91
|
+
> Laravel calls a `report()` function only for the errors of the class its parameter type-hints. TypeScript types are
|
|
92
|
+
> gone when your app runs, so Oriel calls every function for every error it reports: check the error's class with
|
|
93
|
+
> `instanceof`, as above.
|
|
94
|
+
|
|
95
|
+
Each error is reported once, however many times it reaches the handler, as in Laravel after `dontReportDuplicates()`.
|
|
96
|
+
Laravel's global and per-exception log context, `context()`, isn't in Oriel; [Logging](logging.md) shows how to add
|
|
97
|
+
context to every log entry.
|
|
98
|
+
|
|
99
|
+
#### Reporting an error yourself
|
|
100
|
+
|
|
101
|
+
Oriel has no `report()` helper. To report an error and carry on with the request, call the exception handler's
|
|
102
|
+
`report()`:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
declare function validateTheValue(value: string): void
|
|
106
|
+
// ---cut---
|
|
107
|
+
async function isValid(value: string): Promise<boolean> {
|
|
108
|
+
try {
|
|
109
|
+
validateTheValue(value)
|
|
110
|
+
|
|
111
|
+
return true
|
|
112
|
+
} catch (error) {
|
|
113
|
+
await app().make(ExceptionHandler).report(error)
|
|
114
|
+
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Exception log levels
|
|
121
|
+
|
|
122
|
+
Every report is logged at the `error` level. Laravel's `level()`, which changes the level by the error's class, isn't
|
|
123
|
+
in Oriel: log the error yourself from a `report()` function, at the level you want, and return `false`.
|
|
124
|
+
|
|
125
|
+
### Ignoring exceptions by type
|
|
126
|
+
|
|
127
|
+
`dontReport()` takes the error classes never to report. They are still rendered as usual:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
declare const exceptions: ExceptionsConfigurator
|
|
131
|
+
declare class InvalidOrderException extends Error {}
|
|
132
|
+
// ---cut---
|
|
133
|
+
exceptions.dontReport(InvalidOrderException)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Oriel never reports the errors that are part of normal requests: an `abort()`, a missing route or model, a failed
|
|
137
|
+
validation, an unauthenticated or unauthorized user, and a CSRF token mismatch. Laravel's `dontReportWhen()`,
|
|
138
|
+
`stopIgnoring()` and `ShouldntReport` interface aren't in Oriel.
|
|
139
|
+
|
|
140
|
+
### Rendering exceptions
|
|
141
|
+
|
|
142
|
+
`render()` registers a function that turns an error into a response. It receives the error and the request, and
|
|
143
|
+
returns a `Response`, or nothing to leave the error to the default rendering:
|
|
144
|
+
|
|
145
|
+
```ts title="bootstrap/app.ts"
|
|
146
|
+
// @filename: app/Exceptions/InvalidOrderException.ts
|
|
147
|
+
export class InvalidOrderException extends Error {}
|
|
148
|
+
// ---cut---
|
|
149
|
+
import { dirname } from 'node:path'
|
|
150
|
+
import { Application, response, View } from '@orieljs/framework'
|
|
151
|
+
import { InvalidOrderException } from '../app/Exceptions/InvalidOrderException'
|
|
152
|
+
import providers from './providers'
|
|
153
|
+
|
|
154
|
+
export function createApplication(): Promise<Application> {
|
|
155
|
+
return Application.configure({ basePath: dirname(import.meta.dirname) })
|
|
156
|
+
.withRouting({ web: 'routes/web.ts', console: 'routes/console.ts', health: '/up' })
|
|
157
|
+
.withExceptions((exceptions) => {
|
|
158
|
+
exceptions.render(async (error) => {
|
|
159
|
+
if (error instanceof InvalidOrderException) {
|
|
160
|
+
return response(await View.make('errors.invalid-order').render(), 500)
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
.withProviders(providers)
|
|
165
|
+
.create()
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The functions run first, for every error, so they can change how a built-in error renders too:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
declare const exceptions: ExceptionsConfigurator
|
|
173
|
+
// ---cut---
|
|
174
|
+
exceptions.render((error, request) => {
|
|
175
|
+
if (error instanceof ModelNotFoundError && (request as HttpRequest).is('api/*')) {
|
|
176
|
+
return response().json({ message: 'Record not found.' }, 404)
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The request is typed `unknown`: during a request it is the request `request()` returns.
|
|
182
|
+
|
|
183
|
+
#### Rendering exceptions as JSON
|
|
184
|
+
|
|
185
|
+
An error is rendered as JSON when the request expects JSON: its `Accept` header asks for JSON first, or it is an
|
|
186
|
+
AJAX request that accepts anything (see [content negotiation](requests.md#content-negotiation)). Otherwise it gets
|
|
187
|
+
an HTML page. With `shouldRenderJsonWhen()`, you decide it yourself; the API starter kit answers JSON for every route
|
|
188
|
+
under `/api`:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
declare const exceptions: ExceptionsConfigurator
|
|
192
|
+
// ---cut---
|
|
193
|
+
exceptions.shouldRenderJsonWhen((request) => request.is('api/*') || request.expectsJson())
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Laravel's `respond()`, which changes every response the handler renders, isn't in Oriel: use `render()`.
|
|
197
|
+
|
|
198
|
+
### Renderable exceptions
|
|
199
|
+
|
|
200
|
+
An error class of your own can render itself. Give it a `render()` method that returns a `Response`:
|
|
201
|
+
|
|
202
|
+
```ts title="app/Exceptions/InvalidOrderException.ts"
|
|
203
|
+
import { response } from '@orieljs/framework'
|
|
204
|
+
|
|
205
|
+
export class InvalidOrderException extends Error {
|
|
206
|
+
/** Render the exception as an HTTP response. */
|
|
207
|
+
render(request: unknown): Response {
|
|
208
|
+
return response('The order could not be processed.', 422)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
When `render()` returns something that isn't a `Response`, the error is rendered as usual. A `toResponse()` method
|
|
214
|
+
works the same way. The error is still reported, unless its class is in `dontReport()`. Laravel's reportable
|
|
215
|
+
exceptions, with a `report()` method of their own, aren't in Oriel: use a `report()` function in `withExceptions()`.
|
|
216
|
+
|
|
217
|
+
Laravel's `throttle()`, which samples or rate limits the reports, isn't in Oriel either.
|
|
218
|
+
|
|
219
|
+
## HTTP exceptions
|
|
220
|
+
|
|
221
|
+
`abort()` ends the request with an HTTP status, from anywhere in your code:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
abort(404)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
A message and headers can follow. The message is what JSON responses show; the HTML pages show it for a 403 only:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
abort(403, 'You may not edit this post.')
|
|
231
|
+
|
|
232
|
+
abort(503, 'Down for maintenance', { 'Retry-After': '120' })
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`abortIf()` and `abortUnless()` abort when a condition is true, or false:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
declare const user: User | undefined
|
|
239
|
+
declare const post: Post
|
|
240
|
+
// ---cut---
|
|
241
|
+
abortIf(user === undefined, 403)
|
|
242
|
+
|
|
243
|
+
abortUnless(post.published_at !== null, 404)
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`abort()` also takes a ready `Response`, which is sent as it is: `abort(response('Gone fishing', 418))`.
|
|
247
|
+
|
|
248
|
+
### Statuses by error
|
|
249
|
+
|
|
250
|
+
Oriel's own errors carry their statuses. Each gets the same page, or JSON, as an `abort()` with that status:
|
|
251
|
+
|
|
252
|
+
| Error | Status |
|
|
253
|
+
| --- | --- |
|
|
254
|
+
| No route matches the URL | 404 |
|
|
255
|
+
| The route doesn't take the method | 405, with an `Allow` header |
|
|
256
|
+
| A model not found, by `findOrFail()` or [route model binding](routing.md#route-model-binding) | 404 |
|
|
257
|
+
| A guest on a route with the `auth` middleware | 401 as JSON; otherwise a redirect to the login page |
|
|
258
|
+
| An authorization check that fails | 403, or the status the policy chose |
|
|
259
|
+
| A form without its [CSRF token](csrf.md) | 419 |
|
|
260
|
+
| A failed [validation](validation.md) | 422 as JSON; otherwise a redirect back with the errors |
|
|
261
|
+
| Too many requests on a `throttle` route | 429, with `Retry-After` and the `X-RateLimit-*` headers |
|
|
262
|
+
| A body over the [size limit](requests.md#body-size-limits) | 413 |
|
|
263
|
+
| A JSON body nested too deeply | 400 |
|
|
264
|
+
| Anything else | 500 |
|
|
265
|
+
|
|
266
|
+
None of these is reported, except the last. The login page a guest is sent to is `http.redirect_guests_to`, set with
|
|
267
|
+
[`redirectGuestsTo()`](middleware.md#redirecting-guests-and-signed-in-users), else the route named `login`, else
|
|
268
|
+
`/login`; the URL the guest wanted is kept for after they sign in.
|
|
269
|
+
|
|
270
|
+
### Custom HTTP error pages
|
|
271
|
+
|
|
272
|
+
To show your own page for a status, add a view named after it: `resources/views/errors/404.tsx` for a 404. The view
|
|
273
|
+
receives the `status`, the `message` and the `exception`:
|
|
274
|
+
|
|
275
|
+
```tsx title="resources/views/errors/404.tsx"
|
|
276
|
+
interface Props {
|
|
277
|
+
status: number
|
|
278
|
+
message: string
|
|
279
|
+
exception: unknown
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export default function NotFound({ status, message, exception }: Props) {
|
|
283
|
+
return (
|
|
284
|
+
<html lang="en">
|
|
285
|
+
<body>
|
|
286
|
+
<h1>{status}</h1>
|
|
287
|
+
<h2>{exception instanceof Error && exception.message !== '' ? exception.message : message}</h2>
|
|
288
|
+
</body>
|
|
289
|
+
</html>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
`message` is the status's text, "Not Found", except for a 403, where it is the error's message. The error's own
|
|
295
|
+
message is on `exception`, and is empty for an `abort(404)` without one. A new app has no error views. Without one,
|
|
296
|
+
the page is a minimal one with the status and its text, as `404 | Not Found`.
|
|
297
|
+
|
|
298
|
+
The pages are views like any other, so they can use your layout. A view that fails to render is reported, and the
|
|
299
|
+
minimal page is sent in its place.
|
|
300
|
+
|
|
301
|
+
#### Fallback HTTP error pages
|
|
302
|
+
|
|
303
|
+
`resources/views/errors/4xx.tsx` is the page for any client error without a page of its own, and `5xx.tsx` for any
|
|
304
|
+
server error.
|
|
305
|
+
|
|
306
|
+
> [!NOTE]
|
|
307
|
+
> Laravel's fallback pages don't cover 404, 500 and 503, which have pages of their own inside the framework. Oriel
|
|
308
|
+
> has none of those, so `4xx.tsx` and `5xx.tsx` cover every status without its own view.
|
|
309
|
+
|
|
310
|
+
### What a production error shows
|
|
311
|
+
|
|
312
|
+
With `debug` off, a server error shows nothing of the error: your `errors/500.tsx` or `errors/5xx.tsx` view, or the
|
|
313
|
+
minimal "500 | Server Error", and `{ "message": "Server Error" }` to a request that expects JSON. The error is
|
|
314
|
+
reported to your log with its stack.
|
|
315
|
+
|
|
316
|
+
An error with an HTTP status shows its status text on the HTML page (a 403 shows its message), and its message in
|
|
317
|
+
JSON: `{ "message": "No query results for model [Post] 5" }` for a missing `Post`. Keep secrets out of the messages
|
|
318
|
+
you give `abort()`.
|
package/docs/events.md
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# Events
|
|
2
|
+
|
|
3
|
+
Events let one part of your app announce that something happened, and let other parts act on it, without the two
|
|
4
|
+
knowing about each other.
|
|
5
|
+
|
|
6
|
+
## Introduction
|
|
7
|
+
|
|
8
|
+
An event is a plain class that carries what happened: an `OrderShipped` event carries the order. A listener is a
|
|
9
|
+
class with a `handle()` method that the event is passed to. One event can have many listeners: when an order ships,
|
|
10
|
+
one listener emails the customer and another tells the warehouse, and the code that ships the order dispatches the
|
|
11
|
+
event and knows about neither.
|
|
12
|
+
|
|
13
|
+
Events live in `app/Events` and listeners in `app/Listeners`. Neither folder exists in a new app: the `make:event` and
|
|
14
|
+
`make:listener` commands create them.
|
|
15
|
+
|
|
16
|
+
## Generating events and listeners
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
node oriel make:event OrderShipped
|
|
20
|
+
node oriel make:listener SendShipmentNotification --event=OrderShipped
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`make:listener` with `--event` writes a listener that already names its event in `static listensTo`, so
|
|
24
|
+
[discovery](#event-discovery) registers it. Add `--queued` for a [queued listener](#queued-event-listeners).
|
|
25
|
+
|
|
26
|
+
## Registering events and listeners
|
|
27
|
+
|
|
28
|
+
### Event discovery
|
|
29
|
+
|
|
30
|
+
When your app boots, Oriel imports every file under `app/Listeners`, folders included, and registers each exported
|
|
31
|
+
class that has a `handle()` method and a static `listensTo` field. `listensTo` names the event the listener handles:
|
|
32
|
+
an event class, an event's name as a string, or an array of either.
|
|
33
|
+
|
|
34
|
+
```ts title="app/Listeners/SendShipmentNotification.ts"
|
|
35
|
+
// @filename: app/Events/OrderShipped.ts
|
|
36
|
+
import type { Order } from '../Models/Order'
|
|
37
|
+
export class OrderShipped {
|
|
38
|
+
constructor(readonly order: Order) {}
|
|
39
|
+
}
|
|
40
|
+
// ---cut---
|
|
41
|
+
import { OrderShipped } from '../Events/OrderShipped'
|
|
42
|
+
|
|
43
|
+
export class SendShipmentNotification {
|
|
44
|
+
static listensTo = OrderShipped
|
|
45
|
+
|
|
46
|
+
async handle(event: OrderShipped): Promise<void> {
|
|
47
|
+
// Email the customer about event.order...
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Any other export in a listener's file, such as a helper function, is skipped.
|
|
53
|
+
|
|
54
|
+
> [!NOTE]
|
|
55
|
+
> Laravel finds a listener's event from the type of its `handle()` method's parameter. TypeScript types are gone when
|
|
56
|
+
> your app runs, so an Oriel listener names its event in `static listensTo`. A listener without it isn't discovered:
|
|
57
|
+
> register it yourself.
|
|
58
|
+
|
|
59
|
+
A new app has no `config/events.ts`. To turn discovery off, add one: `export default { discover: false }`.
|
|
60
|
+
|
|
61
|
+
### Manually registering events
|
|
62
|
+
|
|
63
|
+
`Event.listen()` registers a listener in a provider's `boot()` method. The listener can be a listener class, a
|
|
64
|
+
`[class, method]` pair, or a function:
|
|
65
|
+
|
|
66
|
+
```ts title="app/Providers/AppServiceProvider.ts"
|
|
67
|
+
// @filename: app/Events/OrderShipped.ts
|
|
68
|
+
import type { Order } from '../Models/Order'
|
|
69
|
+
export class OrderShipped {
|
|
70
|
+
constructor(readonly order: Order) {}
|
|
71
|
+
}
|
|
72
|
+
// @filename: app/Listeners/SendShipmentNotification.ts
|
|
73
|
+
import type { OrderShipped } from '../Events/OrderShipped'
|
|
74
|
+
export declare class SendShipmentNotification {
|
|
75
|
+
handle(event: OrderShipped): Promise<void>
|
|
76
|
+
}
|
|
77
|
+
// ---cut---
|
|
78
|
+
import { Event, ServiceProvider } from '@orieljs/framework'
|
|
79
|
+
import { OrderShipped } from '../Events/OrderShipped'
|
|
80
|
+
import { SendShipmentNotification } from '../Listeners/SendShipmentNotification'
|
|
81
|
+
|
|
82
|
+
export class AppServiceProvider extends ServiceProvider {
|
|
83
|
+
override boot(): void {
|
|
84
|
+
Event.listen(OrderShipped, SendShipmentNotification)
|
|
85
|
+
|
|
86
|
+
Event.listen(OrderShipped, (event: OrderShipped) => {
|
|
87
|
+
// ...
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A listener class registered here and also discovered runs twice, once for each registration.
|
|
94
|
+
|
|
95
|
+
`config/events.ts` can list them too, under `listen`, as `[event, listeners]` pairs (a TypeScript object can't take a
|
|
96
|
+
class as a key):
|
|
97
|
+
|
|
98
|
+
```ts title="config/events.ts"
|
|
99
|
+
// @filename: app/Events/OrderShipped.ts
|
|
100
|
+
import type { Order } from '../Models/Order'
|
|
101
|
+
export class OrderShipped {
|
|
102
|
+
constructor(readonly order: Order) {}
|
|
103
|
+
}
|
|
104
|
+
// @filename: app/Listeners/SendShipmentNotification.ts
|
|
105
|
+
import type { OrderShipped } from '../Events/OrderShipped'
|
|
106
|
+
export declare class SendShipmentNotification {
|
|
107
|
+
handle(event: OrderShipped): Promise<void>
|
|
108
|
+
}
|
|
109
|
+
// ---cut---
|
|
110
|
+
import type { EventListenMap } from '@orieljs/framework'
|
|
111
|
+
import { OrderShipped } from '../app/Events/OrderShipped'
|
|
112
|
+
import { SendShipmentNotification } from '../app/Listeners/SendShipmentNotification'
|
|
113
|
+
|
|
114
|
+
export default {
|
|
115
|
+
discover: true,
|
|
116
|
+
listen: [[OrderShipped, [SendShipmentNotification]]] as EventListenMap,
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
#### Wildcard listeners
|
|
121
|
+
|
|
122
|
+
A listener for a string event can use `*` as a wildcard. It is called with the event's name and the array of its
|
|
123
|
+
payload:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { Event } from '@orieljs/framework'
|
|
127
|
+
|
|
128
|
+
Event.listen('order.*', (eventName: string, payload?: unknown[]) => {
|
|
129
|
+
// ...
|
|
130
|
+
})
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Viewing the events
|
|
134
|
+
|
|
135
|
+
`event:list` lists every event with how many listeners it has:
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
node oriel event:list
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Defining events
|
|
142
|
+
|
|
143
|
+
An event holds the data its listeners need, and nothing else:
|
|
144
|
+
|
|
145
|
+
```ts title="app/Events/OrderShipped.ts"
|
|
146
|
+
import type { Order } from '../Models/Order'
|
|
147
|
+
|
|
148
|
+
export class OrderShipped {
|
|
149
|
+
constructor(readonly order: Order) {}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Defining listeners
|
|
154
|
+
|
|
155
|
+
A listener's `handle()` method takes the event. It may be async; the dispatch waits for it before it calls the next
|
|
156
|
+
listener.
|
|
157
|
+
|
|
158
|
+
Your app builds a listener class with its container each time the event is dispatched. It is built by the application,
|
|
159
|
+
not by the request's scope, so an `inject()` of a request-scoped service in a listener's field throws during a request.
|
|
160
|
+
Call `inject()` inside `handle()` instead.
|
|
161
|
+
|
|
162
|
+
### Stopping the propagation of an event
|
|
163
|
+
|
|
164
|
+
A listener that returns `false` stops the listeners after it from running.
|
|
165
|
+
|
|
166
|
+
## Queued event listeners
|
|
167
|
+
|
|
168
|
+
A listener that sends email or calls another service can run on the queue instead, so the request doesn't wait for it.
|
|
169
|
+
Give it a `readonly shouldQueue = true` field, as `make:listener --queued` does:
|
|
170
|
+
|
|
171
|
+
```ts title="app/Listeners/SendShipmentNotification.ts"
|
|
172
|
+
// @filename: app/Events/OrderShipped.ts
|
|
173
|
+
import type { Order } from '../Models/Order'
|
|
174
|
+
export class OrderShipped {
|
|
175
|
+
constructor(readonly order: Order) {}
|
|
176
|
+
}
|
|
177
|
+
// ---cut---
|
|
178
|
+
import type { ShouldQueue } from '@orieljs/framework/contracts'
|
|
179
|
+
import { OrderShipped } from '../Events/OrderShipped'
|
|
180
|
+
|
|
181
|
+
export class SendShipmentNotification implements ShouldQueue {
|
|
182
|
+
static listensTo = OrderShipped
|
|
183
|
+
|
|
184
|
+
readonly shouldQueue = true
|
|
185
|
+
|
|
186
|
+
async handle(event: OrderShipped): Promise<void> {
|
|
187
|
+
// ...
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
When the event is dispatched, the listener is pushed to the queue as a job, with the event, and a worker
|
|
193
|
+
(`node oriel queue:work`) runs it. The queue component must be in your app; read the [Queues](queues.md) page for
|
|
194
|
+
workers and connections.
|
|
195
|
+
|
|
196
|
+
### Customizing the queue connection, name and delay
|
|
197
|
+
|
|
198
|
+
A queued listener takes the job options as fields: `connection`, `queue`, `delay`, `tries`, `backoff` and `timeout`.
|
|
199
|
+
Each can be a method that returns the value. A listener with any of these fields is queued even without
|
|
200
|
+
`shouldQueue`.
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
export class SendShipmentNotification {
|
|
204
|
+
readonly shouldQueue = true
|
|
205
|
+
readonly connection = 'redis'
|
|
206
|
+
readonly queue = 'listeners'
|
|
207
|
+
readonly delay = 60
|
|
208
|
+
|
|
209
|
+
async handle(): Promise<void> {
|
|
210
|
+
// ...
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Unique listeners
|
|
216
|
+
|
|
217
|
+
A queued listener with a `uniqueId()` method is unique (so is one with `uniqueFor`, `uniqueUntilProcessing` or
|
|
218
|
+
`uniqueVia()`, whose id is then empty): while one of its jobs is waiting or running, another dispatch with the same id
|
|
219
|
+
isn't queued. `uniqueId()` is called with the event, when the event is dispatched:
|
|
220
|
+
|
|
221
|
+
```ts title="app/Listeners/SendShipmentNotification.ts"
|
|
222
|
+
// @filename: app/Events/OrderShipped.ts
|
|
223
|
+
import type { Order } from '../Models/Order'
|
|
224
|
+
export class OrderShipped {
|
|
225
|
+
constructor(readonly order: Order) {}
|
|
226
|
+
}
|
|
227
|
+
// ---cut---
|
|
228
|
+
import { OrderShipped } from '../Events/OrderShipped'
|
|
229
|
+
|
|
230
|
+
export class SendShipmentNotification {
|
|
231
|
+
static listensTo = OrderShipped
|
|
232
|
+
|
|
233
|
+
readonly shouldQueue = true
|
|
234
|
+
readonly uniqueFor = 3600
|
|
235
|
+
|
|
236
|
+
uniqueId(event: OrderShipped): string {
|
|
237
|
+
return String(event.order.getKey())
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async handle(event: OrderShipped): Promise<void> {
|
|
241
|
+
// ...
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
A duplicate is a second job for the same listener class with the same id. The lock is the listener's, by class: the
|
|
247
|
+
same class listening to two events, or through two methods of a [subscriber](#event-subscribers), shares one lock
|
|
248
|
+
for each id. `uniqueFor` is how many seconds the lock lasts at most, and `uniqueUntilProcessing = true` releases it
|
|
249
|
+
when the job starts rather than when it ends. The lock is a cache lock, so your app's cache store must support locks
|
|
250
|
+
(read the Cache page's [Atomic locks](cache.md#atomic-locks)); `uniqueVia()` returns another store's name.
|
|
251
|
+
|
|
252
|
+
> [!NOTE]
|
|
253
|
+
> Laravel 12 has no unique listeners; Laravel 13 adds them. Oriel's work as Laravel 13's do, and as
|
|
254
|
+
> [unique jobs](queues.md#unique-jobs).
|
|
255
|
+
|
|
256
|
+
## Dispatching events
|
|
257
|
+
|
|
258
|
+
`Event.dispatch()` dispatches an event, and the `event()` helper does the same. Both wait for every listener that
|
|
259
|
+
isn't queued, and give back the listeners' return values:
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
// @filename: app/Events/OrderShipped.ts
|
|
263
|
+
import type { Order } from '../Models/Order'
|
|
264
|
+
export class OrderShipped {
|
|
265
|
+
constructor(readonly order: Order) {}
|
|
266
|
+
}
|
|
267
|
+
// ---cut---
|
|
268
|
+
import { Event, event } from '@orieljs/framework'
|
|
269
|
+
import { OrderShipped } from './app/Events/OrderShipped'
|
|
270
|
+
import { Order } from './app/Models/Order'
|
|
271
|
+
|
|
272
|
+
const order = await Order.findOrFail(1)
|
|
273
|
+
|
|
274
|
+
await Event.dispatch(new OrderShipped(order))
|
|
275
|
+
await event(new OrderShipped(order))
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
A string event takes its payload as an array, which its listeners receive as arguments:
|
|
279
|
+
`Event.dispatch('order.shipped', [order])`. `Event.until()` stops at the first listener that returns something other
|
|
280
|
+
than `undefined`, and gives back that value.
|
|
281
|
+
|
|
282
|
+
## Event subscribers
|
|
283
|
+
|
|
284
|
+
A subscriber is one class that listens to several events. Its `subscribe()` method returns a map of event names to the
|
|
285
|
+
method, or methods, that handle them:
|
|
286
|
+
|
|
287
|
+
```ts title="app/Listeners/UserEventSubscriber.ts"
|
|
288
|
+
export class UserEventSubscriber {
|
|
289
|
+
handleUserLogin(): void {
|
|
290
|
+
// ...
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
handleUserLogout(): void {
|
|
294
|
+
// ...
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
subscribe(): Record<string, string> {
|
|
298
|
+
return {
|
|
299
|
+
'user.login': 'handleUserLogin',
|
|
300
|
+
'user.logout': 'handleUserLogout',
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
The map's keys are strings, so they name string events. For class events, `subscribe()` takes the dispatcher and can
|
|
307
|
+
call its `listen()` with `[UserEventSubscriber, 'handleUserLogin']` pairs. Register a subscriber in a provider's
|
|
308
|
+
`boot()`, with `Event.subscribe(UserEventSubscriber)`; discovery doesn't find subscribers.
|
|
309
|
+
|
|
310
|
+
## Testing
|
|
311
|
+
|
|
312
|
+
`Event.fake()` swaps the dispatcher for a fake that records events instead of running their listeners. Give it the
|
|
313
|
+
events to fake, and the others still run:
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
// @filename: app/Events/OrderShipped.ts
|
|
317
|
+
import type { Order } from '../Models/Order'
|
|
318
|
+
export class OrderShipped {
|
|
319
|
+
constructor(readonly order: Order) {}
|
|
320
|
+
}
|
|
321
|
+
// ---cut---
|
|
322
|
+
import { Event } from '@orieljs/framework'
|
|
323
|
+
import { OrderShipped } from './app/Events/OrderShipped'
|
|
324
|
+
|
|
325
|
+
const events = Event.fake([OrderShipped])
|
|
326
|
+
|
|
327
|
+
// Ship the order...
|
|
328
|
+
|
|
329
|
+
events.assertDispatched(OrderShipped)
|
|
330
|
+
events.assertDispatchedTimes(OrderShipped, 1)
|
|
331
|
+
events.assertNotDispatched('order.cancelled')
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
The fake also has `assertNothingDispatched()`, `assertListening(event, listener)` and `dispatched(event)`.
|
|
335
|
+
`EventFake.fakeExcept([OrderShipped])`, from `@orieljs/framework/testing`, fakes every event but the ones you list.
|
|
336
|
+
[Fakes](mocking.md) covers fakes in full.
|