@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.
Files changed (201) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +128 -0
  4. package/package.json +72 -0
  5. package/src/application/Application.ts +1671 -0
  6. package/src/application/BootDoctor.ts +108 -0
  7. package/src/application/DevErrorPage.ts +567 -0
  8. package/src/application/ExceptionHandler.ts +183 -0
  9. package/src/application/currentApp.ts +73 -0
  10. package/src/assets/assets.ts +79 -0
  11. package/src/assets/index.ts +16 -0
  12. package/src/auth/AuthenticatedUser.ts +18 -0
  13. package/src/build/PackageLinter.ts +146 -0
  14. package/src/build/PackageScaffold.ts +127 -0
  15. package/src/build/codemod.ts +64 -0
  16. package/src/build/index.ts +12 -0
  17. package/src/command/Command.ts +254 -0
  18. package/src/command/CommandRunner.ts +593 -0
  19. package/src/command/OutputWriter.ts +61 -0
  20. package/src/command/builtin/CompileCommand.ts +46 -0
  21. package/src/command/builtin/CssBuildCommand.ts +71 -0
  22. package/src/command/builtin/KeyGenerateCommand.ts +58 -0
  23. package/src/command/builtin/LintPackagesCommand.ts +72 -0
  24. package/src/command/builtin/MakeCommandCommand.ts +85 -0
  25. package/src/command/builtin/MakeControllerCommand.ts +95 -0
  26. package/src/command/builtin/MakeEventCommand.ts +85 -0
  27. package/src/command/builtin/MakeJobCommand.ts +53 -0
  28. package/src/command/builtin/MakeListenerCommand.ts +35 -0
  29. package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
  30. package/src/command/builtin/MakeNotificationCommand.ts +48 -0
  31. package/src/command/builtin/MakeObserverCommand.ts +78 -0
  32. package/src/command/builtin/MakePackageCommand.ts +45 -0
  33. package/src/command/builtin/MakePolicyCommand.ts +66 -0
  34. package/src/command/builtin/MakeProviderCommand.ts +75 -0
  35. package/src/command/builtin/MakeRequestCommand.ts +47 -0
  36. package/src/command/builtin/MakeResourceCommand.ts +61 -0
  37. package/src/command/builtin/MakeTestCommand.ts +120 -0
  38. package/src/command/builtin/ReloadCommand.ts +52 -0
  39. package/src/command/builtin/ReplCommand.ts +174 -0
  40. package/src/command/builtin/RouteListCommand.ts +188 -0
  41. package/src/command/builtin/ServeCommand.ts +321 -0
  42. package/src/command/builtin/StartCommand.ts +3 -0
  43. package/src/command/builtin/StatusCommand.ts +71 -0
  44. package/src/command/builtin/TestCommand.ts +172 -0
  45. package/src/command/builtin/WorkerCommand.ts +27 -0
  46. package/src/command/builtin/index.ts +53 -0
  47. package/src/command/scaffold/worker.ts.txt +12 -0
  48. package/src/command/scaffold/zerotal.ts.txt +26 -0
  49. package/src/command/startZerotal.ts +55 -0
  50. package/src/config/AppConfig.ts +253 -0
  51. package/src/config/ConfigLoader.ts +117 -0
  52. package/src/config/ConfigManager.ts +169 -0
  53. package/src/config/index.ts +46 -0
  54. package/src/config/registry.ts +59 -0
  55. package/src/config/validation.ts +117 -0
  56. package/src/container/Container.ts +606 -0
  57. package/src/container/ContextualBindingBuilder.ts +57 -0
  58. package/src/container/ScopedResolver.ts +117 -0
  59. package/src/container/index.ts +32 -0
  60. package/src/container/inject.ts +55 -0
  61. package/src/container/types.ts +71 -0
  62. package/src/context/RequestContext.ts +91 -0
  63. package/src/contracts/auth.ts +24 -0
  64. package/src/contracts/index.ts +23 -0
  65. package/src/contracts/session.ts +70 -0
  66. package/src/contracts/transaction.ts +26 -0
  67. package/src/conventions/ConventionLoader.ts +128 -0
  68. package/src/conventions/builtinConcerns.ts +131 -0
  69. package/src/crypt/Crypt.ts +141 -0
  70. package/src/crypt/URLSigner.ts +96 -0
  71. package/src/datetime/Carbon.ts +1396 -0
  72. package/src/datetime/CarbonInterval.ts +421 -0
  73. package/src/datetime/clock.ts +28 -0
  74. package/src/datetime/index.ts +23 -0
  75. package/src/datetime/temporal-shim.ts +1 -0
  76. package/src/dev/BuildOutput.ts +131 -0
  77. package/src/dev/CssPlugins.ts +184 -0
  78. package/src/dev/DevBuildHook.ts +74 -0
  79. package/src/dev/DevOrchestrator.ts +213 -0
  80. package/src/dev/DevReloadMiddleware.ts +101 -0
  81. package/src/dev/DevReloadServer.ts +85 -0
  82. package/src/dev/DevWsServer.ts +45 -0
  83. package/src/dev/index.ts +19 -0
  84. package/src/dev/reloadClient.ts +39 -0
  85. package/src/env/Def.ts +232 -0
  86. package/src/env/EnvSchema.ts +105 -0
  87. package/src/env/index.ts +34 -0
  88. package/src/env/t.ts +128 -0
  89. package/src/errors/ConfigError.ts +12 -0
  90. package/src/errors/ContainerErrors.ts +143 -0
  91. package/src/errors/HttpError.ts +127 -0
  92. package/src/errors/ValidationError.ts +19 -0
  93. package/src/errors/ZerotalError.ts +25 -0
  94. package/src/errors/index.ts +46 -0
  95. package/src/events/CallQueuedListener.ts +66 -0
  96. package/src/events/Emitter.ts +280 -0
  97. package/src/events/EventFake.ts +160 -0
  98. package/src/events/FrameworkEvents.ts +252 -0
  99. package/src/facade/Facade.ts +101 -0
  100. package/src/facade/facades/App.ts +155 -0
  101. package/src/facade/facades/Artisan.ts +63 -0
  102. package/src/facade/facades/Config.ts +21 -0
  103. package/src/facade/facades/Events.ts +19 -0
  104. package/src/facade/facades/index.ts +28 -0
  105. package/src/global.d.ts +9 -0
  106. package/src/hash/Hash.ts +60 -0
  107. package/src/health/Health.ts +221 -0
  108. package/src/health/index.ts +27 -0
  109. package/src/helpers/Collection.ts +435 -0
  110. package/src/helpers/config.ts +59 -0
  111. package/src/helpers/fluent.ts +52 -0
  112. package/src/helpers/html.ts +11 -0
  113. package/src/helpers/index.ts +266 -0
  114. package/src/helpers/make.ts +35 -0
  115. package/src/helpers/markdown.ts +73 -0
  116. package/src/helpers/pageElements.ts +27 -0
  117. package/src/helpers/request.ts +62 -0
  118. package/src/helpers/response.ts +411 -0
  119. package/src/helpers/str.ts +208 -0
  120. package/src/http/Http.ts +298 -0
  121. package/src/http/HttpClient.ts +289 -0
  122. package/src/http/Resource.ts +171 -0
  123. package/src/http/UploadedFile.ts +204 -0
  124. package/src/http/Uri.ts +490 -0
  125. package/src/http/index.ts +46 -0
  126. package/src/http/negotiate.ts +213 -0
  127. package/src/http/originGuard.ts +76 -0
  128. package/src/http/sniffContentType.ts +105 -0
  129. package/src/http/url.ts +204 -0
  130. package/src/http/withHeaders.ts +24 -0
  131. package/src/index.ts +250 -0
  132. package/src/lock/LockManager.ts +228 -0
  133. package/src/lock/config.ts +49 -0
  134. package/src/lock/drivers/LockDriver.ts +32 -0
  135. package/src/lock/drivers/MemoryLockDriver.ts +52 -0
  136. package/src/lock/drivers/RedisLockDriver.ts +58 -0
  137. package/src/lock/drivers/SqliteLockDriver.ts +85 -0
  138. package/src/lock/errors.ts +20 -0
  139. package/src/lock/facades/Lock.ts +114 -0
  140. package/src/lock/index.ts +53 -0
  141. package/src/logger/Log.ts +35 -0
  142. package/src/logger/LogManager.ts +430 -0
  143. package/src/logger/LoggerMiddleware.ts +125 -0
  144. package/src/logger/channels/ConsoleChannel.ts +139 -0
  145. package/src/logger/channels/DailyChannel.ts +74 -0
  146. package/src/logger/channels/NullChannel.ts +17 -0
  147. package/src/logger/channels/SingleChannel.ts +34 -0
  148. package/src/logger/channels/StackChannel.ts +29 -0
  149. package/src/logger/config.ts +90 -0
  150. package/src/logger/format.ts +96 -0
  151. package/src/logger/frameworkLog.ts +93 -0
  152. package/src/logger/index.ts +68 -0
  153. package/src/logger/renderTable.ts +111 -0
  154. package/src/logger/types.ts +212 -0
  155. package/src/macros/config.macro.ts +50 -0
  156. package/src/metrics/HttpMetrics.ts +114 -0
  157. package/src/metrics/index.ts +18 -0
  158. package/src/middleware/BaseMiddleware.ts +72 -0
  159. package/src/middleware/CorsMiddleware.ts +152 -0
  160. package/src/middleware/RateLimiter.ts +255 -0
  161. package/src/middleware/SecureHeadersMiddleware.ts +127 -0
  162. package/src/middleware/ThrottleMiddleware.ts +252 -0
  163. package/src/middleware/WebhookMiddleware.ts +204 -0
  164. package/src/pipeline/ContextRegistry.ts +42 -0
  165. package/src/pipeline/HttpContext.ts +865 -0
  166. package/src/pipeline/Pipeline.ts +150 -0
  167. package/src/pipeline/currentPage.ts +46 -0
  168. package/src/pipeline/types.ts +80 -0
  169. package/src/provider/LockProvider.ts +64 -0
  170. package/src/provider/LogProvider.ts +137 -0
  171. package/src/provider/ServiceProvider.ts +84 -0
  172. package/src/provider/StorageProvider.ts +45 -0
  173. package/src/router/FileRouter.ts +526 -0
  174. package/src/router/Route.ts +76 -0
  175. package/src/router/RouteHandler.ts +335 -0
  176. package/src/router/Router.ts +1247 -0
  177. package/src/router/domain.ts +65 -0
  178. package/src/security/index.ts +22 -0
  179. package/src/storage/FakeDisk.ts +233 -0
  180. package/src/storage/StorageFilesMiddleware.ts +150 -0
  181. package/src/storage/StorageManager.ts +173 -0
  182. package/src/storage/config.ts +47 -0
  183. package/src/storage/drivers/LocalDriver.ts +138 -0
  184. package/src/storage/drivers/S3Driver.ts +169 -0
  185. package/src/storage/errors.ts +135 -0
  186. package/src/storage/facades/Storage.ts +3 -0
  187. package/src/storage/global.d.ts +7 -0
  188. package/src/storage/index.ts +22 -0
  189. package/src/storage/root.ts +59 -0
  190. package/src/storage/types.ts +104 -0
  191. package/src/support/appKey.ts +38 -0
  192. package/src/support/cookie.ts +72 -0
  193. package/src/support/crypto.ts +52 -0
  194. package/src/support/deepMerge.ts +117 -0
  195. package/src/support/env.ts +71 -0
  196. package/src/support/network.ts +79 -0
  197. package/src/support/port.ts +197 -0
  198. package/src/support/str.ts +122 -0
  199. package/src/view/FileRouteResolver.ts +59 -0
  200. package/src/view/index.ts +144 -0
  201. 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
+ }