zayra-events 0.0.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/README.md +223 -0
- package/package.json +31 -0
- package/src/errors.js +38 -0
- package/src/event-types.js +94 -0
- package/src/format.js +55 -0
- package/src/history.js +76 -0
- package/src/index.js +27 -0
- package/src/manager.js +230 -0
package/README.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# zayra-events
|
|
2
|
+
|
|
3
|
+
The central event communication layer for **ZAYRA AI**. `zayra-events` lets every `zayra-*` package publish and subscribe to events without depending on each other directly — `zayra-task-engine` doesn't need to know `zayra-memory` exists to tell it a task finished; both just talk to the same `EventManager`.
|
|
4
|
+
|
|
5
|
+
This is version `0.0.1`. It contains **no frontend UI, no business logic, and no AI decision-making** — this package only handles communication.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install zayra-events
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { EventManager, TaskEvent } from "zayra-events";
|
|
17
|
+
|
|
18
|
+
const events = new EventManager();
|
|
19
|
+
|
|
20
|
+
events.on(TaskEvent.COMPLETED, (envelope) => {
|
|
21
|
+
console.log(`Task ${envelope.data.taskId} completed at ${envelope.timestamp}`);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await events.emit(TaskEvent.COMPLETED, { taskId: "t1" }, { source: "zayra-task-engine" });
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Event Manager API
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
events.on(eventName, handler); // subscribe — returns an unsubscribe function
|
|
31
|
+
events.once(eventName, handler); // subscribe for one occurrence, then auto-unsubscribe
|
|
32
|
+
events.removeListener(eventName, handler);
|
|
33
|
+
events.removeAllListeners(eventName?); // omit eventName to clear every listener on every event
|
|
34
|
+
events.listenerCount(eventName);
|
|
35
|
+
|
|
36
|
+
await events.emit(eventName, data?, { source? }); // publish — see "emit() semantics" below
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Event data format
|
|
40
|
+
|
|
41
|
+
Every event `on()` receives, and every result `emit()` resolves to, follows the same shape:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
{
|
|
45
|
+
event: "task.completed",
|
|
46
|
+
timestamp: "2026-07-19T12:00:00.000Z",
|
|
47
|
+
data: { taskId: "t1" },
|
|
48
|
+
source: "zayra-task-engine", // whichever module passed { source } to emit(), or null
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Standard events
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
import { SystemEvent, ModuleEvent, PluginEvent, AiEvent, MemoryEvent, TaskEvent } from "zayra-events";
|
|
56
|
+
|
|
57
|
+
SystemEvent.STARTING; // "system.starting"
|
|
58
|
+
SystemEvent.STARTED; // "system.started"
|
|
59
|
+
SystemEvent.READY; // "system.ready"
|
|
60
|
+
SystemEvent.SHUTDOWN; // "system.shutdown"
|
|
61
|
+
|
|
62
|
+
ModuleEvent.LOADED; // "module.loaded"
|
|
63
|
+
ModuleEvent.STARTED; // "module.started"
|
|
64
|
+
ModuleEvent.FAILED; // "module.failed"
|
|
65
|
+
ModuleEvent.REMOVED; // "module.removed"
|
|
66
|
+
|
|
67
|
+
PluginEvent.INSTALLED; // "plugin.installed"
|
|
68
|
+
PluginEvent.LOADED; // "plugin.loaded"
|
|
69
|
+
PluginEvent.ENABLED; // "plugin.enabled"
|
|
70
|
+
PluginEvent.DISABLED; // "plugin.disabled"
|
|
71
|
+
PluginEvent.REMOVED; // "plugin.removed"
|
|
72
|
+
|
|
73
|
+
AiEvent.MODEL_SELECTED; // "model.selected"
|
|
74
|
+
AiEvent.PROVIDER_CONNECTED; // "provider.connected"
|
|
75
|
+
AiEvent.PROVIDER_FAILED; // "provider.failed"
|
|
76
|
+
AiEvent.REQUEST_STARTED; // "ai.request.started"
|
|
77
|
+
AiEvent.REQUEST_COMPLETED; // "ai.request.completed"
|
|
78
|
+
AiEvent.REQUEST_FAILED; // "ai.request.failed"
|
|
79
|
+
|
|
80
|
+
MemoryEvent.CREATED; // "memory.created"
|
|
81
|
+
MemoryEvent.UPDATED; // "memory.updated"
|
|
82
|
+
MemoryEvent.DELETED; // "memory.deleted"
|
|
83
|
+
MemoryEvent.RECALLED; // "memory.recalled"
|
|
84
|
+
|
|
85
|
+
TaskEvent.CREATED; // "task.created"
|
|
86
|
+
TaskEvent.STARTED; // "task.started"
|
|
87
|
+
TaskEvent.COMPLETED; // "task.completed"
|
|
88
|
+
TaskEvent.FAILED; // "task.failed"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
These constants exist to prevent typos — they're a catalog, not a restriction. See **Plugin compatibility** below.
|
|
92
|
+
|
|
93
|
+
## Plugin compatibility (custom events)
|
|
94
|
+
|
|
95
|
+
Any dot-namespaced string is a valid event name, standard or not:
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
events.on("custom.plugin.event", (envelope) => { /* ... */ });
|
|
99
|
+
await events.emit("custom.plugin.event", { anything: true });
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
import { isStandardEvent } from "zayra-events";
|
|
104
|
+
|
|
105
|
+
isStandardEvent("task.completed"); // true
|
|
106
|
+
isStandardEvent("custom.plugin.event"); // false — still perfectly valid to emit/listen to
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## `emit()` semantics
|
|
110
|
+
|
|
111
|
+
- **Listeners run concurrently**, each on its own microtask — a slow or misbehaving listener never blocks the others, and `emit()` never blocks your synchronous code unless you explicitly `await` it (Performance Rules: lightweight, asynchronous, avoid blocking operations).
|
|
112
|
+
- **A throwing or rejecting listener never crashes `emit()`** or stops other listeners — its failure is caught and reported in the result instead.
|
|
113
|
+
- **`once()` listeners are removed after they run**, whether they succeeded or threw.
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
const outcome = await events.emit("ai.request.completed", { ms: 420 });
|
|
117
|
+
|
|
118
|
+
outcome.result;
|
|
119
|
+
// { listenerCount: 3, errorCount: 1, errors: [{ name, code: "LISTENER_FAILURE", message, listenerName }] }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`emit()` itself only throws for a genuinely invalid call — a malformed event name, or an unexpected internal failure — never for a listener's own mistake.
|
|
123
|
+
|
|
124
|
+
## Event history
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
events.history.list(); // everything recorded so far, oldest first
|
|
128
|
+
events.history.list({ event: "task.completed" }); // filter by event name
|
|
129
|
+
events.history.list({ source: "zayra-memory" }); // filter by source
|
|
130
|
+
events.history.list({ limit: 20 }); // only the most recent 20
|
|
131
|
+
events.history.size; // current entry count
|
|
132
|
+
events.history.clear();
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Each entry: `{ event, timestamp, source, result }`. Capped at `maxSize` (default `500`, configurable via `new EventManager({ historySize: 1000 })`) so long-running processes don't grow this unbounded — the oldest entry is dropped first once full.
|
|
136
|
+
|
|
137
|
+
## Cross-package communication, without a direct dependency
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
// Inside zayra-task-engine's manager.js (or any module wired with the same EventManager):
|
|
141
|
+
await events.emit(TaskEvent.COMPLETED, { taskId: task.id }, { source: "zayra-task-engine" });
|
|
142
|
+
|
|
143
|
+
// Inside zayra-memory's manager.js — no import of zayra-task-engine anywhere:
|
|
144
|
+
events.on(TaskEvent.COMPLETED, async (envelope) => {
|
|
145
|
+
await memory.save({ content: `Task ${envelope.data.taskId} completed.` });
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Both sides only need `zayra-events` — this is what lets `zayra-core`, `zayra-config`, `zayra-memory`, `zayra-tools`, `zayra-plugins`, `zayra-provider`, and `zayra-task-engine` communicate without any of them importing each other.
|
|
150
|
+
|
|
151
|
+
## Errors
|
|
152
|
+
|
|
153
|
+
- `EventError` — the single error class this package throws, distinguished by `err.code`:
|
|
154
|
+
- `"INVALID_EVENT"` — a bad event name or missing/non-function handler was passed to `on()`/`once()`/`emit()`
|
|
155
|
+
- `"LISTENER_FAILURE"` — a listener threw or rejected during `emit()` (reported in the result, not thrown)
|
|
156
|
+
- `"PROCESSING_FAILURE"` — an unexpected internal failure while processing an event
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
import { EventError } from "zayra-events";
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
events.on("", handler);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (err instanceof EventError && err.code === "INVALID_EVENT") {
|
|
165
|
+
console.error("Bad event name:", err.eventName);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Design note: dependency-free by convention
|
|
171
|
+
|
|
172
|
+
Like every other `zayra-*` package, `zayra-events` ships with **zero npm dependencies**. Packages that previously shipped their own minimal fallback event bus (`zayra-plugins`, `zayra-task-engine`, `zayra-sdk`) can now depend on this package directly and swap their internal `EventBus` for a shared `EventManager` instance — the `on()`/`off()`/`emit()`-shaped API was kept intentionally close to make that swap mechanical.
|
|
173
|
+
|
|
174
|
+
## What this package deliberately does NOT do
|
|
175
|
+
|
|
176
|
+
Per its design rules:
|
|
177
|
+
|
|
178
|
+
- No frontend/UI code
|
|
179
|
+
- No business logic — `zayra-events` doesn't decide what an event *means*, it only delivers it
|
|
180
|
+
- No AI decision-making
|
|
181
|
+
- No enforcement of which events a package may or may not emit — that's each package's own concern
|
|
182
|
+
|
|
183
|
+
## Project structure
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
zayra-events/
|
|
187
|
+
src/
|
|
188
|
+
index.js # public entry point
|
|
189
|
+
manager.js # EventManager — on/once/removeListener/emit
|
|
190
|
+
event-types.js # SystemEvent, ModuleEvent, PluginEvent, AiEvent, MemoryEvent, TaskEvent catalogs
|
|
191
|
+
format.js # event name validation + createEventEnvelope()
|
|
192
|
+
history.js # EventHistory — bounded recent-events log
|
|
193
|
+
errors.js # EventError
|
|
194
|
+
package.json
|
|
195
|
+
README.md
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## API reference
|
|
199
|
+
|
|
200
|
+
### `class EventManager`
|
|
201
|
+
|
|
202
|
+
| Method | Description |
|
|
203
|
+
| --- | --- |
|
|
204
|
+
| `new EventManager(options?)` | `options.historySize` (default `500`), or inject `options.history`. |
|
|
205
|
+
| `on(eventName, handler)` | Subscribe. Returns an unsubscribe function. |
|
|
206
|
+
| `once(eventName, handler)` | Subscribe for one occurrence. |
|
|
207
|
+
| `removeListener(eventName, handler)` | Unsubscribe one handler. |
|
|
208
|
+
| `removeAllListeners(eventName?)` | Clear listeners for one event, or all events. |
|
|
209
|
+
| `listenerCount(eventName)` | Number of active listeners for an event. |
|
|
210
|
+
| `emit(eventName, data?, options?)` | Publish. `options.source` tags the emitter. Returns `Promise<EmitResult>`. |
|
|
211
|
+
| `.history` | The `EventHistory` instance backing this manager. |
|
|
212
|
+
|
|
213
|
+
### Other exports
|
|
214
|
+
|
|
215
|
+
`EventHistory`, `isValidEventName()`, `createEventEnvelope()`, `SystemEvent`, `ModuleEvent`, `PluginEvent`, `AiEvent`, `MemoryEvent`, `TaskEvent`, `STANDARD_EVENTS`, `isStandardEvent()`, `EventError`
|
|
216
|
+
|
|
217
|
+
## Compatibility
|
|
218
|
+
|
|
219
|
+
Designed to be the shared communication backbone for `zayra-core`, `zayra-config`, `zayra-memory`, `zayra-tools`, `zayra-plugins`, `zayra-provider`, `zayra-task-engine`, and `zayra-sdk`.
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "zayra-events",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Central event communication layer for the ZAYRA AI ecosystem — lets packages communicate without a direct dependency between them.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18.0.0"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test \"test/**/*.test.js\""
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"zayra",
|
|
22
|
+
"ai",
|
|
23
|
+
"events",
|
|
24
|
+
"event-bus",
|
|
25
|
+
"pubsub",
|
|
26
|
+
"communication"
|
|
27
|
+
],
|
|
28
|
+
"author": "",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {}
|
|
31
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* errors.js
|
|
3
|
+
*
|
|
4
|
+
* Error Handling (spec section: "Error Handling"). A single
|
|
5
|
+
* EventError class, distinguished by `err.code`, covering the three
|
|
6
|
+
* failure modes the spec calls out:
|
|
7
|
+
*
|
|
8
|
+
* INVALID_EVENT - a bad event name or missing handler was passed to on()/once()/emit()
|
|
9
|
+
* LISTENER_FAILURE - a listener threw or rejected during emit()
|
|
10
|
+
* PROCESSING_FAILURE - something else went wrong while processing an event
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export class EventError extends Error {
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} message
|
|
16
|
+
* @param {object} [options]
|
|
17
|
+
* @param {"INVALID_EVENT"|"LISTENER_FAILURE"|"PROCESSING_FAILURE"|string} [options.code]
|
|
18
|
+
* @param {string} [options.eventName]
|
|
19
|
+
* @param {unknown} [options.cause]
|
|
20
|
+
*/
|
|
21
|
+
constructor(message, options = {}) {
|
|
22
|
+
super(message);
|
|
23
|
+
|
|
24
|
+
this.name = this.constructor.name;
|
|
25
|
+
this.code = options.code ?? "EVENT_ERROR";
|
|
26
|
+
this.eventName = options.eventName ?? null;
|
|
27
|
+
|
|
28
|
+
if (options.cause !== undefined) {
|
|
29
|
+
this.cause = options.cause;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (typeof Error.captureStackTrace === "function") {
|
|
33
|
+
Error.captureStackTrace(this, this.constructor);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default { EventError };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* event-types.js
|
|
3
|
+
*
|
|
4
|
+
* Standard Events, Module Events, Plugin Events, AI Events, Memory
|
|
5
|
+
* Events, and Task Events (spec sections of the same names) — the
|
|
6
|
+
* shared event vocabulary every zayra-* package can rely on without
|
|
7
|
+
* having to agree on spelling separately.
|
|
8
|
+
*
|
|
9
|
+
* This is a catalog, not a restriction: per spec's "Plugin
|
|
10
|
+
* Compatibility" section, EventManager accepts any valid
|
|
11
|
+
* dot-namespaced event name, standard or custom (see format.js).
|
|
12
|
+
* These constants just prevent typos like "task.compelted" for the
|
|
13
|
+
* common cases.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** System lifecycle events. @readonly */
|
|
17
|
+
export const SystemEvent = Object.freeze({
|
|
18
|
+
STARTING: "system.starting",
|
|
19
|
+
STARTED: "system.started",
|
|
20
|
+
READY: "system.ready",
|
|
21
|
+
SHUTDOWN: "system.shutdown",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Generic module lifecycle events (any zayra-* module). @readonly */
|
|
25
|
+
export const ModuleEvent = Object.freeze({
|
|
26
|
+
LOADED: "module.loaded",
|
|
27
|
+
STARTED: "module.started",
|
|
28
|
+
FAILED: "module.failed",
|
|
29
|
+
REMOVED: "module.removed",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** zayra-plugins lifecycle events. @readonly */
|
|
33
|
+
export const PluginEvent = Object.freeze({
|
|
34
|
+
INSTALLED: "plugin.installed",
|
|
35
|
+
LOADED: "plugin.loaded",
|
|
36
|
+
ENABLED: "plugin.enabled",
|
|
37
|
+
DISABLED: "plugin.disabled",
|
|
38
|
+
REMOVED: "plugin.removed",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** AI provider / model / request events (zayra-provider, zayra-ai-router). @readonly */
|
|
42
|
+
export const AiEvent = Object.freeze({
|
|
43
|
+
MODEL_SELECTED: "model.selected",
|
|
44
|
+
PROVIDER_CONNECTED: "provider.connected",
|
|
45
|
+
PROVIDER_FAILED: "provider.failed",
|
|
46
|
+
REQUEST_STARTED: "ai.request.started",
|
|
47
|
+
REQUEST_COMPLETED: "ai.request.completed",
|
|
48
|
+
REQUEST_FAILED: "ai.request.failed",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** zayra-memory events. @readonly */
|
|
52
|
+
export const MemoryEvent = Object.freeze({
|
|
53
|
+
CREATED: "memory.created",
|
|
54
|
+
UPDATED: "memory.updated",
|
|
55
|
+
DELETED: "memory.deleted",
|
|
56
|
+
RECALLED: "memory.recalled",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** zayra-task-engine events (the shared baseline subset — zayra-task-engine's own internal bus additionally has paused/resumed/cancelled/step-level events). @readonly */
|
|
60
|
+
export const TaskEvent = Object.freeze({
|
|
61
|
+
CREATED: "task.created",
|
|
62
|
+
STARTED: "task.started",
|
|
63
|
+
COMPLETED: "task.completed",
|
|
64
|
+
FAILED: "task.failed",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** Every standard event name, flattened, e.g. for isStandardEvent(). */
|
|
68
|
+
export const STANDARD_EVENTS = Object.freeze([
|
|
69
|
+
...Object.values(SystemEvent),
|
|
70
|
+
...Object.values(ModuleEvent),
|
|
71
|
+
...Object.values(PluginEvent),
|
|
72
|
+
...Object.values(AiEvent),
|
|
73
|
+
...Object.values(MemoryEvent),
|
|
74
|
+
...Object.values(TaskEvent),
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} eventName
|
|
79
|
+
* @returns {boolean} Whether eventName is part of the standard catalog above (informational only — custom events are still valid, see format.js).
|
|
80
|
+
*/
|
|
81
|
+
export function isStandardEvent(eventName) {
|
|
82
|
+
return STANDARD_EVENTS.includes(eventName);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default {
|
|
86
|
+
SystemEvent,
|
|
87
|
+
ModuleEvent,
|
|
88
|
+
PluginEvent,
|
|
89
|
+
AiEvent,
|
|
90
|
+
MemoryEvent,
|
|
91
|
+
TaskEvent,
|
|
92
|
+
STANDARD_EVENTS,
|
|
93
|
+
isStandardEvent,
|
|
94
|
+
};
|
package/src/format.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* format.js
|
|
3
|
+
*
|
|
4
|
+
* Event Data Format (spec section: "Event Data Format") — every
|
|
5
|
+
* event emitted through EventManager takes this shape:
|
|
6
|
+
*
|
|
7
|
+
* { event: "task.completed", timestamp: "2026-07-19T12:00:00.000Z", data: {}, source: null }
|
|
8
|
+
*
|
|
9
|
+
* Also defines the event-name validation shared by manager.js. Per
|
|
10
|
+
* spec's "Plugin Compatibility" section, ANY dot-namespaced name is a
|
|
11
|
+
* valid event — not just the standard catalog in event-types.js — so
|
|
12
|
+
* a plugin can freely emit something like "custom.plugin.event".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { EventError } from "./errors.js";
|
|
16
|
+
|
|
17
|
+
/** Dot-namespaced identifier: letters/digits/underscores, segments joined by ".". */
|
|
18
|
+
const EVENT_NAME_PATTERN = /^[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)*$/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {unknown} eventName
|
|
22
|
+
* @returns {boolean}
|
|
23
|
+
*/
|
|
24
|
+
export function isValidEventName(eventName) {
|
|
25
|
+
return typeof eventName === "string" && eventName.length > 0 && EVENT_NAME_PATTERN.test(eventName);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Builds a standard event envelope.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} eventName
|
|
32
|
+
* @param {object} [data]
|
|
33
|
+
* @param {object} [options]
|
|
34
|
+
* @param {string} [options.source] - Which package/module emitted this event, e.g. "zayra-task-engine".
|
|
35
|
+
* @param {string} [options.timestamp] - Overrides the auto-generated ISO timestamp (mainly for tests).
|
|
36
|
+
* @returns {{ event: string, timestamp: string, data: object, source: string|null }}
|
|
37
|
+
* @throws {EventError} If eventName is invalid (code: "INVALID_EVENT").
|
|
38
|
+
*/
|
|
39
|
+
export function createEventEnvelope(eventName, data = {}, options = {}) {
|
|
40
|
+
if (!isValidEventName(eventName)) {
|
|
41
|
+
throw new EventError(
|
|
42
|
+
`Invalid event name: "${eventName}". Use a dot-namespaced identifier, e.g. "task.completed".`,
|
|
43
|
+
{ code: "INVALID_EVENT", eventName }
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
event: eventName,
|
|
49
|
+
timestamp: options.timestamp ?? new Date().toISOString(),
|
|
50
|
+
data: data ?? {},
|
|
51
|
+
source: options.source ?? null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default { isValidEventName, createEventEnvelope };
|
package/src/history.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* history.js
|
|
3
|
+
*
|
|
4
|
+
* Event History (spec section: "Event History"). Stores recent
|
|
5
|
+
* events — name, time, source, result — in memory, capped at a
|
|
6
|
+
* fixed size so it stays lightweight (Performance Rules) rather than
|
|
7
|
+
* growing unbounded for a long-lived process.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} EventHistoryEntry
|
|
12
|
+
* @property {string} event
|
|
13
|
+
* @property {string} timestamp
|
|
14
|
+
* @property {string|null} source
|
|
15
|
+
* @property {{ listenerCount: number, errorCount: number, errors: object[] }} result
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export class EventHistory {
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} [options]
|
|
21
|
+
* @param {number} [options.maxSize=500] - Oldest entries are dropped once this many are stored.
|
|
22
|
+
*/
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
this.maxSize = options.maxSize ?? 500;
|
|
25
|
+
/** @type {EventHistoryEntry[]} */
|
|
26
|
+
this._entries = [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Records an entry, dropping the oldest one first if at capacity.
|
|
31
|
+
* @param {EventHistoryEntry} entry
|
|
32
|
+
*/
|
|
33
|
+
record(entry) {
|
|
34
|
+
this._entries.push(entry);
|
|
35
|
+
if (this._entries.length > this.maxSize) {
|
|
36
|
+
this._entries.shift();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Lists recorded entries, most-recently-recorded last, optionally
|
|
42
|
+
* filtered.
|
|
43
|
+
* @param {object} [filter]
|
|
44
|
+
* @param {string} [filter.event] - Restrict to one event name.
|
|
45
|
+
* @param {string} [filter.source] - Restrict to one source.
|
|
46
|
+
* @param {number} [filter.limit] - Only the most recent N entries.
|
|
47
|
+
* @returns {EventHistoryEntry[]}
|
|
48
|
+
*/
|
|
49
|
+
list(filter = {}) {
|
|
50
|
+
let entries = this._entries;
|
|
51
|
+
|
|
52
|
+
if (filter.event) {
|
|
53
|
+
entries = entries.filter((entry) => entry.event === filter.event);
|
|
54
|
+
}
|
|
55
|
+
if (filter.source) {
|
|
56
|
+
entries = entries.filter((entry) => entry.source === filter.source);
|
|
57
|
+
}
|
|
58
|
+
if (typeof filter.limit === "number" && filter.limit >= 0) {
|
|
59
|
+
entries = entries.slice(-filter.limit);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return entries;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @returns {number} */
|
|
66
|
+
get size() {
|
|
67
|
+
return this._entries.length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Removes every recorded entry. */
|
|
71
|
+
clear() {
|
|
72
|
+
this._entries = [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export default EventHistory;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.js
|
|
3
|
+
*
|
|
4
|
+
* Public entry point for the zayra-events package.
|
|
5
|
+
*
|
|
6
|
+
* Anything a consumer needs should be exported from here — they
|
|
7
|
+
* should never need to import from "zayra-events/src/...". This
|
|
8
|
+
* keeps the internal file layout free to change without breaking
|
|
9
|
+
* every zayra-* package that subscribes/publishes through this one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export { EventManager } from "./manager.js";
|
|
13
|
+
export { EventHistory } from "./history.js";
|
|
14
|
+
export { isValidEventName, createEventEnvelope } from "./format.js";
|
|
15
|
+
export {
|
|
16
|
+
SystemEvent,
|
|
17
|
+
ModuleEvent,
|
|
18
|
+
PluginEvent,
|
|
19
|
+
AiEvent,
|
|
20
|
+
MemoryEvent,
|
|
21
|
+
TaskEvent,
|
|
22
|
+
STANDARD_EVENTS,
|
|
23
|
+
isStandardEvent,
|
|
24
|
+
} from "./event-types.js";
|
|
25
|
+
export { EventError } from "./errors.js";
|
|
26
|
+
|
|
27
|
+
export { EventManager as default } from "./manager.js";
|
package/src/manager.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* manager.js
|
|
3
|
+
*
|
|
4
|
+
* EventManager (spec section: "Event Manager") — the central
|
|
5
|
+
* communication layer every zayra-* package can subscribe to and
|
|
6
|
+
* publish through, so they never need a direct dependency on one
|
|
7
|
+
* another (spec's "Event System Upgrade" goal).
|
|
8
|
+
*
|
|
9
|
+
* Performance Rules: lightweight, asynchronous, scalable, and never
|
|
10
|
+
* blocking. emit() reflects that directly:
|
|
11
|
+
* - listeners run concurrently (Promise.allSettled), each kicked
|
|
12
|
+
* off on its own microtask, so the caller's synchronous code
|
|
13
|
+
* never blocks waiting on a slow or misbehaving handler unless
|
|
14
|
+
* it chooses to `await emit(...)`.
|
|
15
|
+
* - a throwing/rejecting listener never stops the others or
|
|
16
|
+
* crashes emit() — its failure is caught, described, and
|
|
17
|
+
* returned in the result instead (Error Handling: "listener
|
|
18
|
+
* failure").
|
|
19
|
+
*
|
|
20
|
+
* Important Rules: this file only handles communication — no
|
|
21
|
+
* frontend UI, no business logic, no AI decision-making.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { EventError } from "./errors.js";
|
|
25
|
+
import { isValidEventName, createEventEnvelope } from "./format.js";
|
|
26
|
+
import { EventHistory } from "./history.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} EmitResult
|
|
30
|
+
* @property {string} event
|
|
31
|
+
* @property {string} timestamp
|
|
32
|
+
* @property {object} data
|
|
33
|
+
* @property {string|null} source
|
|
34
|
+
* @property {{ listenerCount: number, errorCount: number, errors: object[] }} result
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export class EventManager {
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} [options]
|
|
40
|
+
* @param {number} [options.historySize=500] - Passed to EventHistory.
|
|
41
|
+
* @param {EventHistory} [options.history] - Inject a custom/shared EventHistory instance instead of creating a new one.
|
|
42
|
+
*/
|
|
43
|
+
constructor(options = {}) {
|
|
44
|
+
/** @type {Map<string, Map<Function, { once: boolean }>>} */
|
|
45
|
+
this._listeners = new Map();
|
|
46
|
+
this.history = options.history ?? new EventHistory({ maxSize: options.historySize });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ------------------------------------------------------------------ */
|
|
50
|
+
/* Subscribing: on / once / removeListener */
|
|
51
|
+
/* ------------------------------------------------------------------ */
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Subscribes to an event. Duplicate on() calls with the same
|
|
55
|
+
* (eventName, handler) pair are harmless no-ops after the first —
|
|
56
|
+
* a Map keyed by the handler function can't hold it twice.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} eventName
|
|
59
|
+
* @param {(envelope: { event: string, timestamp: string, data: object, source: string|null }) => void|Promise<void>} handler
|
|
60
|
+
* @returns {() => void} Unsubscribe function.
|
|
61
|
+
* @throws {EventError} If eventName or handler is invalid (code: "INVALID_EVENT").
|
|
62
|
+
*/
|
|
63
|
+
on(eventName, handler) {
|
|
64
|
+
this._validateSubscription(eventName, handler, "on");
|
|
65
|
+
this._handlersFor(eventName).set(handler, { once: false });
|
|
66
|
+
return () => this.removeListener(eventName, handler);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Subscribes to an event for a single occurrence — the handler is
|
|
71
|
+
* automatically removed right after it runs (whether it succeeds
|
|
72
|
+
* or throws).
|
|
73
|
+
*
|
|
74
|
+
* @param {string} eventName
|
|
75
|
+
* @param {(envelope: object) => void|Promise<void>} handler
|
|
76
|
+
* @returns {() => void} Unsubscribe function.
|
|
77
|
+
* @throws {EventError} If eventName or handler is invalid.
|
|
78
|
+
*/
|
|
79
|
+
once(eventName, handler) {
|
|
80
|
+
this._validateSubscription(eventName, handler, "once");
|
|
81
|
+
this._handlersFor(eventName).set(handler, { once: true });
|
|
82
|
+
return () => this.removeListener(eventName, handler);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Unsubscribes one handler from one event. Safe to call for a
|
|
87
|
+
* handler that was never subscribed.
|
|
88
|
+
* @param {string} eventName
|
|
89
|
+
* @param {Function} handler
|
|
90
|
+
*/
|
|
91
|
+
removeListener(eventName, handler) {
|
|
92
|
+
this._listeners.get(eventName)?.delete(handler);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Removes every listener for one event, or every listener for
|
|
97
|
+
* every event if eventName is omitted.
|
|
98
|
+
* @param {string} [eventName]
|
|
99
|
+
*/
|
|
100
|
+
removeAllListeners(eventName) {
|
|
101
|
+
if (eventName) {
|
|
102
|
+
this._listeners.delete(eventName);
|
|
103
|
+
} else {
|
|
104
|
+
this._listeners.clear();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @param {string} eventName @returns {number} */
|
|
109
|
+
listenerCount(eventName) {
|
|
110
|
+
return this._listeners.get(eventName)?.size ?? 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* ------------------------------------------------------------------ */
|
|
114
|
+
/* Publishing: emit */
|
|
115
|
+
/* ------------------------------------------------------------------ */
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Publishes an event. Never throws for a listener failure — those
|
|
119
|
+
* are collected into the returned result instead. Only throws for
|
|
120
|
+
* an invalid event name/data (a caller bug, per "Error Handling:
|
|
121
|
+
* invalid event") or an unexpected internal failure (code:
|
|
122
|
+
* "PROCESSING_FAILURE").
|
|
123
|
+
*
|
|
124
|
+
* @param {string} eventName
|
|
125
|
+
* @param {object} [data]
|
|
126
|
+
* @param {object} [options]
|
|
127
|
+
* @param {string} [options.source] - Which package/module is emitting this, e.g. "zayra-task-engine".
|
|
128
|
+
* @returns {Promise<EmitResult>}
|
|
129
|
+
* @throws {EventError}
|
|
130
|
+
*/
|
|
131
|
+
async emit(eventName, data = {}, options = {}) {
|
|
132
|
+
let envelope;
|
|
133
|
+
let entries;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
envelope = createEventEnvelope(eventName, data, { source: options.source });
|
|
137
|
+
entries = [...(this._listeners.get(eventName)?.entries() ?? [])];
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (err instanceof EventError) {
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
throw new EventError(`Failed to process event "${eventName}": ${err.message}`, {
|
|
143
|
+
code: "PROCESSING_FAILURE",
|
|
144
|
+
eventName,
|
|
145
|
+
cause: err,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const errors = [];
|
|
150
|
+
const settlements = await Promise.allSettled(
|
|
151
|
+
entries.map(([handler]) => Promise.resolve().then(() => handler(envelope)))
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
settlements.forEach((settlement, index) => {
|
|
155
|
+
if (settlement.status === "rejected") {
|
|
156
|
+
const [handler] = entries[index];
|
|
157
|
+
errors.push(this._describeListenerFailure(envelope.event, handler, settlement.reason));
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// "once" listeners are removed after they've run, success or not.
|
|
162
|
+
const map = this._listeners.get(eventName);
|
|
163
|
+
if (map) {
|
|
164
|
+
for (const [handler, meta] of entries) {
|
|
165
|
+
if (meta.once) {
|
|
166
|
+
map.delete(handler);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const result = { listenerCount: entries.length, errorCount: errors.length, errors };
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
this.history.record({ event: envelope.event, timestamp: envelope.timestamp, source: envelope.source, result });
|
|
175
|
+
} catch (err) {
|
|
176
|
+
// A broken history implementation should never take emit() down with it.
|
|
177
|
+
console.error("[zayra-events] Failed to record event history:", err);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { ...envelope, result };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* ------------------------------------------------------------------ */
|
|
184
|
+
/* Internals */
|
|
185
|
+
/* ------------------------------------------------------------------ */
|
|
186
|
+
|
|
187
|
+
/** @private */
|
|
188
|
+
_handlersFor(eventName) {
|
|
189
|
+
if (!this._listeners.has(eventName)) {
|
|
190
|
+
this._listeners.set(eventName, new Map());
|
|
191
|
+
}
|
|
192
|
+
return this._listeners.get(eventName);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** @private */
|
|
196
|
+
_validateSubscription(eventName, handler, methodName) {
|
|
197
|
+
if (!isValidEventName(eventName)) {
|
|
198
|
+
throw new EventError(
|
|
199
|
+
`${methodName}() requires a valid dot-namespaced event name, got: "${eventName}".`,
|
|
200
|
+
{ code: "INVALID_EVENT", eventName }
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (typeof handler !== "function") {
|
|
204
|
+
throw new EventError(`${methodName}() requires a function handler.`, { code: "INVALID_EVENT", eventName });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Turns a listener's thrown/rejected value into a plain,
|
|
210
|
+
* serializable description — never leaks the raw handler function
|
|
211
|
+
* or a non-serializable error object into history/results.
|
|
212
|
+
* @private
|
|
213
|
+
*/
|
|
214
|
+
_describeListenerFailure(eventName, handler, reason) {
|
|
215
|
+
const cause = reason instanceof Error ? reason : new Error(String(reason));
|
|
216
|
+
const err = new EventError(`Listener for "${eventName}" failed: ${cause.message}`, {
|
|
217
|
+
code: "LISTENER_FAILURE",
|
|
218
|
+
eventName,
|
|
219
|
+
cause,
|
|
220
|
+
});
|
|
221
|
+
return {
|
|
222
|
+
name: err.name,
|
|
223
|
+
code: err.code,
|
|
224
|
+
message: err.message,
|
|
225
|
+
listenerName: handler.name || "<anonymous>",
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export default EventManager;
|