event-emission 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/src/observe.ts ADDED
@@ -0,0 +1,734 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment -- Proxy handlers require any spreads */
2
+ /* eslint-disable @typescript-eslint/no-redundant-type-constituents -- Type unions are intentional for flexibility */
3
+
4
+ import type { EventfulEvent, EventTargetLike } from './types';
5
+
6
+ // =============================================================================
7
+ // DOM Type Stubs (for DOM-free environments)
8
+ // =============================================================================
9
+
10
+ /** Minimal EventTarget interface for duck-typing */
11
+ interface MinimalEventTarget {
12
+ addEventListener(type: string, listener: (event: unknown) => void): void;
13
+ removeEventListener(type: string, listener: (event: unknown) => void): void;
14
+ dispatchEvent(event: unknown): boolean;
15
+ }
16
+
17
+ /** Minimal Event interface for forwarding */
18
+ interface MinimalEvent {
19
+ type: string;
20
+ }
21
+
22
+ /** Minimal CustomEvent interface for forwarding */
23
+ interface MinimalCustomEvent extends MinimalEvent {
24
+ detail?: unknown;
25
+ }
26
+
27
+ /** Type declaration for structuredClone (available in modern runtimes) */
28
+ declare function structuredClone<T>(value: T): T;
29
+
30
+ // =============================================================================
31
+ // Symbols
32
+ // =============================================================================
33
+
34
+ /** Symbol marking an object as proxied */
35
+ export const PROXY_MARKER = Symbol.for('@lasercat/eventful/proxy');
36
+
37
+ /** Symbol to access the original unproxied target */
38
+ export const ORIGINAL_TARGET = Symbol.for('@lasercat/eventful/original');
39
+
40
+ // =============================================================================
41
+ // Types
42
+ // =============================================================================
43
+
44
+ /** Options for observable proxy creation */
45
+ export interface ObserveOptions {
46
+ /** Enable deep observation of nested objects (default: true) */
47
+ deep?: boolean;
48
+ /** Clone strategy for previous state (default: 'path') */
49
+ cloneStrategy?: 'shallow' | 'deep' | 'path';
50
+ }
51
+
52
+ /** Event detail for property changes */
53
+ export interface PropertyChangeDetail<T = unknown> {
54
+ /** The new value */
55
+ value: T;
56
+ /** Current state of the root object (after change) */
57
+ current: unknown;
58
+ /** Previous state of the root object (before change) */
59
+ previous: unknown;
60
+ }
61
+
62
+ /** Array methods that mutate the array */
63
+ export type ArrayMutationMethod =
64
+ | 'push'
65
+ | 'pop'
66
+ | 'shift'
67
+ | 'unshift'
68
+ | 'splice'
69
+ | 'sort'
70
+ | 'reverse'
71
+ | 'fill'
72
+ | 'copyWithin';
73
+
74
+ /** Event detail for array mutations */
75
+ export interface ArrayMutationDetail<T = unknown> {
76
+ /** The array method that was called */
77
+ method: ArrayMutationMethod;
78
+ /** Arguments passed to the method */
79
+ args: unknown[];
80
+ /** Return value of the method */
81
+ result: unknown;
82
+ /** Items that were added (if applicable) */
83
+ added?: T[];
84
+ /** Items that were removed (if applicable) */
85
+ removed?: T[];
86
+ /** Current state of the root object (after change) */
87
+ current: unknown;
88
+ /** Previous state of the root object (before change) */
89
+ previous: unknown;
90
+ }
91
+
92
+ /** Event map for observable objects */
93
+ export type ObservableEventMap<_T extends object> = {
94
+ update: PropertyChangeDetail;
95
+ [key: `update:${string}`]: PropertyChangeDetail | ArrayMutationDetail;
96
+ };
97
+
98
+ // =============================================================================
99
+ // Constants
100
+ // =============================================================================
101
+
102
+ const ARRAY_MUTATORS = new Set<ArrayMutationMethod>([
103
+ 'push',
104
+ 'pop',
105
+ 'shift',
106
+ 'unshift',
107
+ 'splice',
108
+ 'sort',
109
+ 'reverse',
110
+ 'fill',
111
+ 'copyWithin',
112
+ ]);
113
+
114
+ // =============================================================================
115
+ // Internal Types
116
+ // =============================================================================
117
+
118
+ interface ProxyContext<T extends object> {
119
+ eventTarget: EventTargetLike<ObservableEventMap<T>>;
120
+ /** Reference to the original (unproxied) root object for cloning */
121
+ originalRoot: T;
122
+ options: Required<ObserveOptions>;
123
+ }
124
+
125
+ // =============================================================================
126
+ // Utility Functions
127
+ // =============================================================================
128
+
129
+ /** Check if a value can be proxied */
130
+ function isProxyable(value: unknown): value is object {
131
+ return (
132
+ value !== null &&
133
+ typeof value === 'object' &&
134
+ !isProxied(value) &&
135
+ !(value instanceof Date) &&
136
+ !(value instanceof RegExp) &&
137
+ !(value instanceof Map) &&
138
+ !(value instanceof Set) &&
139
+ !(value instanceof WeakMap) &&
140
+ !(value instanceof WeakSet) &&
141
+ !(value instanceof Promise) &&
142
+ !(value instanceof Error) &&
143
+ !(value instanceof ArrayBuffer) &&
144
+ !ArrayBuffer.isView(value)
145
+ );
146
+ }
147
+
148
+ /** Check if already proxied */
149
+ function isProxied(value: unknown): boolean {
150
+ return (
151
+ typeof value === 'object' &&
152
+ value !== null &&
153
+ (value as Record<symbol, unknown>)[PROXY_MARKER] === true
154
+ );
155
+ }
156
+
157
+ /** Check if property is an array mutator */
158
+ function isArrayMutator(prop: string | symbol): prop is ArrayMutationMethod {
159
+ return typeof prop === 'string' && ARRAY_MUTATORS.has(prop as ArrayMutationMethod);
160
+ }
161
+
162
+ /** Clone along changed path for efficiency */
163
+ function cloneAlongPath(obj: unknown, path?: string): unknown {
164
+ if (obj === null || typeof obj !== 'object') {
165
+ return obj;
166
+ }
167
+
168
+ if (!path) {
169
+ return Array.isArray(obj) ? [...obj] : { ...obj };
170
+ }
171
+
172
+ const parts = path.split('.');
173
+
174
+ if (Array.isArray(obj)) {
175
+ // For arrays, shallow copy
176
+ return [...obj];
177
+ }
178
+
179
+ const result: Record<string, unknown> = { ...obj };
180
+
181
+ let current: Record<string, unknown> = result;
182
+ // Clone all objects along the path, INCLUDING the leaf
183
+ for (let i = 0; i < parts.length; i++) {
184
+ const key = parts[i];
185
+ const value = current[key];
186
+ if (value !== null && typeof value === 'object') {
187
+ current[key] = Array.isArray(value) ? [...value] : { ...value };
188
+ // Only traverse deeper if not the last element
189
+ if (i < parts.length - 1) {
190
+ current = current[key] as Record<string, unknown>;
191
+ }
192
+ } else {
193
+ break;
194
+ }
195
+ }
196
+
197
+ return result;
198
+ }
199
+
200
+ /** Clone for comparison based on strategy */
201
+ function cloneForComparison(
202
+ obj: unknown,
203
+ strategy: 'shallow' | 'deep' | 'path',
204
+ changedPath?: string,
205
+ ): unknown {
206
+ if (obj === null || typeof obj !== 'object') return obj;
207
+
208
+ switch (strategy) {
209
+ case 'shallow':
210
+ return Array.isArray(obj) ? [...obj] : { ...obj };
211
+
212
+ case 'deep':
213
+ return structuredClone(obj);
214
+
215
+ case 'path':
216
+ return cloneAlongPath(obj, changedPath);
217
+
218
+ default:
219
+ return obj;
220
+ }
221
+ }
222
+
223
+ /** Compute array diff for mutation events */
224
+ function computeArrayDiff(
225
+ method: ArrayMutationMethod,
226
+ before: unknown[],
227
+ _after: unknown[],
228
+ args: unknown[],
229
+ ): { added?: unknown[]; removed?: unknown[] } {
230
+ switch (method) {
231
+ case 'push':
232
+ return { added: args };
233
+ case 'pop':
234
+ return { removed: before.length > 0 ? [before[before.length - 1]] : [] };
235
+ case 'shift':
236
+ return { removed: before.length > 0 ? [before[0]] : [] };
237
+ case 'unshift':
238
+ return { added: args };
239
+ case 'splice': {
240
+ const [start, deleteCount, ...items] = args as [number, number?, ...unknown[]];
241
+ const actualStart =
242
+ start < 0 ? Math.max(before.length + start, 0) : Math.min(start, before.length);
243
+ const actualDeleteCount = Math.min(
244
+ deleteCount ?? before.length - actualStart,
245
+ before.length - actualStart,
246
+ );
247
+ return {
248
+ removed: before.slice(actualStart, actualStart + actualDeleteCount),
249
+ added: items,
250
+ };
251
+ }
252
+ case 'sort':
253
+ case 'reverse':
254
+ case 'fill':
255
+ case 'copyWithin':
256
+ return {};
257
+ default:
258
+ return {};
259
+ }
260
+ }
261
+
262
+ // =============================================================================
263
+ // Proxy Registry (prevents duplicate proxying)
264
+ // =============================================================================
265
+
266
+ // Registry key combines target object with context to allow same object
267
+ // to be observed in different contexts
268
+ const proxyRegistry = new WeakMap<
269
+ object,
270
+ WeakMap<ProxyContext<object>, { proxy: object; path: string }>
271
+ >();
272
+
273
+ /** Get or create proxy registry entry for a context */
274
+ function getContextRegistry(
275
+ target: object,
276
+ ): WeakMap<ProxyContext<object>, { proxy: object; path: string }> {
277
+ let contextMap = proxyRegistry.get(target);
278
+ if (!contextMap) {
279
+ contextMap = new WeakMap();
280
+ proxyRegistry.set(target, contextMap);
281
+ }
282
+ return contextMap;
283
+ }
284
+
285
+ // =============================================================================
286
+ // Array Method Interceptor
287
+ // =============================================================================
288
+
289
+ function createArrayMethodInterceptor<T extends object>(
290
+ array: unknown[],
291
+ method: ArrayMutationMethod,
292
+ path: string,
293
+ context: ProxyContext<T>,
294
+ ): (...args: unknown[]) => unknown {
295
+ const original = array[method as keyof typeof array] as (...args: unknown[]) => unknown;
296
+
297
+ return function (this: unknown[], ...args: unknown[]): unknown {
298
+ // Clone from original (unproxied) root BEFORE mutation
299
+ const previousState = cloneForComparison(
300
+ context.originalRoot,
301
+ context.options.cloneStrategy,
302
+ path,
303
+ );
304
+ const previousItems = [...array];
305
+
306
+ const result = original.apply(this, args);
307
+
308
+ const { added, removed } = computeArrayDiff(method, previousItems, array, args);
309
+
310
+ // Determine event path - for root arrays, avoid leading dot
311
+ const methodEventPath = path ? `update:${path}.${method}` : `update:${method}`;
312
+ const arrayEventPath = path ? `update:${path}` : 'update:';
313
+
314
+ // Dispatch method-specific event
315
+ context.eventTarget.dispatchEvent({
316
+ type: methodEventPath as keyof ObservableEventMap<T> & string,
317
+ detail: {
318
+ method,
319
+ args,
320
+ result,
321
+ added,
322
+ removed,
323
+ current: context.originalRoot,
324
+ previous: previousState,
325
+ },
326
+ } as EventfulEvent<ObservableEventMap<T>[keyof ObservableEventMap<T>]>);
327
+
328
+ // Dispatch path event for the array itself (only if path is non-empty)
329
+ if (path) {
330
+ context.eventTarget.dispatchEvent({
331
+ type: arrayEventPath as keyof ObservableEventMap<T> & string,
332
+ detail: {
333
+ value: array,
334
+ current: context.originalRoot,
335
+ previous: previousState,
336
+ },
337
+ } as EventfulEvent<ObservableEventMap<T>[keyof ObservableEventMap<T>]>);
338
+ }
339
+
340
+ // Dispatch top-level update
341
+ context.eventTarget.dispatchEvent({
342
+ type: 'update' as keyof ObservableEventMap<T> & string,
343
+ detail: {
344
+ current: context.originalRoot,
345
+ previous: previousState,
346
+ },
347
+ } as EventfulEvent<ObservableEventMap<T>[keyof ObservableEventMap<T>]>);
348
+
349
+ return result;
350
+ };
351
+ }
352
+
353
+ // =============================================================================
354
+ // Core Proxy Creation
355
+ // =============================================================================
356
+
357
+ function createObservableProxyInternal<T extends object>(
358
+ target: T,
359
+ path: string,
360
+ context: ProxyContext<T>,
361
+ ): T {
362
+ // Check if this exact object is already proxied for this context
363
+ const contextRegistry = getContextRegistry(target);
364
+ const existing = contextRegistry.get(context as unknown as ProxyContext<object>);
365
+ if (existing) {
366
+ // Return existing proxy - note: shared objects will use the first path they were accessed from
367
+ // This is intentional to avoid duplicate event dispatching
368
+ return existing.proxy as T;
369
+ }
370
+
371
+ const proxy = new Proxy(target, {
372
+ get(obj, prop, receiver) {
373
+ // Handle internal markers
374
+ if (prop === PROXY_MARKER) return true;
375
+ if (prop === ORIGINAL_TARGET) return obj;
376
+
377
+ // Pass through symbols
378
+ if (typeof prop === 'symbol') {
379
+ return Reflect.get(obj, prop, receiver);
380
+ }
381
+
382
+ const value = Reflect.get(obj, prop, receiver);
383
+
384
+ // Intercept array mutating methods
385
+ if (Array.isArray(obj) && isArrayMutator(prop)) {
386
+ return createArrayMethodInterceptor(obj, prop, path, context);
387
+ }
388
+
389
+ // Lazy proxy nested objects/arrays
390
+ if (context.options.deep && isProxyable(value)) {
391
+ const nestedPath = path ? `${path}.${prop}` : prop;
392
+ return createObservableProxyInternal(
393
+ value as object,
394
+ nestedPath,
395
+ context as ProxyContext<object>,
396
+ );
397
+ }
398
+
399
+ return value;
400
+ },
401
+
402
+ set(obj, prop, value, receiver) {
403
+ // Pass through symbols
404
+ if (typeof prop === 'symbol') {
405
+ return Reflect.set(obj, prop, value, receiver);
406
+ }
407
+
408
+ const oldValue = Reflect.get(obj, prop, receiver);
409
+
410
+ // Skip if value unchanged (shallow equality)
411
+ if (Object.is(oldValue, value)) {
412
+ return true;
413
+ }
414
+
415
+ // Capture previous state before mutation (from original, not proxy)
416
+ const propPath = path ? `${path}.${prop}` : prop;
417
+ const previousState = cloneForComparison(
418
+ context.originalRoot,
419
+ context.options.cloneStrategy,
420
+ propPath,
421
+ );
422
+
423
+ const success = Reflect.set(obj, prop, value, receiver);
424
+
425
+ if (success) {
426
+ // Dispatch path-specific event
427
+ context.eventTarget.dispatchEvent({
428
+ type: `update:${propPath}` as keyof ObservableEventMap<T> & string,
429
+ detail: {
430
+ value,
431
+ current: context.originalRoot,
432
+ previous: previousState,
433
+ },
434
+ } as EventfulEvent<ObservableEventMap<T>[keyof ObservableEventMap<T>]>);
435
+
436
+ // Dispatch top-level update event
437
+ context.eventTarget.dispatchEvent({
438
+ type: 'update' as keyof ObservableEventMap<T> & string,
439
+ detail: {
440
+ current: context.originalRoot,
441
+ previous: previousState,
442
+ },
443
+ } as EventfulEvent<ObservableEventMap<T>[keyof ObservableEventMap<T>]>);
444
+ }
445
+
446
+ return success;
447
+ },
448
+
449
+ deleteProperty(obj, prop) {
450
+ // Pass through symbols
451
+ if (typeof prop === 'symbol') {
452
+ return Reflect.deleteProperty(obj, prop);
453
+ }
454
+
455
+ const propPath = path ? `${path}.${String(prop)}` : String(prop);
456
+ const previousState = cloneForComparison(
457
+ context.originalRoot,
458
+ context.options.cloneStrategy,
459
+ propPath,
460
+ );
461
+
462
+ const success = Reflect.deleteProperty(obj, prop);
463
+
464
+ if (success) {
465
+ // Dispatch path-specific event
466
+ context.eventTarget.dispatchEvent({
467
+ type: `update:${propPath}` as keyof ObservableEventMap<T> & string,
468
+ detail: {
469
+ value: undefined,
470
+ current: context.originalRoot,
471
+ previous: previousState,
472
+ },
473
+ } as EventfulEvent<ObservableEventMap<T>[keyof ObservableEventMap<T>]>);
474
+
475
+ // Dispatch top-level update event
476
+ context.eventTarget.dispatchEvent({
477
+ type: 'update' as keyof ObservableEventMap<T> & string,
478
+ detail: {
479
+ current: context.originalRoot,
480
+ previous: previousState,
481
+ },
482
+ } as EventfulEvent<ObservableEventMap<T>[keyof ObservableEventMap<T>]>);
483
+ }
484
+
485
+ return success;
486
+ },
487
+ });
488
+
489
+ // Register the proxy
490
+ contextRegistry.set(context as unknown as ProxyContext<object>, {
491
+ proxy,
492
+ path,
493
+ });
494
+
495
+ return proxy;
496
+ }
497
+
498
+ // =============================================================================
499
+ // EventTarget Forwarding
500
+ // =============================================================================
501
+
502
+ /** Duck-type check for EventTarget */
503
+ function isEventTarget(obj: unknown): obj is MinimalEventTarget {
504
+ return (
505
+ typeof obj === 'object' &&
506
+ obj !== null &&
507
+ typeof (obj as MinimalEventTarget).addEventListener === 'function' &&
508
+ typeof (obj as MinimalEventTarget).removeEventListener === 'function' &&
509
+ typeof (obj as MinimalEventTarget).dispatchEvent === 'function'
510
+ );
511
+ }
512
+
513
+ /**
514
+ * Sets up event forwarding from a source EventTarget to an Eventful target.
515
+ *
516
+ * This function enables integration between DOM EventTargets and Eventful targets.
517
+ * When listeners are added to the target, corresponding forwarding handlers are
518
+ * automatically registered on the source. Update events are not forwarded to
519
+ * prevent circular event loops.
520
+ *
521
+ * @template T - The object type whose events are being forwarded.
522
+ * @param source - The DOM EventTarget to forward events from.
523
+ * @param target - The Eventful target to forward events to.
524
+ * @returns A cleanup function that removes all forwarding handlers when called.
525
+ *
526
+ * @example
527
+ * ```typescript
528
+ * const domElement = document.getElementById('my-element');
529
+ * const events = createEventTarget<{ click: MouseEvent; focus: FocusEvent }>();
530
+ *
531
+ * const cleanup = setupEventForwarding(domElement, events);
532
+ *
533
+ * // When you add listeners to events, they will receive events from domElement
534
+ * events.addEventListener('click', (event) => {
535
+ * console.log('Click received via forwarding');
536
+ * });
537
+ *
538
+ * // Stop forwarding when done
539
+ * cleanup();
540
+ * ```
541
+ */
542
+ export function setupEventForwarding<T extends object>(
543
+ source: MinimalEventTarget,
544
+ target: EventTargetLike<ObservableEventMap<T>>,
545
+ ): () => void {
546
+ const handlers = new Map<string, (event: unknown) => void>();
547
+
548
+ const forwardHandler = (type: string) => (event: unknown) => {
549
+ const detail = (event as MinimalCustomEvent).detail ?? event;
550
+ target.dispatchEvent({
551
+ type: type as keyof ObservableEventMap<T> & string,
552
+ detail,
553
+ } as EventfulEvent<ObservableEventMap<T>[keyof ObservableEventMap<T>]>);
554
+ };
555
+
556
+ // Save original method reference without mutating target
557
+ const originalAddEventListener = target.addEventListener.bind(target);
558
+
559
+ // Create a wrapped addEventListener that also sets up forwarding
560
+ const wrappedAddEventListener = ((
561
+ type: string,
562
+ listener: (event: EventfulEvent<unknown>) => void | Promise<void>,
563
+ options?: unknown,
564
+ ) => {
565
+ // Forward non-update events from source (lazily, once per type)
566
+ if (!handlers.has(type) && type !== 'update' && !type.startsWith('update:')) {
567
+ const handler = forwardHandler(type);
568
+ handlers.set(type, handler);
569
+ source.addEventListener(type, handler);
570
+ }
571
+ return originalAddEventListener(
572
+ type as keyof ObservableEventMap<T> & string,
573
+ listener as (
574
+ event: EventfulEvent<ObservableEventMap<T>[keyof ObservableEventMap<T>]>,
575
+ ) => void,
576
+ options as Parameters<typeof originalAddEventListener>[2],
577
+ );
578
+ }) as typeof target.addEventListener;
579
+
580
+ // Replace the addEventListener method
581
+ (target as { addEventListener: typeof wrappedAddEventListener }).addEventListener =
582
+ wrappedAddEventListener;
583
+
584
+ return () => {
585
+ // Restore original addEventListener
586
+ (target as { addEventListener: typeof originalAddEventListener }).addEventListener =
587
+ originalAddEventListener;
588
+ // Clean up all forwarding handlers
589
+ for (const [type, handler] of handlers) {
590
+ source.removeEventListener(type, handler);
591
+ }
592
+ handlers.clear();
593
+ };
594
+ }
595
+
596
+ // =============================================================================
597
+ // Public API
598
+ // =============================================================================
599
+
600
+ /**
601
+ * Checks if an object is an observed proxy created by createObservableProxy.
602
+ *
603
+ * Use this to determine whether an object is being tracked for changes.
604
+ * Useful for conditional logic or debugging.
605
+ *
606
+ * @param obj - The object to check.
607
+ * @returns True if the object is an observed proxy, false otherwise.
608
+ *
609
+ * @example
610
+ * ```typescript
611
+ * const original = { count: 0 };
612
+ * const state = createEventTarget(original, { observe: true });
613
+ *
614
+ * console.log(isObserved(original)); // false
615
+ * console.log(isObserved(state)); // true
616
+ * ```
617
+ */
618
+ export function isObserved(obj: unknown): boolean {
619
+ return isProxied(obj);
620
+ }
621
+
622
+ /**
623
+ * Retrieves the original unproxied object from an observed proxy.
624
+ *
625
+ * When you pass an object to createEventTarget with observe: true, a Proxy
626
+ * wrapper is created. This function returns the underlying original object,
627
+ * which is useful when you need direct access without triggering events.
628
+ *
629
+ * @template T - The object type.
630
+ * @param proxy - The observed proxy (or any object).
631
+ * @returns The original unproxied object. If the input is not a proxy, returns it unchanged.
632
+ *
633
+ * @example
634
+ * ```typescript
635
+ * const original = { count: 0 };
636
+ * const state = createEventTarget(original, { observe: true });
637
+ *
638
+ * // state is a Proxy wrapping original
639
+ * const unwrapped = getOriginal(state);
640
+ *
641
+ * console.log(unwrapped === original); // true
642
+ * console.log(unwrapped === state); // false
643
+ * ```
644
+ *
645
+ * @example Passing to external APIs that don't work with Proxies
646
+ * ```typescript
647
+ * const data = createEventTarget({ items: [] }, { observe: true });
648
+ *
649
+ * // Some serialization libraries have issues with Proxies
650
+ * const json = JSON.stringify(getOriginal(data));
651
+ * ```
652
+ */
653
+ export function getOriginal<T extends object>(proxy: T): T {
654
+ if (!isProxied(proxy)) {
655
+ return proxy;
656
+ }
657
+ return (proxy as Record<symbol, T>)[ORIGINAL_TARGET] ?? proxy;
658
+ }
659
+
660
+ /**
661
+ * Creates an observable proxy that dispatches events when properties change.
662
+ *
663
+ * This function wraps an object in a Proxy that tracks all property modifications,
664
+ * including nested objects and array mutations. Events are dispatched to the
665
+ * provided event target for each change.
666
+ *
667
+ * Note: This is typically called internally by createEventTarget with observe: true.
668
+ * You usually don't need to call this directly.
669
+ *
670
+ * @template T - The object type being observed.
671
+ * @param target - The object to observe.
672
+ * @param eventTarget - The event target to dispatch change events to.
673
+ * @param options - Optional configuration for observation behavior.
674
+ * @returns A proxied version of the target that dispatches events on changes.
675
+ *
676
+ * @example Direct usage (advanced)
677
+ * ```typescript
678
+ * import { createEventTarget, createObservableProxy } from 'event-emission';
679
+ *
680
+ * type State = { count: number };
681
+ * const eventTarget = createEventTarget<ObservableEventMap<State>>();
682
+ * const original = { count: 0 };
683
+ *
684
+ * const state = createObservableProxy(original, eventTarget, {
685
+ * deep: true,
686
+ * cloneStrategy: 'path',
687
+ * });
688
+ *
689
+ * eventTarget.addEventListener('update', (event) => {
690
+ * console.log('State changed:', event.detail);
691
+ * });
692
+ *
693
+ * state.count = 1; // Triggers 'update' and 'update:count' events
694
+ * ```
695
+ *
696
+ * @example Typical usage via createEventTarget
697
+ * ```typescript
698
+ * const state = createEventTarget({ count: 0 }, { observe: true });
699
+ *
700
+ * state.addEventListener('update:count', (event) => {
701
+ * console.log('Count changed to:', event.detail.value);
702
+ * });
703
+ *
704
+ * state.count = 1; // Triggers the event
705
+ * ```
706
+ */
707
+ export function createObservableProxy<T extends object>(
708
+ target: T,
709
+ eventTarget: EventTargetLike<ObservableEventMap<T>>,
710
+ options?: ObserveOptions,
711
+ ): T {
712
+ const resolvedOptions: Required<ObserveOptions> = {
713
+ deep: options?.deep ?? true,
714
+ cloneStrategy: options?.cloneStrategy ?? 'path',
715
+ };
716
+
717
+ const context: ProxyContext<T> = {
718
+ eventTarget,
719
+ originalRoot: target, // Keep reference to original, never the proxy
720
+ options: resolvedOptions,
721
+ };
722
+
723
+ const proxy = createObservableProxyInternal(target, '', context);
724
+
725
+ // Set up event forwarding if target is already an EventTarget
726
+ if (isEventTarget(target)) {
727
+ setupEventForwarding(target as unknown as MinimalEventTarget, eventTarget);
728
+ }
729
+
730
+ return proxy;
731
+ }
732
+
733
+ /* eslint-enable @typescript-eslint/no-unsafe-assignment */
734
+ /* eslint-enable @typescript-eslint/no-redundant-type-constituents */