@znight/core 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +413 -2
- package/dist/index.d.mts +1069 -0
- package/dist/index.mjs +2202 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +60 -4
- package/src/aop.ts +216 -0
- package/src/application.ts +528 -0
- package/src/container-errors.ts +175 -0
- package/src/container.ts +1013 -0
- package/src/decorators.ts +126 -0
- package/src/definition.ts +39 -0
- package/src/errors.ts +163 -0
- package/src/graph.ts +257 -0
- package/src/hooks.ts +289 -0
- package/src/index.ts +94 -0
- package/src/lifecycle.ts +85 -0
- package/src/logger.ts +92 -0
- package/src/metadata.ts +145 -0
- package/src/plugin.ts +81 -0
- package/src/process.ts +84 -0
- package/src/runtime-errors.ts +84 -0
- package/src/scope.ts +49 -0
- package/src/slot.ts +113 -0
- package/src/tokens.ts +76 -0
- package/src/utils.ts +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Ugho STEPHAN
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,414 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @znight/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The runtime of [Znight](../../README.md): a dependency injection container driven by **compile-time
|
|
4
|
+
metadata**, the application lifecycle, plugins, hooks, request scopes, method interceptors (AOP) and
|
|
5
|
+
the marker decorators.
|
|
6
|
+
|
|
7
|
+
`@znight/compiler` analyses your sources at build time and generates `__ZNIGHT_METADATA__`: every
|
|
8
|
+
provider, its dependencies, its scope and a precomputed resolution order. `@znight/core` only executes
|
|
9
|
+
that plan. There is no `reflect-metadata`, no runtime type lookup, and resolving a singleton is a
|
|
10
|
+
single `Map` lookup.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
# bun
|
|
14
|
+
bun add @znight/core
|
|
15
|
+
bun add -d @znight/compiler vite
|
|
16
|
+
|
|
17
|
+
# npm
|
|
18
|
+
npm install @znight/core
|
|
19
|
+
npm install -D @znight/compiler vite
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Runs on Node.js >= 24 and Bun >= 1.3 (ESM only). Decorators require
|
|
23
|
+
`"experimentalDecorators": true` in your `tsconfig.json`.
|
|
24
|
+
|
|
25
|
+
**Documentation:** [`@znight/core`](https://github.com/znight-org/znight/blob/main/docs/packages/core.md)
|
|
26
|
+
and the guides on [dependency injection](https://github.com/znight-org/znight/blob/main/docs/guide/dependency-injection.md),
|
|
27
|
+
[scopes](https://github.com/znight-org/znight/blob/main/docs/guide/scopes.md),
|
|
28
|
+
[lifecycle](https://github.com/znight-org/znight/blob/main/docs/guide/lifecycle.md) and
|
|
29
|
+
[data access](https://github.com/znight-org/znight/blob/main/docs/guide/data-access.md).
|
|
30
|
+
**Examples:** every [example application](https://github.com/znight-org/znight/tree/main/examples)
|
|
31
|
+
uses it; [`rest-api`](https://github.com/znight-org/znight/tree/main/examples/rest-api) provides a
|
|
32
|
+
Drizzle database through `@Factory`/`@Provides`, [`monorepo`](https://github.com/znight-org/znight/tree/main/examples/monorepo)
|
|
33
|
+
shares providers through a library package.
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// src/users/user.service.ts
|
|
39
|
+
import { Injectable, type OnStart } from '@znight/core'
|
|
40
|
+
|
|
41
|
+
@Injectable()
|
|
42
|
+
export class UserService implements OnStart {
|
|
43
|
+
constructor(private readonly repository: UserRepository) {}
|
|
44
|
+
|
|
45
|
+
async onStart() {
|
|
46
|
+
await this.repository.warmUp()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
findById(id: string) {
|
|
50
|
+
return this.repository.findById(id)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// src/main.ts
|
|
57
|
+
import { Application } from '@znight/core'
|
|
58
|
+
import { HttpPlugin } from '@znight/http'
|
|
59
|
+
import { __ZNIGHT_METADATA__ } from './.znight/container.generated.js'
|
|
60
|
+
|
|
61
|
+
const app = await Application.create({
|
|
62
|
+
metadata: __ZNIGHT_METADATA__,
|
|
63
|
+
plugins: [HttpPlugin.configure({ port: 3000 })],
|
|
64
|
+
})
|
|
65
|
+
await app.start() // SIGINT / SIGTERM trigger a graceful app.stop()
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Decorators
|
|
69
|
+
|
|
70
|
+
Decorators are **markers**: they do nothing at runtime (each returns the same shared no-op function)
|
|
71
|
+
and the compiler reads them from the AST. Arguments must be statically evaluable (literals, `const`
|
|
72
|
+
references...).
|
|
73
|
+
|
|
74
|
+
| Decorator | Target | Purpose |
|
|
75
|
+
| -------------------------------------------------- | ----------------------------------- | ---------------------------------------------------------- |
|
|
76
|
+
| `@Injectable({ scope?, name?, primary? })` | class | Declares a provider (`singleton` by default) |
|
|
77
|
+
| `@Factory()` | class | A singleton whose `@Provides` methods produce providers |
|
|
78
|
+
| `@Provides({ scope?, name?, primary?, dispose? })` | method of a `@Factory` | Provides the method's return type (`Promise<T>` → async) |
|
|
79
|
+
| `@Inject(name)` | constructor / `@Provides` parameter | Injects the provider qualified with `name` |
|
|
80
|
+
| `@Optional()` | constructor / `@Provides` parameter | Injects `undefined` when no provider exists |
|
|
81
|
+
| `@Named(name)` / `@Primary()` | class / `@Provides` method | Qualifier / preferred candidate |
|
|
82
|
+
| `@Intercept(Interceptor, options?)` | method | Wraps calls with an interceptor provider (see [AOP](#aop)) |
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
@Factory()
|
|
86
|
+
export class DatabaseFactory {
|
|
87
|
+
@Provides({ dispose: 'end' })
|
|
88
|
+
async createPool(config: DatabaseConfig): Promise<Pool> {
|
|
89
|
+
const pool = new Pool({ connectionString: config.url })
|
|
90
|
+
await pool.query('select 1')
|
|
91
|
+
return pool
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Application
|
|
97
|
+
|
|
98
|
+
`Application.create(options)` builds the container and runs the plugins:
|
|
99
|
+
|
|
100
|
+
1. supplies `providers`, runs each plugin's `register` (in order), applies `overrides`;
|
|
101
|
+
2. `container.init()`: validates the graph and instantiates singletons;
|
|
102
|
+
3. runs each plugin's `onBootstrap`.
|
|
103
|
+
|
|
104
|
+
If anything fails, everything created so far is disposed (plugins receive `onDispose`) and the error
|
|
105
|
+
is rethrown.
|
|
106
|
+
|
|
107
|
+
`providers` and `overrides` accept a `Map`, an iterable of `[key, value]` pairs or a record keyed by
|
|
108
|
+
token; keys may be tokens or classes. Override values that look like a provider definition
|
|
109
|
+
(`{ scope, deps, factory }`) are registered as definitions.
|
|
110
|
+
|
|
111
|
+
| Option | Description |
|
|
112
|
+
| ----------- | ----------------------------------------------------------------------------------------------- |
|
|
113
|
+
| `metadata` | The generated `__ZNIGHT_METADATA__`. |
|
|
114
|
+
| `plugins` | Runtime features (`HttpPlugin`, `MessagingPlugin`, `ConfigPlugin`...). |
|
|
115
|
+
| `providers` | Values for externals or replacements of compiled providers (see below). |
|
|
116
|
+
| `overrides` | Same shape as `providers`, applied last with the highest precedence. Ideal for tests. |
|
|
117
|
+
| `logger` | Initial `CoreLogger` (console by default). Replace it later with `app.setLogger()`. |
|
|
118
|
+
| `shutdown` | `{ signals?: ShutdownSignal[] \| false, timeoutMs?: 10_000, hookTimeoutMs?: 5_000, process? }`. |
|
|
119
|
+
| `init` | Forwarded to `container.init()`, e.g. `{ only: [UserController] }`. |
|
|
120
|
+
| `hot` | Pass `import.meta.hot`: the application stops before the replaced module is loaded again. |
|
|
121
|
+
|
|
122
|
+
**`app.start()`** runs providers' `onStart` (resolution order: dependencies first), plugins' `onStart`
|
|
123
|
+
(start listening), then providers' and plugins' `onReady`. Signal handlers are installed when the start
|
|
124
|
+
begins. If a step fails, what was started is stopped, everything is disposed and the error is rethrown.
|
|
125
|
+
|
|
126
|
+
**`app.stop()`** is idempotent and waits for an in-progress start. It runs three phases:
|
|
127
|
+
|
|
128
|
+
1. plugins' `onStop`, in reverse order (stop ingress, drain in-flight work);
|
|
129
|
+
2. for each provider in reverse resolution order: `onStop` (if it was started), then disposal;
|
|
130
|
+
3. plugins' `onDispose`, in reverse order (close connections, flush buffers).
|
|
131
|
+
|
|
132
|
+
Every step is attempted, each within `hookTimeoutMs` and all within `timeoutMs`. Failures are collected
|
|
133
|
+
into a `ZnightAggregateError` (`ZNIGHT021`, `error.errors`). Both budgets accept `Infinity` for "no
|
|
134
|
+
limit"; a budget above `2^31 - 1` ms (about 24.8 days, the range of timers) means no limit as well.
|
|
135
|
+
|
|
136
|
+
A start hook (`onStart` / `onReady` of a provider or plugin) may call `app.stop()`, awaited or not,
|
|
137
|
+
e.g. a job that ends its work in `onReady`. The shutdown then runs right away instead of waiting for the
|
|
138
|
+
start (which is waiting for that hook): what completed its `onStart` is stopped, the remaining start
|
|
139
|
+
hooks are skipped and `app.start()` resolves once the application is stopped (it rejects if the hook
|
|
140
|
+
throws afterwards).
|
|
141
|
+
|
|
142
|
+
With `hot: import.meta.hot`, the dispose callback returns the `stop()` promise, so the HMR runtime
|
|
143
|
+
(Vite's module runner, Bun) waits for the old application to release its ports and consumers before
|
|
144
|
+
it loads the new module. A failed stop is logged, not thrown at the HMR runtime.
|
|
145
|
+
|
|
146
|
+
On `SIGINT`/`SIGTERM` the application stops gracefully and logs `Application stopped` once, at `info`
|
|
147
|
+
(at `debug` after an explicit `app.stop()`). `process.exitCode` is set to `1` when this shutdown fails,
|
|
148
|
+
and a second signal forces `process.exit(130)`. An explicit `app.stop()` never touches the exit code.
|
|
149
|
+
|
|
150
|
+
`app.state` is one of `created`, `starting`, `ready`, `stopping`, `stopped` or `failed`.
|
|
151
|
+
|
|
152
|
+
## Lifecycle
|
|
153
|
+
|
|
154
|
+
Singletons are duck-typed: implement any of these methods, no registration needed.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
@Injectable()
|
|
158
|
+
export class Scheduler implements OnStart, OnReady, OnStop {
|
|
159
|
+
onStart() {} // after the container is ready, dependencies first
|
|
160
|
+
onReady() {} // once every provider and plugin started
|
|
161
|
+
onStop() {} // on shutdown, dependents first (only if onStart ran)
|
|
162
|
+
async [Symbol.asyncDispose]() {} // always called on shutdown / rollback
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Disposal calls the `dispose` method declared with `@Provides({ dispose })`, otherwise
|
|
167
|
+
`[Symbol.asyncDispose]()`, otherwise `[Symbol.dispose]()`. Release resources acquired in a constructor
|
|
168
|
+
or a factory there: unlike `onStop`, disposal also runs when the application was never started or
|
|
169
|
+
failed to start. Transient instances belong to their consumer and are never disposed by the
|
|
170
|
+
container. Values you register (`providers`, `registerValue`, `overrides`) are not disposed either.
|
|
171
|
+
|
|
172
|
+
## Container
|
|
173
|
+
|
|
174
|
+
The `Container` is created by the application (`app.container`); you can also use it directly, e.g.
|
|
175
|
+
in tests:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
const container = Container.create(__ZNIGHT_METADATA__)
|
|
179
|
+
container.registerValue('@znight/observability#Logger', fakeLogger)
|
|
180
|
+
await container.init()
|
|
181
|
+
|
|
182
|
+
container.get(UserService) // by class
|
|
183
|
+
container.get('src/users/user.service.ts#UserService') // by token
|
|
184
|
+
container.get('UserService') // by unique display name
|
|
185
|
+
container.get(UserRepository) // abstract class / alias
|
|
186
|
+
container.getOptional(Cache) // undefined when there is no provider
|
|
187
|
+
container.has(Cache)
|
|
188
|
+
container.tokenOf(UserService)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
A class that is not a registered key (an abstract base behind an alias, a class supplied as an
|
|
192
|
+
external) falls back to its name, filtered by prototype chain. A candidate matches when its class (the
|
|
193
|
+
class of its definition, or the class it was compiled or registered with, kept when `override()`
|
|
194
|
+
replaces it with a fake) or its value is of that class or a subclass; a candidate whose class belongs
|
|
195
|
+
to another hierarchy is ignored. Candidates whose class is unknown (an unbound external, a `@Provides`
|
|
196
|
+
factory, a value that is not an instance of the class, e.g. an `implements` fake) are only considered
|
|
197
|
+
when no candidate matches. So `get(Logger)` finds the `ConsoleLogger` aliased as `Logger` even when
|
|
198
|
+
`@znight/observability#Logger` is also declared, and a user class named `Container` never resolves the
|
|
199
|
+
Znight `Container`.
|
|
200
|
+
|
|
201
|
+
Registrations are only allowed before `init()` (afterwards they throw `ZNIGHT016`). When several
|
|
202
|
+
registrations target the same token, the highest precedence wins:
|
|
203
|
+
|
|
204
|
+
`override()` > plugin `register()` / `registerValue()` > `Application.create({ providers })` > compiled providers
|
|
205
|
+
|
|
206
|
+
`registerValue()` on a compiled provider throws (`ZNIGHT004`, use `override()`), and registering the
|
|
207
|
+
same token twice at the same level throws as well.
|
|
208
|
+
|
|
209
|
+
`init()` validates the merged graph (compiled and runtime definitions) and reports every missing
|
|
210
|
+
dependency at once (`ZNIGHT001`, naming the plugin expected to provide it), cycles (`ZNIGHT002`) and
|
|
211
|
+
scope mismatches (`ZNIGHT003`). A singleton factory starts as soon as its dependencies are ready, so
|
|
212
|
+
independent async singletons (database pools, caches...) are created concurrently. If a factory fails,
|
|
213
|
+
the error is a `ZNIGHT015` `InstantiationError` carrying the token, the dependency path, the source
|
|
214
|
+
location and the original `cause`; already created instances are disposed in reverse creation order.
|
|
215
|
+
|
|
216
|
+
`init({ only: [UserController] })` instantiates only the dependency closure of the given providers;
|
|
217
|
+
other singletons are created synchronously on first use (async factories must be in the closure) and
|
|
218
|
+
only receive lifecycle hooks if they exist when the application starts. Like during `init()`, such a
|
|
219
|
+
singleton is created outside of any request scope, even when a request uses it first: the timers,
|
|
220
|
+
sockets and promises it creates never see that request's `currentScope()`.
|
|
221
|
+
|
|
222
|
+
## Scopes
|
|
223
|
+
|
|
224
|
+
| Scope | Lifetime |
|
|
225
|
+
| ----------- | ------------------------------------------------------------------------ |
|
|
226
|
+
| `singleton` | One instance for the application (default). Async factories are allowed. |
|
|
227
|
+
| `request` | One instance per `runInScope()` call, disposed when the scope ends. |
|
|
228
|
+
| `transient` | A new instance for every injection or `get()`. |
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
const user = await container.runInScope(
|
|
232
|
+
async () => container.get(CurrentUser).load(),
|
|
233
|
+
[['@znight/http#HttpContext', ctx]], // seed values (request-scoped externals)
|
|
234
|
+
)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Scopes are isolated with `AsyncLocalStorage` (concurrent requests never share instances). Resolving a
|
|
238
|
+
request-scoped provider outside of a scope throws `ZNIGHT013`. `container.currentScope()` exposes the
|
|
239
|
+
active scope (`get`, `set`, `has`). Plugins (HTTP, messaging) open the scopes for you.
|
|
240
|
+
|
|
241
|
+
## Lazy dependencies
|
|
242
|
+
|
|
243
|
+
`Lazy<T>` injects a handle resolved at call time, in the current scope. It breaks cycles and lets a
|
|
244
|
+
singleton use a request-scoped value:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
@Injectable()
|
|
248
|
+
export class AuditService {
|
|
249
|
+
constructor(private readonly context: Lazy<HttpContext>) {}
|
|
250
|
+
|
|
251
|
+
record(action: string) {
|
|
252
|
+
const { user } = this.context.get() // resolved in the current request scope
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Plugins
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
import { type Plugin, type PluginOptions, resolveOptions } from '@znight/core'
|
|
261
|
+
|
|
262
|
+
export const HeartbeatPlugin = {
|
|
263
|
+
configure(options: PluginOptions<{ intervalMs: number }>): Plugin {
|
|
264
|
+
let timer: NodeJS.Timeout | undefined
|
|
265
|
+
let intervalMs = 1000
|
|
266
|
+
return {
|
|
267
|
+
name: 'heartbeat',
|
|
268
|
+
register(ctx) {
|
|
269
|
+
ctx.container.registerValue('@acme/heartbeat#Clock', systemClock)
|
|
270
|
+
},
|
|
271
|
+
onBootstrap(ctx) {
|
|
272
|
+
// options may be a function of the container: HeartbeatPlugin.configure(c => ({ intervalMs: c.get(AppConfig).beat }))
|
|
273
|
+
intervalMs = resolveOptions(options, ctx.container).intervalMs
|
|
274
|
+
ctx.logger.info('heartbeat configured', { intervalMs })
|
|
275
|
+
},
|
|
276
|
+
onStart() {
|
|
277
|
+
timer = setInterval(beat, intervalMs)
|
|
278
|
+
},
|
|
279
|
+
onStop() {
|
|
280
|
+
clearInterval(timer)
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
A plugin receives a `PluginContext` with `app`, `container`, `metadata`, `hooks`, a live `logger`
|
|
288
|
+
(follows `app.setLogger()`) and `getPlugin(name)`.
|
|
289
|
+
|
|
290
|
+
A plugin that resolves providers at boot which the metadata does not make entry points (classes
|
|
291
|
+
given in its options, like the global guards of `HttpPlugin`, or hosts it reads from its own
|
|
292
|
+
metadata section) lists them in an optional `initRoots(context)` method. The application never calls
|
|
293
|
+
it; tools that restrict the boot to a few providers do, so that those providers and their async
|
|
294
|
+
dependencies are created at boot (an async factory cannot run on demand): `TestApp` of
|
|
295
|
+
`@znight/testing`, or your own `init: { only }` list.
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
const AuditPlugin = {
|
|
299
|
+
configure(options: { sinks: readonly (new (...args: never[]) => AuditSink)[] }): Plugin {
|
|
300
|
+
return {
|
|
301
|
+
name: 'audit',
|
|
302
|
+
// called before the application exists: cheap, side-effect free, never throws
|
|
303
|
+
initRoots: () => options.sinks,
|
|
304
|
+
onBootstrap(ctx) {
|
|
305
|
+
for (const sink of options.sinks) ctx.container.get(sink).open()
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const audit = AuditPlugin.configure({ sinks: [S3AuditSink] })
|
|
312
|
+
await Application.create({
|
|
313
|
+
metadata,
|
|
314
|
+
plugins: [audit],
|
|
315
|
+
init: { only: [UserController, ...(audit.initRoots?.({ metadata }) ?? [])] },
|
|
316
|
+
})
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
`initRoots` receives `{ metadata }` (`PluginRootsContext`) and returns classes or tokens
|
|
320
|
+
(`ProviderKey[]`). Options given as a function of the container cannot be read before the boot:
|
|
321
|
+
return what is known (`[]`).
|
|
322
|
+
|
|
323
|
+
## Hooks
|
|
324
|
+
|
|
325
|
+
`app.hooks` is a typed event bus. The hooks of the first-party packages and their event types are
|
|
326
|
+
declared in core, so producers and observers (`@znight/observability`, your plugins) share them
|
|
327
|
+
without depending on each other:
|
|
328
|
+
|
|
329
|
+
| Hook | Event | Produced by |
|
|
330
|
+
| ------------------- | ---------------------- | ---------------------------------------------------------------------------- |
|
|
331
|
+
| `http:request` | `HttpRequestEvent` | `@znight/http`, one request |
|
|
332
|
+
| `messaging:message` | `MessageDeliveryEvent` | `@znight/messaging`, one delivery |
|
|
333
|
+
| `messaging:publish` | `MessagePublishEvent` | `@znight/messaging`, one `MessageBroker` publication |
|
|
334
|
+
| `schedule:run` | `ScheduledRunEvent` | `@znight/schedule`, one occurrence of a job (executed or not, see `outcome`) |
|
|
335
|
+
|
|
336
|
+
`ScheduledRunEvent` carries `job`, `kind` (`cron` / `interval` / `timeout`), `host`, `method`,
|
|
337
|
+
`runId`, `trigger` (`schedule` / `start` / `manual` / `queued`), `scheduledAt` (epoch ms),
|
|
338
|
+
`context` (the `JobContext`), `startTime`, and, once settled, `outcome` (`completed` / `failed` /
|
|
339
|
+
`skipped` / `not-acquired`), `error` and `aborted` (`timeout` / `lock-lost` / `removed` /
|
|
340
|
+
`shutdown`). Runs handed to the job are wrapped with `hooks.run`, then emitted; occurrences that
|
|
341
|
+
never reach the wrappers (`context` is `undefined`) are only emitted. Listeners always see the final
|
|
342
|
+
`outcome`. Wrappers do not: it is `undefined` until the job settles (`completed` when `next()`
|
|
343
|
+
resolves, `failed` with `error` when the job threw), and stays `undefined` when the job did not
|
|
344
|
+
start: a wrapper vetoed the run (returned without calling `next()`), the run was dropped while the
|
|
345
|
+
wrappers waited (job removed or paused, scheduler stopping), or a wrapper threw first (`next()`
|
|
346
|
+
rejects). The producer settles it after `hooks.run` (`skipped`, or `failed` for a wrapper that
|
|
347
|
+
threw), so a wrapper that reports it reads `event.outcome ?? (nextRejected ? 'failed' : 'skipped')`.
|
|
348
|
+
|
|
349
|
+
Other packages declare their hooks by augmenting `ZnightHooks`:
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
declare module '@znight/core' {
|
|
353
|
+
interface ZnightHooks {
|
|
354
|
+
'acme:sync': AcmeSyncEvent
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
hooks.on('http:request', event => metrics.count(event.route)) // observe; errors are logged, never thrown
|
|
359
|
+
// wrap; the first registered wrapper is the outermost
|
|
360
|
+
hooks.wrap('http:request', async (event, next) => {
|
|
361
|
+
const started = performance.now()
|
|
362
|
+
try {
|
|
363
|
+
return await next()
|
|
364
|
+
} finally {
|
|
365
|
+
histogram.observe(performance.now() - started)
|
|
366
|
+
}
|
|
367
|
+
})
|
|
368
|
+
await hooks.run('http:request', event, () => handle(event))
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## AOP
|
|
372
|
+
|
|
373
|
+
Interceptors are providers implementing `MethodInterceptor`. They are installed by the container right
|
|
374
|
+
after the factory, as own properties of the instance (no Proxy): `#private` fields keep working and
|
|
375
|
+
`this.method()` calls are intercepted too. The top-most `@Intercept` is the outermost interceptor.
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
@Injectable()
|
|
379
|
+
export class TimingInterceptor implements MethodInterceptor<{ label?: string }> {
|
|
380
|
+
async intercept(invocation: MethodInvocation, options: { label?: string }) {
|
|
381
|
+
const started = performance.now()
|
|
382
|
+
try {
|
|
383
|
+
return await invocation.proceed() // or proceed(newArgs)
|
|
384
|
+
} finally {
|
|
385
|
+
console.log(options.label ?? invocation.methodName, performance.now() - started)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
@Injectable()
|
|
391
|
+
export class UserService {
|
|
392
|
+
@Intercept(TimingInterceptor, { label: 'users.find' })
|
|
393
|
+
async findById(id: string) {}
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
For a synchronous method, an interceptor must return synchronously: in development
|
|
398
|
+
(`NODE_ENV !== 'production'`) returning a promise throws a `ZNIGHT005` error naming the interceptor.
|
|
399
|
+
|
|
400
|
+
## Errors
|
|
401
|
+
|
|
402
|
+
Every runtime error is a `ZnightError` with a `code` (`ZNIGHT001`...), `hints` and, when relevant, a
|
|
403
|
+
`cause`. `InstantiationError` (`ZNIGHT015`) adds `token`, `path` and `source`; `ZnightAggregateError`
|
|
404
|
+
(`ZNIGHT021`) adds `errors`. Cleanup failures that happened while handling an error are attached to it:
|
|
405
|
+
read them with `getSuppressedErrors(error)`. The catalogue is exported as `ErrorCode` /
|
|
406
|
+
`ERROR_DESCRIPTIONS` and documented in the [error reference](../../docs/reference/error-codes.md) and
|
|
407
|
+
the [architecture specification](../../docs/internals/architecture.md#48-validations).
|
|
408
|
+
|
|
409
|
+
The catalogue is shared by the compiler and every first-party package. `ZNIGHT001`–`ZNIGHT029`
|
|
410
|
+
belong to the core, the compiler and the features of the first release (`ZNIGHT023`, a missing
|
|
411
|
+
route decision, is reported by `@znight/http`). From `ZNIGHT030`, each package owns a block of ten:
|
|
412
|
+
`ZNIGHT030`–`039` `@znight/openapi`, `ZNIGHT040`–`049` `@znight/schedule` (`050`–`059` and
|
|
413
|
+
`060`–`069` are reserved for `@znight/websocket` and `@znight/graphql`, planned for v0.2). Inside a
|
|
414
|
+
block, codes are allocated in order; third-party packages get no `ZNIGHT` code.
|