@kellanjs/eventcraft 0.1.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/LICENSE +21 -0
- package/README.md +390 -0
- package/dist/core/eventcraft.d.ts +108 -0
- package/dist/core/eventcraft.js +406 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.js +1 -0
- package/dist/react/use-event.d.ts +23 -0
- package/dist/react/use-event.js +98 -0
- package/package.json +96 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lanny Kenneth Lung
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# Eventcraft
|
|
2
|
+
|
|
3
|
+
A tiny TypeScript event helper for components that need to emit events and subscribe to exactly the events they care about.
|
|
4
|
+
|
|
5
|
+
Eventcraft gives you a typed event tree. Emit from leaf events, then subscribe to one event, a whole branch, the root, a named group, or a one-off selection.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { event, eventcraft, group } from "@kellanjs/eventcraft";
|
|
9
|
+
|
|
10
|
+
const events = eventcraft()
|
|
11
|
+
.events({
|
|
12
|
+
notes: {
|
|
13
|
+
created: event<{ id: string; title: string }>(),
|
|
14
|
+
updated: event<{ id: string; title: string }>(),
|
|
15
|
+
deleted: event<{ id: string }>(),
|
|
16
|
+
},
|
|
17
|
+
saved: event(),
|
|
18
|
+
})
|
|
19
|
+
.groups((events) => ({
|
|
20
|
+
searchInvalidating: group(events.notes),
|
|
21
|
+
}))
|
|
22
|
+
.build();
|
|
23
|
+
|
|
24
|
+
events.searchInvalidating.$subscribe((event) => {
|
|
25
|
+
console.log("Refresh search because:", event.name);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
await events.notes.updated({
|
|
29
|
+
id: "note-1",
|
|
30
|
+
title: "Updated title",
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
npm install @kellanjs/eventcraft
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Why Use It?
|
|
41
|
+
|
|
42
|
+
- **Typed payloads**: `event<T>()` controls what can be emitted.
|
|
43
|
+
- **Tree-shaped subscriptions**: subscribe to `events.notes` instead of string wildcards like `"notes.*"`.
|
|
44
|
+
- **Small runtime API**: events are callable functions with `$emit`, `$subscribe`, `$once`, and metadata.
|
|
45
|
+
- **Reusable groups**: name important event sets once and use them across the app.
|
|
46
|
+
- **React-friendly**: optional `useEvent` hook handles component cleanup.
|
|
47
|
+
- **No required React dependency**: the core package is framework-agnostic.
|
|
48
|
+
|
|
49
|
+
## Define Events
|
|
50
|
+
|
|
51
|
+
Create a registry with `eventcraft().events(...).build()`.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { event, eventcraft } from "@kellanjs/eventcraft";
|
|
55
|
+
|
|
56
|
+
const events = eventcraft()
|
|
57
|
+
.events({
|
|
58
|
+
notes: {
|
|
59
|
+
created: event<{ id: string; title: string }>(),
|
|
60
|
+
updated: event<{ id: string; title: string }>(),
|
|
61
|
+
deleted: event<{ id: string }>(),
|
|
62
|
+
},
|
|
63
|
+
saved: event(),
|
|
64
|
+
})
|
|
65
|
+
.build();
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Use `event<T>()` for payloadful events. Use `event()` for payloadless events.
|
|
69
|
+
|
|
70
|
+
## Emit Events
|
|
71
|
+
|
|
72
|
+
Events are callable.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
await events.notes.created({
|
|
76
|
+
id: "note-1",
|
|
77
|
+
title: "First note",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await events.saved();
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
You can also use `$emit`.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
await events.notes.deleted.$emit({ id: "note-1" });
|
|
87
|
+
await events.saved.$emit();
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Subscribe To One Event
|
|
91
|
+
|
|
92
|
+
Leaf event listeners receive `(payload, event)`.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
const unsubscribe = events.notes.updated.$subscribe((payload, event) => {
|
|
96
|
+
console.log(payload.title);
|
|
97
|
+
console.log(event.name); // "notes.updated"
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
unsubscribe();
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Every emitted event includes:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
type EventEnvelope = {
|
|
107
|
+
id: string;
|
|
108
|
+
name: string;
|
|
109
|
+
payload: unknown;
|
|
110
|
+
timestamp: number;
|
|
111
|
+
};
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
By default, ids come from `crypto.randomUUID()` and timestamps come from `Date.now()`. You can override both for tests or custom tracing.
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
const events = eventcraft({
|
|
118
|
+
createId: () => "event-1",
|
|
119
|
+
now: () => 123,
|
|
120
|
+
})
|
|
121
|
+
.events({
|
|
122
|
+
saved: event(),
|
|
123
|
+
})
|
|
124
|
+
.build();
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Subscribe To Event Sources
|
|
128
|
+
|
|
129
|
+
Branches, the root registry, named groups, and selections are all event sources. They receive the full event object.
|
|
130
|
+
|
|
131
|
+
### Branch
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
events.notes.$subscribe((event) => {
|
|
135
|
+
console.log(event.name); // notes.created | notes.updated | notes.deleted
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Root
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
events.$subscribe((event) => {
|
|
143
|
+
console.log("Something happened:", event.name);
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Named Group
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { group } from "@kellanjs/eventcraft";
|
|
151
|
+
|
|
152
|
+
const events = eventcraft()
|
|
153
|
+
.events({
|
|
154
|
+
notes: {
|
|
155
|
+
created: event<{ id: string }>(),
|
|
156
|
+
updated: event<{ id: string }>(),
|
|
157
|
+
deleted: event<{ id: string }>(),
|
|
158
|
+
},
|
|
159
|
+
tags: {
|
|
160
|
+
updated: event<{ id: string }>(),
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
.groups((events) => ({
|
|
164
|
+
searchInvalidating: group(events.notes, events.tags.updated),
|
|
165
|
+
}))
|
|
166
|
+
.build();
|
|
167
|
+
|
|
168
|
+
events.searchInvalidating.$subscribe((event) => {
|
|
169
|
+
console.log(event.name);
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### One-Off Selection
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
events
|
|
177
|
+
.$select([events.notes, events.tags.updated])
|
|
178
|
+
.$subscribe((event) => {
|
|
179
|
+
console.log(event.name);
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
You can also create a selection without the root registry.
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { select } from "@kellanjs/eventcraft";
|
|
187
|
+
|
|
188
|
+
select([events.notes.updated, events.tags.updated]).$subscribe((event) => {
|
|
189
|
+
console.log(event.name);
|
|
190
|
+
});
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Overlapping sources are deduplicated by event node identity. If you select both a branch and one of its child events, the listener runs once for that child event. Separate registries can still contain distinct events with the same `$name`.
|
|
194
|
+
|
|
195
|
+
## Cleanup
|
|
196
|
+
|
|
197
|
+
`$subscribe` returns an unsubscribe function.
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
const unsubscribe = events.notes.updated.$subscribe(listener);
|
|
201
|
+
|
|
202
|
+
unsubscribe();
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
You can bind subscriptions to an `AbortSignal`.
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
const controller = new AbortController();
|
|
209
|
+
|
|
210
|
+
events.notes.updated.$subscribe(listener, {
|
|
211
|
+
signal: controller.signal,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
controller.abort();
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Use `$once` when you only want the next matching event.
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
events.saved.$once((payload, event) => {
|
|
221
|
+
console.log(event.id);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
events.notes.$once((event) => {
|
|
225
|
+
console.log(event.name);
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## React
|
|
230
|
+
|
|
231
|
+
The React hook is available from the React subpath.
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
import { useEvent } from "@kellanjs/eventcraft/react";
|
|
235
|
+
|
|
236
|
+
type AppEvents = typeof events;
|
|
237
|
+
|
|
238
|
+
function NoteListener({ events }: { events: AppEvents }) {
|
|
239
|
+
useEvent(events.notes.updated, (payload) => {
|
|
240
|
+
console.log("Note updated:", payload.id);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`useEvent` accepts the same event sources as the core API.
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
useEvent(events.notes, (event) => {
|
|
251
|
+
console.log(event.name);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
useEvent(events.searchInvalidating, (event) => {
|
|
255
|
+
console.log(event.name);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
useEvent(events.$select([events.notes, events.tags.updated]), (event) => {
|
|
259
|
+
console.log(event.name);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
useEvent(events, (event) => {
|
|
263
|
+
console.log(event.name);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
useEvent([events.notes.created, events.tags.updated], (event) => {
|
|
267
|
+
console.log(event.name);
|
|
268
|
+
});
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
The hook unsubscribes on unmount. If the source changes, it cleans up the old subscription before subscribing to the new source. The listener can change every render without resubscribing, and inline arrays are compared by member identity.
|
|
272
|
+
|
|
273
|
+
## Async Iteration
|
|
274
|
+
|
|
275
|
+
Every event source is an async iterable.
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
for await (const event of events.notes) {
|
|
279
|
+
console.log(event.name);
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Use `break`, `return`, or `throw` to stop the iterator and clean up its internal subscription.
|
|
284
|
+
|
|
285
|
+
## Error Handling
|
|
286
|
+
|
|
287
|
+
Listeners can be synchronous or asynchronous. Eventcraft runs every listener for an emit and waits for all of them to settle. One failing listener does not stop the others.
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
events.notes.created.$subscribe(async (payload) => {
|
|
291
|
+
await saveToDatabase(payload);
|
|
292
|
+
});
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
After listeners settle:
|
|
296
|
+
|
|
297
|
+
- If one handler failed, the emit promise rejects with that error.
|
|
298
|
+
- If multiple handlers failed, the emit promise rejects with an `AggregateError`.
|
|
299
|
+
|
|
300
|
+
Use `onEmit` and `onListenerError` for observability.
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
const events = eventcraft({
|
|
304
|
+
onEmit(event) {
|
|
305
|
+
console.log("Emitted:", event.name);
|
|
306
|
+
},
|
|
307
|
+
onListenerError(error, event) {
|
|
308
|
+
console.error("Listener failed:", event.name, error);
|
|
309
|
+
},
|
|
310
|
+
})
|
|
311
|
+
.events({
|
|
312
|
+
saved: event(),
|
|
313
|
+
})
|
|
314
|
+
.build();
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
If `onEmit` throws or rejects, Eventcraft still delivers the event to listeners, then rejects the emit promise after listener delivery settles. `onListenerError` runs for listener failures and preserves the normal emit rejection behavior.
|
|
318
|
+
|
|
319
|
+
## Naming Rules
|
|
320
|
+
|
|
321
|
+
Eventcraft uses `$` properties internally, such as `$emit`, `$subscribe`, `$once`, `$name`, `$members`, and `$select`.
|
|
322
|
+
|
|
323
|
+
Event and group keys cannot start with `$` or include `.`. Dots are reserved for nested event names like `notes.updated`.
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
eventcraft()
|
|
327
|
+
.events({
|
|
328
|
+
$saved: event(),
|
|
329
|
+
})
|
|
330
|
+
.build();
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
That throws at `build()` time.
|
|
334
|
+
|
|
335
|
+
Groups also cannot shadow existing events or branches.
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
eventcraft()
|
|
339
|
+
.events({
|
|
340
|
+
notes: {
|
|
341
|
+
updated: event<{ id: string }>(),
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
.groups((events) => ({
|
|
345
|
+
notes: group(events.notes.updated),
|
|
346
|
+
}))
|
|
347
|
+
.build();
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
That throws because `notes` is already a branch.
|
|
351
|
+
|
|
352
|
+
## Public API
|
|
353
|
+
|
|
354
|
+
Core:
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
import { event, eventcraft, group, select } from "@kellanjs/eventcraft";
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
React:
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
import { useEvent } from "@kellanjs/eventcraft/react";
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Types:
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
import type {
|
|
370
|
+
AnyEventSource,
|
|
371
|
+
BranchNode,
|
|
372
|
+
EventDef,
|
|
373
|
+
EventEnvelope,
|
|
374
|
+
EventListener,
|
|
375
|
+
EventNode,
|
|
376
|
+
EventcraftOptions,
|
|
377
|
+
EventRegistry,
|
|
378
|
+
GroupNode,
|
|
379
|
+
RootNode,
|
|
380
|
+
SubscribeOptions,
|
|
381
|
+
} from "@kellanjs/eventcraft";
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
## Thanks
|
|
385
|
+
|
|
386
|
+
If you made it this far, thanks for checking out the library, and I hope you find it useful in your projects!
|
|
387
|
+
|
|
388
|
+
## License
|
|
389
|
+
|
|
390
|
+
Keycraft is open source under the terms of the [MIT license](https://github.com/kellanjs/eventcraft/blob/main/LICENSE).
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
type EventEnvelope<Name extends string, Payload> = {
|
|
2
|
+
id: string;
|
|
3
|
+
name: Name;
|
|
4
|
+
payload: Payload;
|
|
5
|
+
timestamp: number;
|
|
6
|
+
};
|
|
7
|
+
type EventListener<Name extends string, Payload> = (payload: Payload, event: EventEnvelope<Name, Payload>) => void | Promise<void>;
|
|
8
|
+
type GroupListener<TEvent> = (event: TEvent) => void | Promise<void>;
|
|
9
|
+
type SubscribeOptions = {
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
};
|
|
12
|
+
type EventcraftOptions = {
|
|
13
|
+
createId?: () => string;
|
|
14
|
+
now?: () => number;
|
|
15
|
+
onEmit?: (event: AnyEvent) => void | Promise<void>;
|
|
16
|
+
onListenerError?: (error: unknown, event: AnyEvent) => void | Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
type EventArgs<Payload> = [Payload] extends [void] ? [] : [payload: Payload];
|
|
19
|
+
type EventNode<Name extends string, Payload> = ((...args: EventArgs<Payload>) => Promise<void>) & {
|
|
20
|
+
$emit: (...args: EventArgs<Payload>) => Promise<void>;
|
|
21
|
+
$name: Name;
|
|
22
|
+
$once: (listener: EventListener<Name, Payload>, options?: SubscribeOptions) => () => void;
|
|
23
|
+
$subscribe: (listener: EventListener<Name, Payload>, options?: SubscribeOptions) => () => void;
|
|
24
|
+
[Symbol.asyncIterator]: () => AsyncIterableIterator<EventEnvelope<Name, Payload>>;
|
|
25
|
+
};
|
|
26
|
+
type AnyEventNode = ((...args: never[]) => Promise<void>) & {
|
|
27
|
+
$emit: (...args: never[]) => Promise<void>;
|
|
28
|
+
$name: string;
|
|
29
|
+
$once: (listener: never, options?: SubscribeOptions) => () => void;
|
|
30
|
+
$subscribe: (listener: never, options?: SubscribeOptions) => () => void;
|
|
31
|
+
[Symbol.asyncIterator]: () => AsyncIterableIterator<AnyEvent>;
|
|
32
|
+
};
|
|
33
|
+
type GroupNode<Name extends string, TEvent> = {
|
|
34
|
+
$members: readonly string[];
|
|
35
|
+
$name: Name;
|
|
36
|
+
$once: (listener: GroupListener<TEvent>, options?: SubscribeOptions) => () => void;
|
|
37
|
+
$subscribe: (listener: GroupListener<TEvent>, options?: SubscribeOptions) => () => void;
|
|
38
|
+
[Symbol.asyncIterator]: () => AsyncIterableIterator<TEvent>;
|
|
39
|
+
};
|
|
40
|
+
type SubscribableNode<Name extends string, TEvent> = GroupNode<Name, TEvent>;
|
|
41
|
+
type BranchNode<Name extends string, TEvent> = SubscribableNode<Name, TEvent>;
|
|
42
|
+
type RootNode<TEvent> = SubscribableNode<"", TEvent> & {
|
|
43
|
+
$select: <const TSources extends readonly AnyEventSource[]>(sources: TSources) => SubscribableNode<"$selection", GroupEventOf<TSources>>;
|
|
44
|
+
};
|
|
45
|
+
type AnySubscribableNode = {
|
|
46
|
+
$members: readonly string[];
|
|
47
|
+
$name: string;
|
|
48
|
+
$once: (listener: GroupListener<unknown>, options?: SubscribeOptions) => () => void;
|
|
49
|
+
$subscribe: (listener: GroupListener<unknown>, options?: SubscribeOptions) => () => void;
|
|
50
|
+
};
|
|
51
|
+
type AnyEvent = {
|
|
52
|
+
id: string;
|
|
53
|
+
name: string;
|
|
54
|
+
payload: unknown;
|
|
55
|
+
timestamp: number;
|
|
56
|
+
};
|
|
57
|
+
declare const EVENT_MARKER: unique symbol;
|
|
58
|
+
type EventDef<Payload = void> = {
|
|
59
|
+
readonly [EVENT_MARKER]: true;
|
|
60
|
+
readonly __payload?: Payload;
|
|
61
|
+
};
|
|
62
|
+
type EventDefs = {
|
|
63
|
+
[key: string]: EventDef<unknown> | EventDefs;
|
|
64
|
+
};
|
|
65
|
+
type GroupDefs = {
|
|
66
|
+
[key: string]: readonly AnyEventSource[] | GroupDefs;
|
|
67
|
+
};
|
|
68
|
+
type Simplify<T> = {
|
|
69
|
+
[K in keyof T]: T[K];
|
|
70
|
+
};
|
|
71
|
+
type JoinPath<Prefix extends string, Key extends string> = Prefix extends "" ? Key : `${Prefix}.${Key}`;
|
|
72
|
+
type EventTreeEventFromDefs<TDefs, Prefix extends string = ""> = {
|
|
73
|
+
[K in keyof TDefs]: TDefs[K] extends EventDef<infer Payload> ? EventEnvelope<JoinPath<Prefix, Extract<K, string>>, Payload> : TDefs[K] extends EventDefs ? EventTreeEventFromDefs<TDefs[K], JoinPath<Prefix, Extract<K, string>>> : never;
|
|
74
|
+
}[keyof TDefs];
|
|
75
|
+
type EventTreeFromDefs<TDefs, Prefix extends string = ""> = Simplify<{
|
|
76
|
+
[K in keyof TDefs]: TDefs[K] extends EventDef<infer Payload> ? EventNode<JoinPath<Prefix, Extract<K, string>>, Payload> : TDefs[K] extends EventDefs ? BranchNode<JoinPath<Prefix, Extract<K, string>>, EventTreeEventFromDefs<TDefs[K], JoinPath<Prefix, Extract<K, string>>>> & EventTreeFromDefs<TDefs[K], JoinPath<Prefix, Extract<K, string>>> : never;
|
|
77
|
+
}>;
|
|
78
|
+
type EventNameOf<T> = T extends {
|
|
79
|
+
$name: infer Name extends string;
|
|
80
|
+
} ? Name : never;
|
|
81
|
+
type EventPayloadOf<T> = T extends {
|
|
82
|
+
$emit: (...args: infer Args) => Promise<void>;
|
|
83
|
+
} ? Args extends [] ? void : Args[0] : never;
|
|
84
|
+
type AnyEventSource = AnyEventNode | AnySubscribableNode;
|
|
85
|
+
type EventSourceEventOf<T> = T extends AnyEventNode ? EventEnvelope<EventNameOf<T>, EventPayloadOf<T>> : T extends SubscribableNode<string, infer TEvent> ? TEvent : never;
|
|
86
|
+
type GroupEventOf<TMembers extends readonly AnyEventSource[]> = {
|
|
87
|
+
[Index in keyof TMembers]: EventSourceEventOf<TMembers[Index]>;
|
|
88
|
+
}[number];
|
|
89
|
+
type GroupTreeFromDefs<TDefs, Prefix extends string = ""> = Simplify<{
|
|
90
|
+
[K in keyof TDefs]: TDefs[K] extends readonly AnyEventSource[] ? GroupNode<JoinPath<Prefix, Extract<K, string>>, GroupEventOf<TDefs[K]>> : TDefs[K] extends GroupDefs ? GroupTreeFromDefs<TDefs[K], JoinPath<Prefix, Extract<K, string>>> : never;
|
|
91
|
+
}>;
|
|
92
|
+
type Registry<TDefs extends EventDefs, TGroups extends GroupDefs> = Simplify<EventTreeFromDefs<TDefs> & GroupTreeFromDefs<TGroups> & RootNode<EventTreeEventFromDefs<TDefs>>>;
|
|
93
|
+
declare class Builder<TDefs extends EventDefs, TGroups extends GroupDefs = Record<never, never>> {
|
|
94
|
+
private readonly options;
|
|
95
|
+
private readonly defs;
|
|
96
|
+
private readonly resolveGroups?;
|
|
97
|
+
constructor(defs: TDefs, options: EventcraftOptions, resolveGroups?: (events: EventTreeFromDefs<TDefs>) => TGroups);
|
|
98
|
+
groups<TNextGroups extends GroupDefs>(resolve: (events: EventTreeFromDefs<TDefs>) => TNextGroups): Builder<TDefs, TNextGroups>;
|
|
99
|
+
build(): Registry<TDefs, TGroups>;
|
|
100
|
+
}
|
|
101
|
+
export declare function event(): EventDef<void>;
|
|
102
|
+
export declare function event<Payload>(): EventDef<Payload>;
|
|
103
|
+
export declare function group<const TMembers extends readonly AnyEventSource[]>(...members: TMembers): TMembers;
|
|
104
|
+
export declare function select<const TSources extends readonly AnyEventSource[]>(sources: TSources): SubscribableNode<"$selection", GroupEventOf<TSources>>;
|
|
105
|
+
export declare function eventcraft(options?: EventcraftOptions): {
|
|
106
|
+
events<TDefs extends EventDefs>(defs: TDefs): Builder<TDefs, Record<never, never>>;
|
|
107
|
+
};
|
|
108
|
+
export type { AnyEventNode, AnyEventSource, BranchNode, GroupNode, RootNode, EventDef, EventEnvelope, EventListener, EventNode, EventcraftOptions, Registry as EventRegistry, SubscribeOptions, };
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
const EVENT_MARKER = Symbol("eventcraft.event");
|
|
2
|
+
const INTERNAL_EVENTS = Symbol("eventcraft.events");
|
|
3
|
+
function assignGroupAtPath(root, path, value) {
|
|
4
|
+
const parts = path.split(".");
|
|
5
|
+
let cursor = root;
|
|
6
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
7
|
+
const part = parts[index];
|
|
8
|
+
const next = cursor[part];
|
|
9
|
+
if (next === undefined) {
|
|
10
|
+
cursor[part] = Object.create(null);
|
|
11
|
+
}
|
|
12
|
+
else if (typeof next !== "object" || next === null) {
|
|
13
|
+
throw new Error(`Eventcraft group "${path}" conflicts with an existing event at "${parts.slice(0, index + 1).join(".")}"`);
|
|
14
|
+
}
|
|
15
|
+
cursor = cursor[part];
|
|
16
|
+
}
|
|
17
|
+
const leaf = parts.at(-1);
|
|
18
|
+
const existing = cursor[leaf];
|
|
19
|
+
if (existing !== undefined) {
|
|
20
|
+
throw new Error(`Eventcraft group "${path}" conflicts with an existing ${typeof existing === "function" ? "event" : "branch"} at the same path`);
|
|
21
|
+
}
|
|
22
|
+
cursor[leaf] = value;
|
|
23
|
+
}
|
|
24
|
+
function isEventDef(value) {
|
|
25
|
+
return Boolean(value &&
|
|
26
|
+
typeof value === "object" &&
|
|
27
|
+
EVENT_MARKER in value);
|
|
28
|
+
}
|
|
29
|
+
function isGroupMembers(value) {
|
|
30
|
+
return Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
function isEventNode(value) {
|
|
33
|
+
if (typeof value !== "function") {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
const node = value;
|
|
37
|
+
return (typeof node.$emit === "function" &&
|
|
38
|
+
typeof node.$name === "string" &&
|
|
39
|
+
typeof node.$subscribe === "function");
|
|
40
|
+
}
|
|
41
|
+
function isSubscribableNode(value) {
|
|
42
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
const node = value;
|
|
46
|
+
return (Array.isArray(node.$members) &&
|
|
47
|
+
typeof node.$name === "string" &&
|
|
48
|
+
typeof node.$subscribe === "function");
|
|
49
|
+
}
|
|
50
|
+
function assertEventSource(value, context) {
|
|
51
|
+
if (isEventNode(value) || isSubscribableNode(value)) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
throw new TypeError(`Eventcraft ${context} must be an Eventcraft event, branch, group, selection, or registry.`);
|
|
55
|
+
}
|
|
56
|
+
function assertEventSourceList(value, context) {
|
|
57
|
+
if (!Array.isArray(value)) {
|
|
58
|
+
throw new TypeError(`Eventcraft ${context} must be an array.`);
|
|
59
|
+
}
|
|
60
|
+
value.forEach((source, index) => {
|
|
61
|
+
assertEventSource(source, `${context} source at index ${index}`);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function assertPublicKey(key, path) {
|
|
65
|
+
if (key.startsWith("$")) {
|
|
66
|
+
throw new Error(`Eventcraft keys cannot start with "$": ${path}`);
|
|
67
|
+
}
|
|
68
|
+
if (key.includes(".")) {
|
|
69
|
+
throw new Error(`Eventcraft keys cannot include ".": ${path}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function noop() { }
|
|
73
|
+
function createOnceSubscription(subscribe, listener, options) {
|
|
74
|
+
let active = true;
|
|
75
|
+
let unsubscribe = noop;
|
|
76
|
+
const cleanup = () => {
|
|
77
|
+
if (!active) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
active = false;
|
|
81
|
+
unsubscribe();
|
|
82
|
+
};
|
|
83
|
+
const onceListener = (...args) => {
|
|
84
|
+
if (!active) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
cleanup();
|
|
88
|
+
return listener(...args);
|
|
89
|
+
};
|
|
90
|
+
unsubscribe = subscribe(onceListener, options);
|
|
91
|
+
return cleanup;
|
|
92
|
+
}
|
|
93
|
+
function bindAbortSignal(unsubscribe, signal) {
|
|
94
|
+
let active = true;
|
|
95
|
+
const cleanup = () => {
|
|
96
|
+
if (!active) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
active = false;
|
|
100
|
+
signal?.removeEventListener("abort", cleanup);
|
|
101
|
+
unsubscribe();
|
|
102
|
+
};
|
|
103
|
+
signal?.addEventListener("abort", cleanup, { once: true });
|
|
104
|
+
return cleanup;
|
|
105
|
+
}
|
|
106
|
+
function createAsyncIterator(subscribe) {
|
|
107
|
+
const events = [];
|
|
108
|
+
const resolvers = [];
|
|
109
|
+
let active = true;
|
|
110
|
+
const unsubscribe = subscribe((event) => {
|
|
111
|
+
const resolve = resolvers.shift();
|
|
112
|
+
if (resolve) {
|
|
113
|
+
resolve({ done: false, value: event });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
events.push(event);
|
|
117
|
+
});
|
|
118
|
+
return {
|
|
119
|
+
[Symbol.asyncIterator]() {
|
|
120
|
+
return this;
|
|
121
|
+
},
|
|
122
|
+
next() {
|
|
123
|
+
const event = events.shift();
|
|
124
|
+
if (event !== undefined) {
|
|
125
|
+
return Promise.resolve({ done: false, value: event });
|
|
126
|
+
}
|
|
127
|
+
if (!active) {
|
|
128
|
+
return Promise.resolve({
|
|
129
|
+
done: true,
|
|
130
|
+
value: undefined,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
resolvers.push(resolve);
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
return() {
|
|
138
|
+
if (active) {
|
|
139
|
+
active = false;
|
|
140
|
+
unsubscribe();
|
|
141
|
+
}
|
|
142
|
+
for (const resolve of resolvers.splice(0)) {
|
|
143
|
+
resolve({
|
|
144
|
+
done: true,
|
|
145
|
+
value: undefined,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return Promise.resolve({
|
|
149
|
+
done: true,
|
|
150
|
+
value: undefined,
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
throw(error) {
|
|
154
|
+
if (active) {
|
|
155
|
+
active = false;
|
|
156
|
+
unsubscribe();
|
|
157
|
+
}
|
|
158
|
+
for (const resolve of resolvers.splice(0)) {
|
|
159
|
+
resolve({
|
|
160
|
+
done: true,
|
|
161
|
+
value: undefined,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return Promise.reject(error);
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function createEventNode(name, options) {
|
|
169
|
+
const listeners = new Set();
|
|
170
|
+
const emit = async (...args) => {
|
|
171
|
+
const payload = args[0];
|
|
172
|
+
const event = {
|
|
173
|
+
id: options.createId?.() ?? crypto.randomUUID(),
|
|
174
|
+
name,
|
|
175
|
+
payload,
|
|
176
|
+
timestamp: options.now?.() ?? Date.now(),
|
|
177
|
+
};
|
|
178
|
+
let onEmitPromise;
|
|
179
|
+
if (options.onEmit) {
|
|
180
|
+
try {
|
|
181
|
+
onEmitPromise = Promise.resolve(options.onEmit(event));
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
onEmitPromise = Promise.reject(error);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const results = await Promise.allSettled([...listeners].map(async (listener) => listener(payload, event)));
|
|
188
|
+
const listenerErrors = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
189
|
+
const onEmitHookResults = onEmitPromise
|
|
190
|
+
? await Promise.allSettled([onEmitPromise])
|
|
191
|
+
: [];
|
|
192
|
+
const listenerErrorHookResults = await Promise.allSettled(listenerErrors.map(async (error) => options.onListenerError?.(error, event)));
|
|
193
|
+
const errors = [
|
|
194
|
+
...listenerErrors,
|
|
195
|
+
...onEmitHookResults.flatMap((result) => result.status === "rejected" ? [result.reason] : []),
|
|
196
|
+
...listenerErrorHookResults.flatMap((result) => result.status === "rejected" ? [result.reason] : []),
|
|
197
|
+
];
|
|
198
|
+
if (errors.length === 1) {
|
|
199
|
+
throw errors[0];
|
|
200
|
+
}
|
|
201
|
+
if (errors.length > 1) {
|
|
202
|
+
throw new AggregateError(errors, `${errors.length} event handlers failed for "${name}"`);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
return Object.assign(emit, {
|
|
206
|
+
$emit: emit,
|
|
207
|
+
$name: name,
|
|
208
|
+
$once(listener, subscribeOptions) {
|
|
209
|
+
return createOnceSubscription(this.$subscribe.bind(this), listener, subscribeOptions);
|
|
210
|
+
},
|
|
211
|
+
$subscribe(listener, subscribeOptions) {
|
|
212
|
+
if (subscribeOptions?.signal?.aborted) {
|
|
213
|
+
return noop;
|
|
214
|
+
}
|
|
215
|
+
listeners.add(listener);
|
|
216
|
+
const unsubscribe = () => {
|
|
217
|
+
listeners.delete(listener);
|
|
218
|
+
};
|
|
219
|
+
return bindAbortSignal(unsubscribe, subscribeOptions?.signal);
|
|
220
|
+
},
|
|
221
|
+
[Symbol.asyncIterator]() {
|
|
222
|
+
return createAsyncIterator((listener) => this.$subscribe((_payload, event) => listener(event)));
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function collectEventNodes(value) {
|
|
227
|
+
if (isEventNode(value)) {
|
|
228
|
+
return [value];
|
|
229
|
+
}
|
|
230
|
+
if (!value || typeof value !== "object") {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
const internal = value[INTERNAL_EVENTS];
|
|
234
|
+
if (Array.isArray(internal)) {
|
|
235
|
+
return internal;
|
|
236
|
+
}
|
|
237
|
+
return Object.entries(value).flatMap(([key, child]) => key.startsWith("$") ? [] : collectEventNodes(child));
|
|
238
|
+
}
|
|
239
|
+
function uniqueEventNodes(members) {
|
|
240
|
+
return [...new Set(members)];
|
|
241
|
+
}
|
|
242
|
+
function subscribeToSource(source, listener, options) {
|
|
243
|
+
const eventListener = listener;
|
|
244
|
+
if (isEventNode(source)) {
|
|
245
|
+
const eventNode = source;
|
|
246
|
+
return eventNode.$subscribe((_payload, event) => eventListener(event), options);
|
|
247
|
+
}
|
|
248
|
+
const subscribableNode = source;
|
|
249
|
+
return subscribableNode.$subscribe(eventListener, options);
|
|
250
|
+
}
|
|
251
|
+
function createBranchNode(name, members) {
|
|
252
|
+
const uniqueMembers = uniqueEventNodes(members);
|
|
253
|
+
const node = {
|
|
254
|
+
$members: uniqueMembers.map((member) => member.$name),
|
|
255
|
+
$name: name,
|
|
256
|
+
$once(listener, options) {
|
|
257
|
+
return createOnceSubscription(this.$subscribe.bind(this), listener, options);
|
|
258
|
+
},
|
|
259
|
+
$subscribe(listener, options) {
|
|
260
|
+
if (options?.signal?.aborted) {
|
|
261
|
+
return noop;
|
|
262
|
+
}
|
|
263
|
+
const unsubscribes = uniqueMembers.map((member) => {
|
|
264
|
+
const eventNode = member;
|
|
265
|
+
return eventNode.$subscribe((_payload, event) => listener(event));
|
|
266
|
+
});
|
|
267
|
+
const unsubscribe = () => {
|
|
268
|
+
for (const unsubscribe of unsubscribes) {
|
|
269
|
+
unsubscribe();
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
return bindAbortSignal(unsubscribe, options?.signal);
|
|
273
|
+
},
|
|
274
|
+
[Symbol.asyncIterator]() {
|
|
275
|
+
return createAsyncIterator((listener) => this.$subscribe(listener));
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
Object.defineProperty(node, INTERNAL_EVENTS, {
|
|
279
|
+
value: uniqueMembers,
|
|
280
|
+
enumerable: false,
|
|
281
|
+
});
|
|
282
|
+
return node;
|
|
283
|
+
}
|
|
284
|
+
function createSelectionNode(members) {
|
|
285
|
+
assertEventSourceList(members, "selection");
|
|
286
|
+
const expandedMembers = [];
|
|
287
|
+
for (const member of members) {
|
|
288
|
+
if (isEventNode(member)) {
|
|
289
|
+
expandedMembers.push(member);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
expandedMembers.push(...collectEventNodes(member));
|
|
293
|
+
}
|
|
294
|
+
const uniqueMembers = uniqueEventNodes(expandedMembers);
|
|
295
|
+
const node = {
|
|
296
|
+
$members: uniqueMembers.map((member) => member.$name),
|
|
297
|
+
$name: "$selection",
|
|
298
|
+
$once(listener, options) {
|
|
299
|
+
return createOnceSubscription(this.$subscribe.bind(this), listener, options);
|
|
300
|
+
},
|
|
301
|
+
$subscribe(listener, options) {
|
|
302
|
+
if (options?.signal?.aborted) {
|
|
303
|
+
return noop;
|
|
304
|
+
}
|
|
305
|
+
const unsubscribes = uniqueMembers.map((member) => subscribeToSource(member, listener, options));
|
|
306
|
+
const unsubscribe = () => {
|
|
307
|
+
for (const unsubscribe of unsubscribes) {
|
|
308
|
+
unsubscribe();
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
return bindAbortSignal(unsubscribe, options?.signal);
|
|
312
|
+
},
|
|
313
|
+
[Symbol.asyncIterator]() {
|
|
314
|
+
return createAsyncIterator((listener) => this.$subscribe(listener));
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
Object.defineProperty(node, INTERNAL_EVENTS, {
|
|
318
|
+
value: uniqueMembers,
|
|
319
|
+
enumerable: false,
|
|
320
|
+
});
|
|
321
|
+
return node;
|
|
322
|
+
}
|
|
323
|
+
function buildEvents(defs, options, prefix = "") {
|
|
324
|
+
const result = Object.create(null);
|
|
325
|
+
for (const [key, value] of Object.entries(defs)) {
|
|
326
|
+
const name = prefix ? `${prefix}.${key}` : key;
|
|
327
|
+
assertPublicKey(key, name);
|
|
328
|
+
if (isEventDef(value)) {
|
|
329
|
+
result[key] = createEventNode(name, options);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const subtree = buildEvents(value, options, name);
|
|
333
|
+
const members = collectEventNodes(subtree);
|
|
334
|
+
Object.assign(subtree, createBranchNode(name, members));
|
|
335
|
+
result[key] = subtree;
|
|
336
|
+
}
|
|
337
|
+
return result;
|
|
338
|
+
}
|
|
339
|
+
class Builder {
|
|
340
|
+
options;
|
|
341
|
+
defs;
|
|
342
|
+
resolveGroups;
|
|
343
|
+
constructor(defs, options, resolveGroups) {
|
|
344
|
+
this.options = options;
|
|
345
|
+
this.defs = defs;
|
|
346
|
+
this.resolveGroups = resolveGroups;
|
|
347
|
+
}
|
|
348
|
+
groups(resolve) {
|
|
349
|
+
return new Builder(this.defs, this.options, resolve);
|
|
350
|
+
}
|
|
351
|
+
build() {
|
|
352
|
+
const events = buildEvents(this.defs, this.options);
|
|
353
|
+
const registry = events;
|
|
354
|
+
const groups = this.resolveGroups?.(events) ?? {};
|
|
355
|
+
const attachGroups = (defs, prefix = "") => {
|
|
356
|
+
for (const [key, value] of Object.entries(defs)) {
|
|
357
|
+
const name = prefix ? `${prefix}.${key}` : key;
|
|
358
|
+
assertPublicKey(key, name);
|
|
359
|
+
if (isGroupMembers(value)) {
|
|
360
|
+
const members = value;
|
|
361
|
+
const selection = createSelectionNode(members);
|
|
362
|
+
const node = {
|
|
363
|
+
...selection,
|
|
364
|
+
$name: name,
|
|
365
|
+
};
|
|
366
|
+
Object.defineProperty(node, INTERNAL_EVENTS, {
|
|
367
|
+
value: selection[INTERNAL_EVENTS],
|
|
368
|
+
enumerable: false,
|
|
369
|
+
});
|
|
370
|
+
assignGroupAtPath(registry, name, node);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
attachGroups(value, name);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
attachGroups(groups);
|
|
377
|
+
const rootMembers = collectEventNodes(registry);
|
|
378
|
+
const rootNode = createBranchNode("", rootMembers);
|
|
379
|
+
Object.assign(registry, rootNode, {
|
|
380
|
+
$select: createSelectionNode,
|
|
381
|
+
});
|
|
382
|
+
return registry;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
export function event() {
|
|
386
|
+
return {
|
|
387
|
+
[EVENT_MARKER]: true,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
export function group(...members) {
|
|
391
|
+
members.forEach((member, index) => {
|
|
392
|
+
assertEventSource(member, `group() source at index ${index}`);
|
|
393
|
+
});
|
|
394
|
+
return members;
|
|
395
|
+
}
|
|
396
|
+
export function select(sources) {
|
|
397
|
+
assertEventSourceList(sources, "select()");
|
|
398
|
+
return createSelectionNode(sources);
|
|
399
|
+
}
|
|
400
|
+
export function eventcraft(options = {}) {
|
|
401
|
+
return {
|
|
402
|
+
events(defs) {
|
|
403
|
+
return new Builder(defs, options);
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { group, select, event, eventcraft } from "./core/eventcraft.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useEvent } from "./use-event.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useEvent } from "./use-event.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AnyEventNode, AnyEventSource, EventListener, GroupNode } from "../core/eventcraft.js";
|
|
2
|
+
type EventNameOf<T> = T extends {
|
|
3
|
+
$name: infer Name extends string;
|
|
4
|
+
} ? Name : never;
|
|
5
|
+
type EventPayloadOf<T> = T extends {
|
|
6
|
+
$emit: (...args: infer Args) => Promise<void>;
|
|
7
|
+
} ? Args extends [] ? void : Args[0] : never;
|
|
8
|
+
type EventOf<T extends AnyEventNode> = {
|
|
9
|
+
id: string;
|
|
10
|
+
name: EventNameOf<T>;
|
|
11
|
+
payload: EventPayloadOf<T>;
|
|
12
|
+
timestamp: number;
|
|
13
|
+
};
|
|
14
|
+
type SubscribableEventOf<TSubscribable extends GroupNode<string, unknown>> = TSubscribable extends GroupNode<string, infer TEvent> ? TEvent : never;
|
|
15
|
+
type EventSourceEventOf<TSource> = TSource extends AnyEventNode ? EventOf<TSource> : TSource extends GroupNode<string, infer TEvent> ? TEvent : never;
|
|
16
|
+
type EventArrayEventOf<TEvents extends readonly AnyEventSource[]> = {
|
|
17
|
+
[Index in keyof TEvents]: EventSourceEventOf<TEvents[Index]>;
|
|
18
|
+
}[number];
|
|
19
|
+
type AnySubscribableNode = GroupNode<string, unknown>;
|
|
20
|
+
export declare function useEvent<TEvent extends AnyEventNode>(event: TEvent, listener: EventListener<EventNameOf<TEvent>, EventPayloadOf<TEvent>>): void;
|
|
21
|
+
export declare function useEvent<const TSources extends readonly AnyEventSource[]>(sources: TSources, listener: (event: EventArrayEventOf<TSources>) => void | Promise<void>): void;
|
|
22
|
+
export declare function useEvent<TSubscribable extends AnySubscribableNode>(subscribable: TSubscribable, listener: (event: SubscribableEventOf<TSubscribable>) => void | Promise<void>): void;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
function isEventArray(source) {
|
|
4
|
+
return Array.isArray(source);
|
|
5
|
+
}
|
|
6
|
+
function isEventNode(source) {
|
|
7
|
+
if (typeof source !== "function") {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
const node = source;
|
|
11
|
+
return (typeof node.$emit === "function" &&
|
|
12
|
+
typeof node.$name === "string" &&
|
|
13
|
+
typeof node.$subscribe === "function");
|
|
14
|
+
}
|
|
15
|
+
function isSubscribableNode(source) {
|
|
16
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
const node = source;
|
|
20
|
+
return (Array.isArray(node.$members) &&
|
|
21
|
+
typeof node.$name === "string" &&
|
|
22
|
+
typeof node.$subscribe === "function");
|
|
23
|
+
}
|
|
24
|
+
function assertEventSource(source, context) {
|
|
25
|
+
if (isEventNode(source) || isSubscribableNode(source)) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
throw new TypeError(`Eventcraft ${context} must be an Eventcraft event, branch, group, selection, or registry.`);
|
|
29
|
+
}
|
|
30
|
+
function assertEventSourceInput(source) {
|
|
31
|
+
if (!Array.isArray(source)) {
|
|
32
|
+
assertEventSource(source, "useEvent() source");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
source.forEach((member, index) => {
|
|
36
|
+
assertEventSource(member, `useEvent() source at index ${index}`);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function subscribeToSource(source, listener) {
|
|
40
|
+
if (isEventNode(source)) {
|
|
41
|
+
const event = source;
|
|
42
|
+
return event.$subscribe((_payload, event) => listener(event));
|
|
43
|
+
}
|
|
44
|
+
const subscribable = source;
|
|
45
|
+
return subscribable.$subscribe(listener);
|
|
46
|
+
}
|
|
47
|
+
function useStableListener(listener) {
|
|
48
|
+
const listenerRef = React.useRef(listener);
|
|
49
|
+
listenerRef.current = listener;
|
|
50
|
+
return listenerRef;
|
|
51
|
+
}
|
|
52
|
+
function areSourcesEqual(left, right) {
|
|
53
|
+
if (Object.is(left, right)) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(left) || !Array.isArray(right)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return (left.length === right.length &&
|
|
60
|
+
left.every((member, index) => Object.is(member, right[index])));
|
|
61
|
+
}
|
|
62
|
+
function useStableSource(source) {
|
|
63
|
+
const sourceRef = React.useRef(source);
|
|
64
|
+
if (!areSourcesEqual(sourceRef.current, source)) {
|
|
65
|
+
sourceRef.current = source;
|
|
66
|
+
}
|
|
67
|
+
return sourceRef.current;
|
|
68
|
+
}
|
|
69
|
+
export function useEvent(source, listener) {
|
|
70
|
+
assertEventSourceInput(source);
|
|
71
|
+
const listenerRef = useStableListener(listener);
|
|
72
|
+
const stableSource = useStableSource(source);
|
|
73
|
+
React.useEffect(() => {
|
|
74
|
+
if (isEventArray(stableSource)) {
|
|
75
|
+
const unsubscribes = stableSource.map((member) => subscribeToSource(member, (event) => {
|
|
76
|
+
const current = listenerRef.current;
|
|
77
|
+
return current(event);
|
|
78
|
+
}));
|
|
79
|
+
return () => {
|
|
80
|
+
for (const unsubscribe of unsubscribes) {
|
|
81
|
+
unsubscribe();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (isEventNode(stableSource)) {
|
|
86
|
+
const event = stableSource;
|
|
87
|
+
return event.$subscribe((payload, event) => {
|
|
88
|
+
const current = listenerRef.current;
|
|
89
|
+
return current(payload, event);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const subscribable = stableSource;
|
|
93
|
+
return subscribable.$subscribe((event) => {
|
|
94
|
+
const current = listenerRef.current;
|
|
95
|
+
return current(event);
|
|
96
|
+
});
|
|
97
|
+
}, [listenerRef, stableSource]);
|
|
98
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kellanjs/eventcraft",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A tiny, type-safe event library with inferred payloads, selective subscriptions, and optional React hooks.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"events",
|
|
7
|
+
"subscribers",
|
|
8
|
+
"pub-sub",
|
|
9
|
+
"event-emitter",
|
|
10
|
+
"typescript",
|
|
11
|
+
"type-safety",
|
|
12
|
+
"type-inference",
|
|
13
|
+
"react",
|
|
14
|
+
"hooks",
|
|
15
|
+
"dx"
|
|
16
|
+
],
|
|
17
|
+
"author": "kellanjs",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"homepage": "https://github.com/kellanjs/eventcraft",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/kellanjs/eventcraft.git"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/kellanjs/eventcraft/issues"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"type": "module",
|
|
31
|
+
"main": "./dist/index.js",
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"import": "./dist/index.js"
|
|
37
|
+
},
|
|
38
|
+
"./react": {
|
|
39
|
+
"types": "./dist/react/index.d.ts",
|
|
40
|
+
"import": "./dist/react/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./package.json": "./package.json"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"react": ">=16.8.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"react": {
|
|
52
|
+
"optional": true
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"sideEffects": false,
|
|
56
|
+
"files": [
|
|
57
|
+
"dist"
|
|
58
|
+
],
|
|
59
|
+
"scripts": {
|
|
60
|
+
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
61
|
+
"dev": "vitest tests/eventcraft.test.ts tests/use-event.test.ts",
|
|
62
|
+
"prebuild": "npm run clean",
|
|
63
|
+
"build": "tsc",
|
|
64
|
+
"lint": "eslint .",
|
|
65
|
+
"type": "tsc --noEmit",
|
|
66
|
+
"type:test": "tsc --noEmit -p tsconfig.test.json",
|
|
67
|
+
"prepack": "npm run build",
|
|
68
|
+
"pretest": "npm run build",
|
|
69
|
+
"test": "vitest run",
|
|
70
|
+
"pretest:coverage": "npm run build",
|
|
71
|
+
"test:coverage": "vitest run --coverage",
|
|
72
|
+
"format": "prettier --write .",
|
|
73
|
+
"check-format": "prettier --check .",
|
|
74
|
+
"check-exports": "attw --pack . --ignore-rules cjs-resolves-to-esm no-resolution",
|
|
75
|
+
"ci": "npm run lint && npm run type && npm run build && npm run type:test && npm run check-format && npm run check-exports && npm run test"
|
|
76
|
+
},
|
|
77
|
+
"devDependencies": {
|
|
78
|
+
"@arethetypeswrong/cli": "^0.18.2",
|
|
79
|
+
"@eslint/js": "^10.0.1",
|
|
80
|
+
"@testing-library/react": "^16.3.2",
|
|
81
|
+
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
|
82
|
+
"@types/node": "^25.6.0",
|
|
83
|
+
"@types/react": "^19.2.14",
|
|
84
|
+
"@types/react-dom": "^19.2.3",
|
|
85
|
+
"@vitest/coverage-v8": "^4.0.8",
|
|
86
|
+
"eslint": "^10.2.1",
|
|
87
|
+
"globals": "^16.5.0",
|
|
88
|
+
"jsdom": "^29.1.1",
|
|
89
|
+
"prettier": "^3.8.3",
|
|
90
|
+
"react": "^19.2.5",
|
|
91
|
+
"react-dom": "^19.2.5",
|
|
92
|
+
"typescript": "^6.0.3",
|
|
93
|
+
"typescript-eslint": "^8.46.2",
|
|
94
|
+
"vitest": "^4.1.5"
|
|
95
|
+
}
|
|
96
|
+
}
|