@zerotal/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/package.json +72 -0
- package/src/application/Application.ts +1671 -0
- package/src/application/BootDoctor.ts +108 -0
- package/src/application/DevErrorPage.ts +567 -0
- package/src/application/ExceptionHandler.ts +183 -0
- package/src/application/currentApp.ts +73 -0
- package/src/assets/assets.ts +79 -0
- package/src/assets/index.ts +16 -0
- package/src/auth/AuthenticatedUser.ts +18 -0
- package/src/build/PackageLinter.ts +146 -0
- package/src/build/PackageScaffold.ts +127 -0
- package/src/build/codemod.ts +64 -0
- package/src/build/index.ts +12 -0
- package/src/command/Command.ts +254 -0
- package/src/command/CommandRunner.ts +593 -0
- package/src/command/OutputWriter.ts +61 -0
- package/src/command/builtin/CompileCommand.ts +46 -0
- package/src/command/builtin/CssBuildCommand.ts +71 -0
- package/src/command/builtin/KeyGenerateCommand.ts +58 -0
- package/src/command/builtin/LintPackagesCommand.ts +72 -0
- package/src/command/builtin/MakeCommandCommand.ts +85 -0
- package/src/command/builtin/MakeControllerCommand.ts +95 -0
- package/src/command/builtin/MakeEventCommand.ts +85 -0
- package/src/command/builtin/MakeJobCommand.ts +53 -0
- package/src/command/builtin/MakeListenerCommand.ts +35 -0
- package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
- package/src/command/builtin/MakeNotificationCommand.ts +48 -0
- package/src/command/builtin/MakeObserverCommand.ts +78 -0
- package/src/command/builtin/MakePackageCommand.ts +45 -0
- package/src/command/builtin/MakePolicyCommand.ts +66 -0
- package/src/command/builtin/MakeProviderCommand.ts +75 -0
- package/src/command/builtin/MakeRequestCommand.ts +47 -0
- package/src/command/builtin/MakeResourceCommand.ts +61 -0
- package/src/command/builtin/MakeTestCommand.ts +120 -0
- package/src/command/builtin/ReloadCommand.ts +52 -0
- package/src/command/builtin/ReplCommand.ts +174 -0
- package/src/command/builtin/RouteListCommand.ts +188 -0
- package/src/command/builtin/ServeCommand.ts +321 -0
- package/src/command/builtin/StartCommand.ts +3 -0
- package/src/command/builtin/StatusCommand.ts +71 -0
- package/src/command/builtin/TestCommand.ts +172 -0
- package/src/command/builtin/WorkerCommand.ts +27 -0
- package/src/command/builtin/index.ts +53 -0
- package/src/command/scaffold/worker.ts.txt +12 -0
- package/src/command/scaffold/zerotal.ts.txt +26 -0
- package/src/command/startZerotal.ts +55 -0
- package/src/config/AppConfig.ts +253 -0
- package/src/config/ConfigLoader.ts +117 -0
- package/src/config/ConfigManager.ts +169 -0
- package/src/config/index.ts +46 -0
- package/src/config/registry.ts +59 -0
- package/src/config/validation.ts +117 -0
- package/src/container/Container.ts +606 -0
- package/src/container/ContextualBindingBuilder.ts +57 -0
- package/src/container/ScopedResolver.ts +117 -0
- package/src/container/index.ts +32 -0
- package/src/container/inject.ts +55 -0
- package/src/container/types.ts +71 -0
- package/src/context/RequestContext.ts +91 -0
- package/src/contracts/auth.ts +24 -0
- package/src/contracts/index.ts +23 -0
- package/src/contracts/session.ts +70 -0
- package/src/contracts/transaction.ts +26 -0
- package/src/conventions/ConventionLoader.ts +128 -0
- package/src/conventions/builtinConcerns.ts +131 -0
- package/src/crypt/Crypt.ts +141 -0
- package/src/crypt/URLSigner.ts +96 -0
- package/src/datetime/Carbon.ts +1396 -0
- package/src/datetime/CarbonInterval.ts +421 -0
- package/src/datetime/clock.ts +28 -0
- package/src/datetime/index.ts +23 -0
- package/src/datetime/temporal-shim.ts +1 -0
- package/src/dev/BuildOutput.ts +131 -0
- package/src/dev/CssPlugins.ts +184 -0
- package/src/dev/DevBuildHook.ts +74 -0
- package/src/dev/DevOrchestrator.ts +213 -0
- package/src/dev/DevReloadMiddleware.ts +101 -0
- package/src/dev/DevReloadServer.ts +85 -0
- package/src/dev/DevWsServer.ts +45 -0
- package/src/dev/index.ts +19 -0
- package/src/dev/reloadClient.ts +39 -0
- package/src/env/Def.ts +232 -0
- package/src/env/EnvSchema.ts +105 -0
- package/src/env/index.ts +34 -0
- package/src/env/t.ts +128 -0
- package/src/errors/ConfigError.ts +12 -0
- package/src/errors/ContainerErrors.ts +143 -0
- package/src/errors/HttpError.ts +127 -0
- package/src/errors/ValidationError.ts +19 -0
- package/src/errors/ZerotalError.ts +25 -0
- package/src/errors/index.ts +46 -0
- package/src/events/CallQueuedListener.ts +66 -0
- package/src/events/Emitter.ts +280 -0
- package/src/events/EventFake.ts +160 -0
- package/src/events/FrameworkEvents.ts +252 -0
- package/src/facade/Facade.ts +101 -0
- package/src/facade/facades/App.ts +155 -0
- package/src/facade/facades/Artisan.ts +63 -0
- package/src/facade/facades/Config.ts +21 -0
- package/src/facade/facades/Events.ts +19 -0
- package/src/facade/facades/index.ts +28 -0
- package/src/global.d.ts +9 -0
- package/src/hash/Hash.ts +60 -0
- package/src/health/Health.ts +221 -0
- package/src/health/index.ts +27 -0
- package/src/helpers/Collection.ts +435 -0
- package/src/helpers/config.ts +59 -0
- package/src/helpers/fluent.ts +52 -0
- package/src/helpers/html.ts +11 -0
- package/src/helpers/index.ts +266 -0
- package/src/helpers/make.ts +35 -0
- package/src/helpers/markdown.ts +73 -0
- package/src/helpers/pageElements.ts +27 -0
- package/src/helpers/request.ts +62 -0
- package/src/helpers/response.ts +411 -0
- package/src/helpers/str.ts +208 -0
- package/src/http/Http.ts +298 -0
- package/src/http/HttpClient.ts +289 -0
- package/src/http/Resource.ts +171 -0
- package/src/http/UploadedFile.ts +204 -0
- package/src/http/Uri.ts +490 -0
- package/src/http/index.ts +46 -0
- package/src/http/negotiate.ts +213 -0
- package/src/http/originGuard.ts +76 -0
- package/src/http/sniffContentType.ts +105 -0
- package/src/http/url.ts +204 -0
- package/src/http/withHeaders.ts +24 -0
- package/src/index.ts +250 -0
- package/src/lock/LockManager.ts +228 -0
- package/src/lock/config.ts +49 -0
- package/src/lock/drivers/LockDriver.ts +32 -0
- package/src/lock/drivers/MemoryLockDriver.ts +52 -0
- package/src/lock/drivers/RedisLockDriver.ts +58 -0
- package/src/lock/drivers/SqliteLockDriver.ts +85 -0
- package/src/lock/errors.ts +20 -0
- package/src/lock/facades/Lock.ts +114 -0
- package/src/lock/index.ts +53 -0
- package/src/logger/Log.ts +35 -0
- package/src/logger/LogManager.ts +430 -0
- package/src/logger/LoggerMiddleware.ts +125 -0
- package/src/logger/channels/ConsoleChannel.ts +139 -0
- package/src/logger/channels/DailyChannel.ts +74 -0
- package/src/logger/channels/NullChannel.ts +17 -0
- package/src/logger/channels/SingleChannel.ts +34 -0
- package/src/logger/channels/StackChannel.ts +29 -0
- package/src/logger/config.ts +90 -0
- package/src/logger/format.ts +96 -0
- package/src/logger/frameworkLog.ts +93 -0
- package/src/logger/index.ts +68 -0
- package/src/logger/renderTable.ts +111 -0
- package/src/logger/types.ts +212 -0
- package/src/macros/config.macro.ts +50 -0
- package/src/metrics/HttpMetrics.ts +114 -0
- package/src/metrics/index.ts +18 -0
- package/src/middleware/BaseMiddleware.ts +72 -0
- package/src/middleware/CorsMiddleware.ts +152 -0
- package/src/middleware/RateLimiter.ts +255 -0
- package/src/middleware/SecureHeadersMiddleware.ts +127 -0
- package/src/middleware/ThrottleMiddleware.ts +252 -0
- package/src/middleware/WebhookMiddleware.ts +204 -0
- package/src/pipeline/ContextRegistry.ts +42 -0
- package/src/pipeline/HttpContext.ts +865 -0
- package/src/pipeline/Pipeline.ts +150 -0
- package/src/pipeline/currentPage.ts +46 -0
- package/src/pipeline/types.ts +80 -0
- package/src/provider/LockProvider.ts +64 -0
- package/src/provider/LogProvider.ts +137 -0
- package/src/provider/ServiceProvider.ts +84 -0
- package/src/provider/StorageProvider.ts +45 -0
- package/src/router/FileRouter.ts +526 -0
- package/src/router/Route.ts +76 -0
- package/src/router/RouteHandler.ts +335 -0
- package/src/router/Router.ts +1247 -0
- package/src/router/domain.ts +65 -0
- package/src/security/index.ts +22 -0
- package/src/storage/FakeDisk.ts +233 -0
- package/src/storage/StorageFilesMiddleware.ts +150 -0
- package/src/storage/StorageManager.ts +173 -0
- package/src/storage/config.ts +47 -0
- package/src/storage/drivers/LocalDriver.ts +138 -0
- package/src/storage/drivers/S3Driver.ts +169 -0
- package/src/storage/errors.ts +135 -0
- package/src/storage/facades/Storage.ts +3 -0
- package/src/storage/global.d.ts +7 -0
- package/src/storage/index.ts +22 -0
- package/src/storage/root.ts +59 -0
- package/src/storage/types.ts +104 -0
- package/src/support/appKey.ts +38 -0
- package/src/support/cookie.ts +72 -0
- package/src/support/crypto.ts +52 -0
- package/src/support/deepMerge.ts +117 -0
- package/src/support/env.ts +71 -0
- package/src/support/network.ts +79 -0
- package/src/support/port.ts +197 -0
- package/src/support/str.ts +122 -0
- package/src/view/FileRouteResolver.ts +59 -0
- package/src/view/index.ts +144 -0
- package/src/view/jsx-runtime.ts +233 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The class-based, typed application event emitter: events are plain classes,
|
|
3
|
+
* listeners are classes with a `handle()` method, and dispatch can run async
|
|
4
|
+
* (fire-and-forget), synchronously, or be deferred to a queue.
|
|
5
|
+
*/
|
|
6
|
+
import { CallQueuedListener } from "./CallQueuedListener.ts";
|
|
7
|
+
|
|
8
|
+
type EventClass<T extends object> = new (...args: unknown[]) => T;
|
|
9
|
+
type ListenerClass<T extends object> = new (...args: unknown[]) => {
|
|
10
|
+
handle(event: T): Promise<void> | void;
|
|
11
|
+
queue?: boolean | string;
|
|
12
|
+
maxAttempts?: number;
|
|
13
|
+
retryDelay?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** A listener that opts into deferred execution by declaring a `queue` target. */
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the event payload type is listener-specific and not known at this boundary.
|
|
18
|
+
export interface QueuedListener<T = any> {
|
|
19
|
+
queue: boolean | string;
|
|
20
|
+
maxAttempts?: number;
|
|
21
|
+
retryDelay?: number;
|
|
22
|
+
handle(event: T): Promise<void> | void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The class-based application event emitter: register listener classes against
|
|
27
|
+
* event classes, then dispatch event instances to them.
|
|
28
|
+
*
|
|
29
|
+
* Events are plain classes and listeners are classes exposing a
|
|
30
|
+
* `handle(event)` method. Dispatch can run concurrently and fire-and-forget
|
|
31
|
+
* ({@link Emitter.emit}), one-at-a-time ({@link Emitter.emitSync}), or be
|
|
32
|
+
* deferred to the queue when a listener declares a `queue` target and a queue
|
|
33
|
+
* manager is registered. Listener failures during {@link Emitter.emit} are
|
|
34
|
+
* isolated and logged, never rethrown to the caller.
|
|
35
|
+
*
|
|
36
|
+
* Reached in applications through the `Events` facade; construct directly only
|
|
37
|
+
* in tests or bespoke wiring.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* import { Emitter } from "@zerotal/core";
|
|
42
|
+
*
|
|
43
|
+
* class UserRegistered {
|
|
44
|
+
* constructor(readonly userId: string) {}
|
|
45
|
+
* }
|
|
46
|
+
*
|
|
47
|
+
* class SendWelcomeEmail {
|
|
48
|
+
* async handle(event: UserRegistered): Promise<void> {
|
|
49
|
+
* // await mail.to(event.userId).send(new Welcome());
|
|
50
|
+
* }
|
|
51
|
+
* }
|
|
52
|
+
*
|
|
53
|
+
* const emitter = new Emitter();
|
|
54
|
+
* emitter.on(UserRegistered, SendWelcomeEmail);
|
|
55
|
+
* await emitter.emit(new UserRegistered("u_123"));
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export class Emitter {
|
|
59
|
+
private _listeners = new Map<Function, ListenerClass<object>[]>();
|
|
60
|
+
private _listenerByName = new Map<string, ListenerClass<object>>();
|
|
61
|
+
|
|
62
|
+
// Holds the application so the emitter can resolve the queue manager lazily.
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Application is typed elsewhere; depending on it here would create a cross-module cycle.
|
|
64
|
+
private _application: any;
|
|
65
|
+
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- see _application above.
|
|
67
|
+
constructor(application?: any) {
|
|
68
|
+
this._application = application;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Broadcaster hook, owned per emitter (i.e. per application). @zerotal/broadcasting's
|
|
72
|
+
// provider installs it so emitting a broadcastable event (one with a `broadcastOn()`
|
|
73
|
+
// method) also broadcasts it, keeping core broadcasting-free via this indirection.
|
|
74
|
+
private _broadcaster: ((event: object) => void) | null = null;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Install the hook that broadcasts emitted events implementing `broadcastOn()`.
|
|
78
|
+
* Set by `@zerotal/broadcasting`'s provider on the application's emitter; apps
|
|
79
|
+
* never call this. Pass `null` to disable.
|
|
80
|
+
*
|
|
81
|
+
* @internal
|
|
82
|
+
*/
|
|
83
|
+
setBroadcaster(fn: ((event: object) => void) | null): void {
|
|
84
|
+
this._broadcaster = fn;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private _maybeBroadcast(event: object): void {
|
|
88
|
+
if (!this._broadcaster) return;
|
|
89
|
+
if (typeof (event as { broadcastOn?: unknown }).broadcastOn !== "function") return;
|
|
90
|
+
try {
|
|
91
|
+
this._broadcaster(event);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error("[Zerotal] broadcaster hook failed:", error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Registration ──────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Register a listener class to run whenever the given event class is emitted.
|
|
101
|
+
* The same event may have many listeners; they run in registration order.
|
|
102
|
+
*
|
|
103
|
+
* @param eventClass The event class to listen for.
|
|
104
|
+
* @param listenerClass A class with a `handle(event)` method.
|
|
105
|
+
* @category Subscription
|
|
106
|
+
*/
|
|
107
|
+
on<T extends object>(eventClass: EventClass<T>, listenerClass: ListenerClass<T>): void {
|
|
108
|
+
const existing = this._listeners.get(eventClass) ?? [];
|
|
109
|
+
existing.push(listenerClass as ListenerClass<object>);
|
|
110
|
+
this._listeners.set(eventClass, existing);
|
|
111
|
+
|
|
112
|
+
// A queued listener crosses the queue boundary as a bare class name, so the name→class
|
|
113
|
+
// index is how a worker finds it again. Two listeners sharing a name — `SendNotification`
|
|
114
|
+
// in two modules is not a stretch — silently overwrote each other, so every queued job
|
|
115
|
+
// for one ran the other's handler. There is no way to tell them apart after
|
|
116
|
+
// serialisation, so the collision is refused where it can still be pointed at.
|
|
117
|
+
const registered = this._listenerByName.get(listenerClass.name);
|
|
118
|
+
if (registered && registered !== listenerClass) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`[Zerotal] Two listener classes are both named "${listenerClass.name}".\n` +
|
|
121
|
+
`A queued listener is dispatched by class name, so the second registration would ` +
|
|
122
|
+
`silently take over the first one's jobs. Rename one of them.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
this._listenerByName.set(listenerClass.name, listenerClass as ListenerClass<object>);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Remove a previously registered listener for the given event class. No-op if
|
|
130
|
+
* the listener was never registered.
|
|
131
|
+
*
|
|
132
|
+
* @category Subscription
|
|
133
|
+
*/
|
|
134
|
+
off<T extends object>(eventClass: EventClass<T>, listenerClass: ListenerClass<T>): void {
|
|
135
|
+
const existing = this._listeners.get(eventClass);
|
|
136
|
+
if (!existing) return;
|
|
137
|
+
const updated = existing.filter((listener) => listener !== listenerClass);
|
|
138
|
+
if (updated.length === 0) {
|
|
139
|
+
this._listeners.delete(eventClass);
|
|
140
|
+
} else {
|
|
141
|
+
this._listeners.set(eventClass, updated);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Drop the name index too, once nothing is listening through this class. Leaving it
|
|
145
|
+
// meant a de-registered listener still ran for every queued job that named it — `off()`
|
|
146
|
+
// looked like it worked and did not — and the index grew without bound.
|
|
147
|
+
const stillRegistered = [...this._listeners.values()].some((listeners) =>
|
|
148
|
+
listeners.includes(listenerClass as ListenerClass<object>),
|
|
149
|
+
);
|
|
150
|
+
if (!stillRegistered) this._listenerByName.delete(listenerClass.name);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Dispatch ──────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Dispatch an event to all its listeners concurrently, deferring any listener
|
|
157
|
+
* that declares a `queue` target to the queue manager when one is available.
|
|
158
|
+
* Listener failures are isolated and logged, never rethrown to the caller.
|
|
159
|
+
* Broadcastable events (those with a `broadcastOn()` method) are broadcast
|
|
160
|
+
* first, so they fire even when no listeners are registered.
|
|
161
|
+
*
|
|
162
|
+
* @param event The event instance; its constructor selects the listeners.
|
|
163
|
+
* @returns Resolves once every listener has settled (queued ones once enqueued).
|
|
164
|
+
* @category Dispatch
|
|
165
|
+
*/
|
|
166
|
+
async emit<T extends object>(event: T): Promise<void> {
|
|
167
|
+
// Broadcast first (synchronously) so it fires even for events with no listeners.
|
|
168
|
+
this._maybeBroadcast(event);
|
|
169
|
+
|
|
170
|
+
// A snapshot, not the live array: a listener that registers another listener for the
|
|
171
|
+
// same event otherwise extends the array being iterated. One emit ran handle() 100,000
|
|
172
|
+
// times that way.
|
|
173
|
+
const listenerClasses = [...(this._listeners.get(event.constructor as Function) ?? [])];
|
|
174
|
+
|
|
175
|
+
if (listenerClasses.length === 0) return;
|
|
176
|
+
|
|
177
|
+
await Promise.allSettled(
|
|
178
|
+
listenerClasses.map(async (ListenerClass) => {
|
|
179
|
+
const listener = new ListenerClass();
|
|
180
|
+
|
|
181
|
+
if (listener.queue) {
|
|
182
|
+
let queueManager;
|
|
183
|
+
if (typeof this._application?.container?.make === "function") {
|
|
184
|
+
try {
|
|
185
|
+
queueManager = await this._application.container.make("queue");
|
|
186
|
+
} catch {
|
|
187
|
+
// No queue manager is registered; fall through to synchronous execution.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (queueManager) {
|
|
192
|
+
const job = new CallQueuedListener(
|
|
193
|
+
ListenerClass.name,
|
|
194
|
+
event.constructor.name,
|
|
195
|
+
event,
|
|
196
|
+
listener.queue,
|
|
197
|
+
listener.maxAttempts,
|
|
198
|
+
listener.retryDelay,
|
|
199
|
+
);
|
|
200
|
+
await queueManager.dispatch(job);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Run inline when the listener is not queued, or no queue manager exists.
|
|
206
|
+
try {
|
|
207
|
+
await listener.handle(event);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
console.error(
|
|
210
|
+
`[Zerotal] Event listener ${ListenerClass.name} threw for ${event.constructor.name}:`,
|
|
211
|
+
error,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Dispatch an event to its listeners one at a time, awaiting each in turn.
|
|
220
|
+
* Unlike {@link Emitter.emit}, listeners run inline (never queued) and a
|
|
221
|
+
* thrown error is not caught — it propagates to the caller.
|
|
222
|
+
*
|
|
223
|
+
* @param event The event instance; its constructor selects the listeners.
|
|
224
|
+
* @category Dispatch
|
|
225
|
+
*/
|
|
226
|
+
async emitSync<T extends object>(event: T): Promise<void> {
|
|
227
|
+
this._maybeBroadcast(event);
|
|
228
|
+
|
|
229
|
+
// A snapshot, not the live array: a listener that registers another listener for the
|
|
230
|
+
// same event otherwise extends the array being iterated. One emit ran handle() 100,000
|
|
231
|
+
// times that way.
|
|
232
|
+
const listenerClasses = [...(this._listeners.get(event.constructor as Function) ?? [])];
|
|
233
|
+
|
|
234
|
+
for (const ListenerClass of listenerClasses) {
|
|
235
|
+
const listener = new ListenerClass();
|
|
236
|
+
await listener.handle(event);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Run a deferred listener by name with its raw payload. Called by the queue
|
|
242
|
+
* worker, not application code.
|
|
243
|
+
*
|
|
244
|
+
* @param listenerName The registered listener class name.
|
|
245
|
+
* @param eventPayload The raw payload handed straight to `handle()` (for most
|
|
246
|
+
* listeners the payload is the event).
|
|
247
|
+
* @throws {Error} If no listener is registered under `listenerName`.
|
|
248
|
+
* @category Dispatch
|
|
249
|
+
*/
|
|
250
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the payload type is listener-specific and lost across the queue boundary.
|
|
251
|
+
async dispatchQueuedListener(listenerName: string, eventPayload: any): Promise<void> {
|
|
252
|
+
const ListenerClass = this._listenerByName.get(listenerName);
|
|
253
|
+
if (!ListenerClass) {
|
|
254
|
+
throw new Error(`Listener ${listenerName} not found in Emitter registry.`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const listener = new ListenerClass();
|
|
258
|
+
|
|
259
|
+
// Without an event-class registry we can't rebuild the original event, so we
|
|
260
|
+
// hand the listener the raw payload — for most listeners the payload is the event.
|
|
261
|
+
await listener.handle(eventPayload);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Whether any listener is registered for the given event class.
|
|
266
|
+
* @category Subscription
|
|
267
|
+
*/
|
|
268
|
+
hasListeners<T extends object>(eventClass: EventClass<T>): boolean {
|
|
269
|
+
return (this._listeners.get(eventClass)?.length ?? 0) > 0;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Remove every registered listener.
|
|
274
|
+
* @category Subscription
|
|
275
|
+
*/
|
|
276
|
+
clear(): void {
|
|
277
|
+
this._listeners.clear();
|
|
278
|
+
this._listenerByName.clear();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The testing counterpart to {@link Emitter}: records emitted events instead of
|
|
3
|
+
* running their listeners, so a test can assert that an action announced what it
|
|
4
|
+
* did without also running everything that reacts to it.
|
|
5
|
+
*/
|
|
6
|
+
import type { Application } from "../application/Application.ts";
|
|
7
|
+
import { currentApp } from "../application/currentApp.ts";
|
|
8
|
+
import type { Binding } from "../container/types.ts";
|
|
9
|
+
import { Emitter } from "./Emitter.ts";
|
|
10
|
+
|
|
11
|
+
/** An event's class, as the assertions receive it. */
|
|
12
|
+
type EventClass<T extends object> = (abstract new (...args: never[]) => T) & {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Drop-in replacement for {@link Emitter} that captures emitted events instead
|
|
18
|
+
* of dispatching them to listeners. Install at the start of a test, restore
|
|
19
|
+
* after.
|
|
20
|
+
*
|
|
21
|
+
* Faking the emitter is what lets a test assert "publishing a post announces
|
|
22
|
+
* `PostPublished`" without the listeners for that event — mail, search
|
|
23
|
+
* indexing, cache invalidation — running as a side effect. To test a listener,
|
|
24
|
+
* do not fake: construct the listener and hand it an event directly.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* const events = EventFake.install();
|
|
28
|
+
*
|
|
29
|
+
* await post.publish();
|
|
30
|
+
*
|
|
31
|
+
* events.assertEmitted(PostPublished);
|
|
32
|
+
* events.assertEmitted(PostPublished, (e) => e.postId === post.id);
|
|
33
|
+
* events.assertNotEmitted(PostDeleted);
|
|
34
|
+
*
|
|
35
|
+
* events.restore(); // call in afterEach
|
|
36
|
+
*/
|
|
37
|
+
export class EventFake extends Emitter {
|
|
38
|
+
private readonly _emitted: object[] = [];
|
|
39
|
+
|
|
40
|
+
private constructor(
|
|
41
|
+
private readonly _app: Application,
|
|
42
|
+
private readonly _original: Binding<unknown> | undefined,
|
|
43
|
+
) {
|
|
44
|
+
super();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Replace the `events` container binding with this fake. */
|
|
48
|
+
static install(): EventFake {
|
|
49
|
+
const app = currentApp();
|
|
50
|
+
const fake = new EventFake(app, app.container.registry.get("events"));
|
|
51
|
+
app.container.value("events", fake);
|
|
52
|
+
return fake;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Restore the original `events` binding. Call in afterEach. */
|
|
56
|
+
restore(): void {
|
|
57
|
+
if (this._original !== undefined) {
|
|
58
|
+
this._app.container.registry.set("events", this._original);
|
|
59
|
+
} else {
|
|
60
|
+
this._app.container.registry.delete("events");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Emitter interface ─────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/** Capture the event — its listeners do NOT run. */
|
|
67
|
+
override async emit<T extends object>(event: T): Promise<void> {
|
|
68
|
+
this._emitted.push(event);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Capture the event — its listeners do NOT run. */
|
|
72
|
+
override async emitSync<T extends object>(event: T): Promise<void> {
|
|
73
|
+
this._emitted.push(event);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Discard the captured events, and any listeners registered on the fake. */
|
|
77
|
+
override clear(): void {
|
|
78
|
+
super.clear();
|
|
79
|
+
this._emitted.length = 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Inspection ────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/** Every event captured, in emit order. */
|
|
85
|
+
emitted(): object[] {
|
|
86
|
+
return [...this._emitted];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The captured events of the given class, narrowed to its type. */
|
|
90
|
+
emittedOf<T extends object>(EventType: EventClass<T>): T[] {
|
|
91
|
+
return this._emitted.filter((e): e is T => e instanceof EventType);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Assertions ────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Assert an event of the given class was emitted, optionally one matching
|
|
98
|
+
* `filter`.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* events.assertEmitted(OrderPlaced);
|
|
102
|
+
* events.assertEmitted(OrderPlaced, (e) => e.total === 4999);
|
|
103
|
+
*/
|
|
104
|
+
assertEmitted<T extends object>(EventType: EventClass<T>, filter?: (event: T) => boolean): void {
|
|
105
|
+
const matches = this.emittedOf(EventType);
|
|
106
|
+
if (matches.length === 0) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`assertEmitted: expected ${_name(EventType)} to be emitted, but ${this._summary()}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (filter && !matches.some(filter)) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`assertEmitted: ${matches.length} ${_name(EventType)} event(s) were emitted, but none ` +
|
|
114
|
+
`matched the filter. Emitted: ${JSON.stringify(matches)}.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Assert no event of the given class was emitted. */
|
|
120
|
+
assertNotEmitted<T extends object>(
|
|
121
|
+
EventType: EventClass<T>,
|
|
122
|
+
filter?: (event: T) => boolean,
|
|
123
|
+
): void {
|
|
124
|
+
const matches = this.emittedOf(EventType);
|
|
125
|
+
const offending = filter ? matches.filter(filter) : matches;
|
|
126
|
+
if (offending.length > 0) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`assertNotEmitted: expected no ${_name(EventType)} but ${offending.length} were emitted: ` +
|
|
129
|
+
`${JSON.stringify(offending)}.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Assert exactly `count` events of the given class were emitted. */
|
|
135
|
+
assertEmittedCount<T extends object>(EventType: EventClass<T>, count: number): void {
|
|
136
|
+
const actual = this.emittedOf(EventType).length;
|
|
137
|
+
if (actual !== count) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`assertEmittedCount: expected ${count} ${_name(EventType)} event(s) but got ${actual}.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Assert no events at all were emitted. */
|
|
145
|
+
assertNothingEmitted(): void {
|
|
146
|
+
if (this._emitted.length > 0) {
|
|
147
|
+
throw new Error(`assertNothingEmitted: ${this._summary()}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private _summary(): string {
|
|
152
|
+
if (this._emitted.length === 0) return "no events were emitted at all.";
|
|
153
|
+
const names = this._emitted.map((e) => e.constructor.name);
|
|
154
|
+
return `the events emitted were [${names.join(", ")}].`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function _name(EventType: EventClass<object>): string {
|
|
159
|
+
return EventType.name;
|
|
160
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight synchronous event bus for framework instrumentation.
|
|
3
|
+
*
|
|
4
|
+
* ORM, Mail, Cache, and Queue emit lifecycle events here.
|
|
5
|
+
* Infrastructure packages (DevTools, Logging, Metrics) subscribe to react.
|
|
6
|
+
*
|
|
7
|
+
* Handlers fire synchronously in the emitter's call stack — keep them fast
|
|
8
|
+
* and side-effect-free (buffer pushes, counter increments). For I/O or heavy
|
|
9
|
+
* work, dispatch a queue job from inside the handler instead.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic "any event class" constructor bound; the args are never read through this type
|
|
13
|
+
type EventCtor = new (...args: any[]) => object;
|
|
14
|
+
type Handler<E> = (event: E) => void;
|
|
15
|
+
|
|
16
|
+
// Events are keyed two ways: by class identity (subscribe with the class) and by
|
|
17
|
+
// a stable string "kind" (subscribe with the string). Code that owns an event
|
|
18
|
+
// subscribes with the class; a decoupled observer that must not import the class
|
|
19
|
+
// — telemetry, the monitor — subscribes by kind. An event's kind is its optional
|
|
20
|
+
// `static kind`, defaulting to the class name.
|
|
21
|
+
const _byClass = new Map<EventCtor, Set<Handler<object>>>();
|
|
22
|
+
const _byKind = new Map<string, Set<Handler<object>>>();
|
|
23
|
+
|
|
24
|
+
/** The stable string key for an event class: its `static kind`, or its class name. */
|
|
25
|
+
function _kindOf(ctor: EventCtor): string {
|
|
26
|
+
return (ctor as { kind?: string }).kind ?? ctor.name;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The framework instrumentation event bus — subscribe with `on`, fire with
|
|
31
|
+
* `emit`. Separate from the class-based {@link Emitter} used for application
|
|
32
|
+
* events: this bus is synchronous, static, and carries the framework's own
|
|
33
|
+
* lifecycle signals ({@link QueryExecuted}, {@link RequestHandled},
|
|
34
|
+
* {@link JobRan}, …) that observability packages subscribe to.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* import { FrameworkEvents, QueryExecuted } from "@zerotal/core";
|
|
39
|
+
*
|
|
40
|
+
* // In a provider's onBooting(): watch every SQL query.
|
|
41
|
+
* const off = FrameworkEvents.on(QueryExecuted, (e) => {
|
|
42
|
+
* if (e.durationMs > 100) console.warn(`slow query (${e.durationMs}ms): ${e.sql}`);
|
|
43
|
+
* });
|
|
44
|
+
*
|
|
45
|
+
* // In onStopping(): unsubscribe to avoid handler leaks.
|
|
46
|
+
* off();
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export const FrameworkEvents = {
|
|
50
|
+
/**
|
|
51
|
+
* Subscribe to a framework event. Returns an unsubscribe function —
|
|
52
|
+
* call it in provider.onStopping() to avoid handler leaks.
|
|
53
|
+
*
|
|
54
|
+
* Pass the **event class** to listen by identity (the usual case, when you own
|
|
55
|
+
* or can import the class), or a **string kind** to listen without importing
|
|
56
|
+
* the class — the way a decoupled observer (telemetry, the monitor) subscribes
|
|
57
|
+
* to a satellite package's events.
|
|
58
|
+
*
|
|
59
|
+
* @param target The event class to listen for, or its string {@link _kindOf | kind}.
|
|
60
|
+
* @param handler Synchronous callback invoked with each emitted instance.
|
|
61
|
+
* @returns A function that removes this subscription when called.
|
|
62
|
+
* @category Subscription
|
|
63
|
+
*/
|
|
64
|
+
on<E extends object>(
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic "any event class" constructor bound
|
|
66
|
+
target: (new (...args: any[]) => E) | string,
|
|
67
|
+
handler: Handler<E>,
|
|
68
|
+
): () => void {
|
|
69
|
+
const handlers = ((): Set<Handler<object>> => {
|
|
70
|
+
if (typeof target === "string") {
|
|
71
|
+
const existing = _byKind.get(target);
|
|
72
|
+
if (existing) return existing;
|
|
73
|
+
const set = new Set<Handler<object>>();
|
|
74
|
+
_byKind.set(target, set);
|
|
75
|
+
return set;
|
|
76
|
+
}
|
|
77
|
+
const ctor = target as EventCtor;
|
|
78
|
+
const existing = _byClass.get(ctor);
|
|
79
|
+
if (existing) return existing;
|
|
80
|
+
const set = new Set<Handler<object>>();
|
|
81
|
+
_byClass.set(ctor, set);
|
|
82
|
+
return set;
|
|
83
|
+
})();
|
|
84
|
+
handlers.add(handler as Handler<object>);
|
|
85
|
+
return () => {
|
|
86
|
+
handlers.delete(handler as Handler<object>);
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Emit a framework event synchronously to all registered handlers — both those
|
|
92
|
+
* subscribed by class identity and those subscribed by the event's kind string.
|
|
93
|
+
* Errors from handlers are swallowed — subscribers must never affect the caller.
|
|
94
|
+
*
|
|
95
|
+
* @param event The event instance; its constructor (and kind) select the handlers.
|
|
96
|
+
* @category Dispatch
|
|
97
|
+
*/
|
|
98
|
+
emit<E extends object>(event: E): void {
|
|
99
|
+
const ctor = event.constructor as EventCtor;
|
|
100
|
+
const classHandlers = _byClass.get(ctor);
|
|
101
|
+
const kindHandlers = _byKind.get(_kindOf(ctor));
|
|
102
|
+
if (!classHandlers?.size && !kindHandlers?.size) return;
|
|
103
|
+
const fire = (handler: Handler<object>): void => {
|
|
104
|
+
try {
|
|
105
|
+
handler(event);
|
|
106
|
+
} catch {
|
|
107
|
+
// Subscriber errors must never propagate back to the emitting code.
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
if (classHandlers) for (const handler of classHandlers) fire(handler);
|
|
111
|
+
if (kindHandlers) for (const handler of kindHandlers) fire(handler);
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Remove all subscriptions. Call in tests to reset state between suites.
|
|
116
|
+
* @category Subscription
|
|
117
|
+
*/
|
|
118
|
+
clear(): void {
|
|
119
|
+
_byClass.clear();
|
|
120
|
+
_byKind.clear();
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Total number of registered handlers across all event types (class- and
|
|
125
|
+
* kind-keyed). Intended for tests to assert subscriptions were cleaned up.
|
|
126
|
+
*
|
|
127
|
+
* @returns The count of live handlers across every event type.
|
|
128
|
+
* @category Subscription
|
|
129
|
+
*/
|
|
130
|
+
handlerCount(): number {
|
|
131
|
+
let count = 0;
|
|
132
|
+
for (const handlers of _byClass.values()) count += handlers.size;
|
|
133
|
+
for (const handlers of _byKind.values()) count += handlers.size;
|
|
134
|
+
return count;
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// ── Framework event types ─────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
// ── Application lifecycle ──────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Emitted once when the application finishes booting — all providers registered
|
|
144
|
+
* and booted and conventions wired. `durationMs` is the wall-clock boot time, so
|
|
145
|
+
* observers (Health page, telemetry, logger) can surface startup cost.
|
|
146
|
+
*
|
|
147
|
+
* @category Application lifecycle
|
|
148
|
+
* @example
|
|
149
|
+
* ```ts
|
|
150
|
+
* FrameworkEvents.on(AppBooted, (e) => {
|
|
151
|
+
* console.log(`booted in ${e.durationMs}ms (${e.providerCount} providers)`);
|
|
152
|
+
* });
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
export class AppBooted {
|
|
156
|
+
constructor(
|
|
157
|
+
readonly durationMs: number,
|
|
158
|
+
/** Application environment, e.g. "web" | "worker" | "console" | "test" | "repl". */
|
|
159
|
+
readonly environment: string,
|
|
160
|
+
/** Number of active service providers that booted. */
|
|
161
|
+
readonly providerCount: number,
|
|
162
|
+
) {}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Emitted after a complete HTTP request–response cycle.
|
|
167
|
+
* Fired by Application after the middleware pipeline resolves.
|
|
168
|
+
*
|
|
169
|
+
* @category HTTP
|
|
170
|
+
* @example
|
|
171
|
+
* ```ts
|
|
172
|
+
* FrameworkEvents.on(RequestHandled, (e) => {
|
|
173
|
+
* metrics.observe("http.duration_ms", e.durationMs);
|
|
174
|
+
* });
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
export class RequestHandled {
|
|
178
|
+
constructor(
|
|
179
|
+
/** HttpContext — typed as object to avoid cross-package circular deps. */
|
|
180
|
+
readonly ctx: object,
|
|
181
|
+
readonly startMs: number,
|
|
182
|
+
readonly durationMs: number,
|
|
183
|
+
) {}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── HTTP ──────────────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Emitted after an outgoing HTTP request made through the `Http` client
|
|
190
|
+
* completes or finally fails. Powers outgoing-dependency dashboards (per-host
|
|
191
|
+
* call counts, p95 latency, error rate). `status` is 0 when the request never
|
|
192
|
+
* got a response (network error / timeout).
|
|
193
|
+
*
|
|
194
|
+
* @category HTTP
|
|
195
|
+
*/
|
|
196
|
+
export class OutgoingRequestCompleted {
|
|
197
|
+
constructor(
|
|
198
|
+
readonly host: string,
|
|
199
|
+
readonly method: string,
|
|
200
|
+
readonly url: string,
|
|
201
|
+
readonly status: number,
|
|
202
|
+
readonly durationMs: number,
|
|
203
|
+
readonly ok: boolean,
|
|
204
|
+
) {}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Emitted after an HTTP request fails (an error propagated out of the pipeline).
|
|
209
|
+
* Fired by the route dispatcher and the server fallback catch blocks.
|
|
210
|
+
*
|
|
211
|
+
* @category HTTP
|
|
212
|
+
*/
|
|
213
|
+
export class RequestFailed {
|
|
214
|
+
constructor(
|
|
215
|
+
/** HttpContext — typed as object to avoid cross-package circular deps. */
|
|
216
|
+
readonly ctx: object,
|
|
217
|
+
readonly startMs: number,
|
|
218
|
+
readonly durationMs: number,
|
|
219
|
+
readonly error: string,
|
|
220
|
+
readonly status: number,
|
|
221
|
+
) {}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Emitted when a middleware short-circuits the pipeline (skips downstream).
|
|
226
|
+
* @category HTTP
|
|
227
|
+
*/
|
|
228
|
+
export class MiddlewareSkipped {
|
|
229
|
+
constructor(
|
|
230
|
+
readonly name: string,
|
|
231
|
+
readonly reason: string,
|
|
232
|
+
readonly ctx: object,
|
|
233
|
+
) {}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Console / commands ────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Emitted after a console (Artisan-style) command finishes — whether run from the
|
|
240
|
+
* CLI or in-process via `Artisan.call()`. Powers the panel's Commands feed.
|
|
241
|
+
*
|
|
242
|
+
* @category Console
|
|
243
|
+
*/
|
|
244
|
+
export class CommandRan {
|
|
245
|
+
constructor(
|
|
246
|
+
readonly name: string,
|
|
247
|
+
readonly durationMs: number,
|
|
248
|
+
readonly exitCode: number,
|
|
249
|
+
readonly ok: boolean,
|
|
250
|
+
readonly error?: string,
|
|
251
|
+
) {}
|
|
252
|
+
}
|