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/LICENSE +21 -0
- package/README.md +331 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/eventful.d.ts +153 -0
- package/dist/eventful.d.ts.map +1 -0
- package/dist/factory.d.ts +89 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/index.cjs +1126 -0
- package/dist/index.cjs.map +15 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1093 -0
- package/dist/index.js.map +15 -0
- package/dist/interop.d.ts +163 -0
- package/dist/interop.d.ts.map +1 -0
- package/dist/observe.d.ts +182 -0
- package/dist/observe.d.ts.map +1 -0
- package/dist/symbols.d.ts +6 -0
- package/dist/symbols.d.ts.map +1 -0
- package/dist/types.d.ts +139 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +107 -0
- package/src/errors.ts +10 -0
- package/src/eventful.ts +323 -0
- package/src/factory.ts +948 -0
- package/src/index.ts +71 -0
- package/src/interop.ts +271 -0
- package/src/observe.ts +734 -0
- package/src/symbols.ts +12 -0
- package/src/types.ts +206 -0
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 */
|