@brinzl/observer 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/AGENTS.md ADDED
@@ -0,0 +1,172 @@
1
+ # Observer.js
2
+
3
+ A lightweight, zero-dependency reactive object observation library built for learning purposes.
4
+
5
+ ## Project Overview
6
+
7
+ This library provides a way to observe changes to JavaScript objects and arrays using ES6 Proxies. When properties are modified, subscribers are notified with detailed change records.
8
+
9
+ ## API Design
10
+
11
+ ```javascript
12
+ // Create an observable from a plain object
13
+ const observable = Observer.create({ name: 'Alice', age: 30 });
14
+
15
+ // Subscribe to all changes
16
+ const unsubscribe = Observer.observe(observable, (changes) => {
17
+ changes.forEach(change => {
18
+ console.log(change.type); // 'set' | 'delete' | 'insert' | 'batch'
19
+ console.log(change.path); // ['user', 'name'] - path to the changed prop
20
+ console.log(change.oldValue);
21
+ console.log(change.newValue);
22
+ });
23
+ });
24
+
25
+ // Mutations trigger the callback automatically
26
+ observable.name = 'Bob'; // fires the subscriber
27
+ observable.address = { city: 'NY' }; // nested objects also become observable
28
+
29
+ // Unsubscribe when done
30
+ unsubscribe();
31
+
32
+ // Or destroy entirely
33
+ Observer.destroy(observable);
34
+ ```
35
+
36
+ ## Architecture
37
+
38
+ ```
39
+ ┌─────────────────────────────────────────────────────────────────┐
40
+ │ Observer (Public API) │
41
+ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
42
+ │ │ create() │ │ observe() │ │ destroy() │ │
43
+ │ └──────┬──────┘ └──────┬──────┘ └────────────┬────────────┘ │
44
+ └─────────┼────────────────┼──────────────────────┼───────────────┘
45
+ │ │ │
46
+ ▼ ▼ ▼
47
+ ┌─────────────────┐ ┌───────────────────┐ ┌──────────────────┐
48
+ │ObservableFactory│ │ SubscriberRegistry│ │ ProxyRegistry │
49
+ │ │ │ │ │ (WeakMap-based) │
50
+ │ - createProxy() │ │ - subscribe() │ │ │
51
+ │ - wrapValue() │ │ - unsubscribe() │ │ - stores metadata│
52
+ │ │ │ - notify() │ │ - revoke fns │
53
+ └────────┬────────┘ └─────────┬─────────┘ └──────────────────┘
54
+ │ │
55
+ ▼ │
56
+ ┌─────────────────────────┐ │
57
+ │ ProxyHandler │ │
58
+ │ │ │
59
+ │ - get trap (lazy wrap) │ │
60
+ │ - set trap ─────────────┼────┼──────┐
61
+ │ - deleteProperty trap ──┼────┼──────┤
62
+ │ - array method patches │ │ │
63
+ └─────────────────────────┘ │ ▼
64
+ │ ┌───────────────┐
65
+ │ │ ChangeQueue │
66
+ │ │ │
67
+ │ │ - enqueue() │
68
+ │ │ - flush() ───┼──► notify subscribers
69
+ │ └───────────────┘
70
+ ```
71
+
72
+ ## File Structure
73
+
74
+ ```
75
+ observer.js/
76
+ ├── src/
77
+ │ ├── index.js # Public API (Observer.create/observe/destroy)
78
+ │ ├── symbols.js # Shared symbols
79
+ │ ├── registry.js # ProxyRegistry (WeakMap wrapper)
80
+ │ ├── factory.js # ObservableFactory (createProxy logic)
81
+ │ ├── queue.js # ChangeQueue (microtask batching)
82
+ │ ├── subscribers.js # SubscriberRegistry
83
+ │ └── array-methods.js # Array mutator wrappers
84
+ ├── test.js # Manual testing script
85
+ ├── package.json
86
+ └── AGENTS.md
87
+ ```
88
+
89
+ ## Design Decisions
90
+
91
+ ### Core Principles
92
+
93
+ 1. **WeakMap for metadata** - Storing observer state (subscribers, original target, revoke functions) in a WeakMap keyed by the proxy means observables are garbage collected naturally when nothing else holds a reference.
94
+
95
+ 2. **Lazy recursive proxying** - Don't walk the entire object tree up front. When a nested object is accessed via a get trap, wrap it in a proxy at that point. This keeps `create()` fast regardless of object size.
96
+
97
+ 3. **Double-wrap prevention** - Use a sentinel symbol (`IS_OBSERVABLE`) so that if someone passes an already-proxied object into `create()`, it returns as-is rather than double-wrapping.
98
+
99
+ 4. **Revocable proxies** - Use `Proxy.revocable()` instead of `new Proxy()` so that `destroy()` can fully invalidate the proxy and prevent further mutations.
100
+
101
+ 5. **Microtask batching** - Changes are batched per microtask (using `queueMicrotask`) rather than firing synchronously on every mutation. This avoids flooding subscribers when multiple properties change at once.
102
+
103
+ 6. **Path tracking** - Each change record includes the path (e.g. `['address', 'city']`) from the root observable to the changed property.
104
+
105
+ ### Scope Decisions
106
+
107
+ - **Objects & Arrays only** - No Map/Set support (can be added later)
108
+ - **Path-filtered subscriptions** - Planned as a later enhancement
109
+ - **Circular references** - Not currently handled (documented as unsupported)
110
+
111
+ ## Implementation Phases
112
+
113
+ ### Phase 1: Core Proxy Wrapper (No Reactivity)
114
+ - Create proxies that wrap objects/arrays
115
+ - Track them in a WeakMap registry
116
+ - Implement lazy nested proxy creation
117
+ - Expose internal symbols for debugging
118
+
119
+ ### Phase 2: Change Detection (Set/Delete Traps)
120
+ - Detect mutations via `set` and `deleteProperty` traps
121
+ - Create change records with type, path, oldValue, newValue
122
+ - Wrap newly assigned objects in proxies
123
+
124
+ ### Phase 3: Change Queue & Microtask Batching
125
+ - Accumulate changes within current microtask
126
+ - Flush batched changes to subscribers
127
+ - Group changes by root observable
128
+
129
+ ### Phase 4: Subscriber Registry
130
+ - Implement `Observer.observe()` and unsubscribe
131
+ - Notify subscribers with batched changes
132
+ - Handle subscriber errors gracefully
133
+
134
+ ### Phase 5: Array Method Interception
135
+ - Intercept mutating methods: push, pop, shift, unshift, splice, sort, reverse
136
+ - Emit meaningful `insert`/`delete` change types
137
+ - Wrap inserted objects in proxies
138
+
139
+ ### Phase 6: Destroy & Cleanup
140
+ - Implement `Observer.destroy()`
141
+ - Revoke proxies to prevent further use
142
+ - Clear subscribers and registry entries
143
+
144
+ ## Internal Symbols
145
+
146
+ ```javascript
147
+ SYMBOLS = {
148
+ IS_OBSERVABLE: Symbol('observer.isObservable'), // Detect wrapped proxies
149
+ RAW: Symbol('observer.raw'), // Access original target
150
+ ROOT: Symbol('observer.root'), // Get root observable
151
+ PATH: Symbol('observer.path') // Get path from root
152
+ }
153
+ ```
154
+
155
+ ## Change Record Format
156
+
157
+ ```javascript
158
+ {
159
+ type: 'set' | 'delete' | 'insert',
160
+ path: string[], // e.g. ['user', 'profile', 'name']
161
+ oldValue: any,
162
+ newValue: any
163
+ }
164
+ ```
165
+
166
+ ## Testing
167
+
168
+ Manual testing via `node test.js`. Run tests after each phase to verify implementation.
169
+
170
+ ## Module System
171
+
172
+ ES Modules (import/export) with `"type": "module"` in package.json.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brinsil Elias
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,130 @@
1
+ # observer.js
2
+
3
+ A tiny, zero-dependency reactive object observation library using ES6 Proxies.
4
+ Observe changes to JavaScript objects and arrays with automatic nested tracking and microtask batching.
5
+
6
+ ## Why?
7
+
8
+ While exploring how Vue.js handle reactivity, and I came across observer pattern that powers their reactive systems. This library was built as a project to understand the fundamentals of reactive programming from first principles.
9
+
10
+ Inspired by libraries like [object-observer](https://github.com/gullerya/object-observer), [icaro](https://github.com/GianlucaGuarini/icaro), and [micro-observer](https://github.com/nicedoc/micro-observer).
11
+
12
+ ## Features
13
+
14
+ - Observe deeply nested objects and arrays
15
+ - Automatic change batching via microtasks
16
+ - Path tracking for precise change locations
17
+ - Clean teardown with proxy revocation
18
+ - Zero dependencies, ~2KB minified
19
+
20
+ ## Usage
21
+
22
+ ```
23
+ npm install @brinzl/observer
24
+ ```
25
+
26
+ ### Basic
27
+
28
+ ```javascript
29
+ import { Observer } from '@brinzl/observer';
30
+
31
+ // Create an observable object.
32
+ const obs = Observer.create({ name: 'Alice', age: 30 });
33
+
34
+ // Subscribe to changes.
35
+ const unsubscribe = Observer.observe(obs, (changes) => {
36
+ changes.forEach(change => {
37
+ console.log(change.type); // 'set' | 'delete'
38
+ console.log(change.path); // ['name']
39
+ console.log(change.oldValue); // 'Alice'
40
+ console.log(change.newValue); // 'Bob'
41
+ });
42
+ });
43
+
44
+ // Mutations trigger the callback.
45
+ obs.name = 'Bob';
46
+ obs.age = 31;
47
+
48
+ // Unsubscribe when done.
49
+ unsubscribe();
50
+ ```
51
+
52
+ ### Nested Objects
53
+
54
+ ```javascript
55
+ const obs = Observer.create({
56
+ user: {
57
+ profile: { name: 'Alice' }
58
+ }
59
+ });
60
+
61
+ Observer.observe(obs, (changes) => {
62
+ console.log(changes);
63
+ });
64
+
65
+ // Nested objects are automatically observed.
66
+ obs.user.profile.name = 'Bob';
67
+ // Change: { type: 'set', path: ['user', 'profile', 'name'], oldValue: 'Alice', newValue: 'Bob' }
68
+ ```
69
+
70
+ ### Arrays
71
+
72
+ ```javascript
73
+ const obs = Observer.create({ items: [1, 2, 3] });
74
+
75
+ Observer.observe(obs, (changes) => {
76
+ console.log(changes);
77
+ });
78
+
79
+ obs.items.push(4);
80
+ obs.items[0] = 10;
81
+ ```
82
+
83
+ ### Cleanup
84
+
85
+ ```javascript
86
+ const obs = Observer.create({ data: 'test' });
87
+
88
+ // Completely destroy the observable.
89
+ Observer.destroy(obs);
90
+
91
+ // Further access throws an error.
92
+ obs.data; // TypeError: Cannot perform 'get' on a proxy that has been revoked
93
+ ```
94
+
95
+ ### Accessing Internals
96
+
97
+ ```javascript
98
+ const { IS_OBSERVABLE, RAW, ROOT, PATH } = Observer.symbols;
99
+
100
+ const obs = Observer.create({ nested: { value: 1 } });
101
+
102
+ obs[IS_OBSERVABLE]; // true
103
+ obs[RAW]; // { nested: { value: 1 } } (original object)
104
+ obs.nested[PATH]; // ['nested']
105
+ obs.nested[ROOT] === obs; // true
106
+ ```
107
+
108
+ ## API
109
+
110
+ | Method | Description |
111
+ |--------|-------------|
112
+ | `Observer.create(target)` | Creates an observable from a plain object or array |
113
+ | `Observer.observe(observable, callback)` | Subscribes to changes, returns an unsubscribe function |
114
+ | `Observer.destroy(observable)` | Revokes the proxy and clears all subscribers |
115
+ | `Observer.symbols` | Internal symbols for accessing raw target, path, and root |
116
+
117
+ ## Change Record
118
+
119
+ ```javascript
120
+ {
121
+ type: 'set' | 'delete',
122
+ path: string[], // e.g. ['user', 'profile', 'name']
123
+ oldValue: any,
124
+ newValue: any
125
+ }
126
+ ```
127
+
128
+ ## License
129
+
130
+ MIT
package/index.html ADDED
@@ -0,0 +1,10 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Observer.js Tests</title>
6
+ </head>
7
+ <body>
8
+ <script type="module" src="./index.js"></script>
9
+ </body>
10
+ </html>
package/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import { Observer } from "./observer";
2
+ Window.Observer = Observer
package/observer.js ADDED
@@ -0,0 +1,187 @@
1
+ const symbols = {
2
+ IS_OBSERVABLE: Symbol('observer.isObservable'),
3
+ RAW: Symbol('observer.raw'),
4
+ ROOT: Symbol('observer.root'),
5
+ PATH: Symbol('observer.path'),
6
+ }
7
+
8
+ let queue = []
9
+ let scheduled = false
10
+
11
+ const registry = new WeakMap()
12
+ const targetToProxy = new WeakMap()
13
+
14
+ const subscribers = new Map()
15
+
16
+ const register = (proxy, metadata) => {
17
+ targetToProxy.set(metadata.target, proxy)
18
+ return registry.set(proxy, metadata)
19
+ }
20
+ const getMetadata = (proxy) => {
21
+ return registry.get(proxy)
22
+ }
23
+ const unregister = (proxy) => {
24
+ const metadata = registry.get(proxy)
25
+ if (metadata) {
26
+ targetToProxy.delete(metadata.target)
27
+ }
28
+ return registry.delete(proxy)
29
+ }
30
+
31
+ const notifySubscribers = (root, changes) => {
32
+ const callbacks = subscribers.get(root)
33
+ if (!callbacks || callbacks.size === 0) return
34
+
35
+ for (const callback of callbacks) {
36
+ try {
37
+ callback(changes)
38
+ } catch (error) {
39
+ console.error('Subscriber error:', error)
40
+ }
41
+ }
42
+ }
43
+
44
+ const flush = () => {
45
+ const batch = queue
46
+ const changesByRoot = new Map()
47
+
48
+ queue = []
49
+ scheduled = false
50
+
51
+ for (let change of batch) {
52
+ if (!changesByRoot.has(change.root)) {
53
+ changesByRoot.set(change.root, [])
54
+ }
55
+ changesByRoot.get(change.root).push({
56
+ type: change.type,
57
+ path: change.path,
58
+ oldValue: change.oldValue,
59
+ newValue: change.newValue
60
+ })
61
+ }
62
+ for (let [root, changes] of changesByRoot) {
63
+ notifySubscribers(root, changes)
64
+ }
65
+ }
66
+
67
+ const enqueue = (change, root) => {
68
+ queue.push({ ...change, root })
69
+ if (!scheduled) {
70
+ scheduled = true
71
+ queueMicrotask(flush)
72
+ }
73
+ }
74
+
75
+ const handler = {
76
+ get: (target, property, receiver) => {
77
+ if (property === symbols.IS_OBSERVABLE) return true
78
+ if (property === symbols.RAW) return target
79
+ if (property === symbols.ROOT) return getMetadata(receiver).root
80
+ if (property === symbols.PATH) return getMetadata(receiver).path
81
+
82
+ const value = Reflect.get(target, property, receiver)
83
+ if (value === null || typeof value !== 'object') {
84
+ return value
85
+ }
86
+
87
+ const metadata = getMetadata(receiver)
88
+ const wrappedValue = createProxy(value, metadata.root, [...metadata.path, property])
89
+ return wrappedValue
90
+ },
91
+ set: (target, property, value, receiver) => {
92
+ const oldValue = target[property]
93
+ const metadata = getMetadata(receiver)
94
+
95
+ if (value !== null && typeof value === 'object') {
96
+ value = createProxy(value, metadata.root, [...metadata.path, property])
97
+ }
98
+
99
+ const success = Reflect.set(target, property, value, receiver)
100
+ if (success && oldValue !== value) {
101
+ enqueue({
102
+ type: 'set',
103
+ path: [...metadata.path, property],
104
+ oldValue,
105
+ newValue: value
106
+ }, metadata.root)
107
+ }
108
+ return success
109
+ },
110
+ deleteProperty: (target, property) => {
111
+ if (!(property in target)) return true
112
+ const oldValue = target[property]
113
+ const proxy = targetToProxy.get(target)
114
+ const metadata = getMetadata(proxy)
115
+
116
+ const success = Reflect.deleteProperty(target, property)
117
+ if (success) {
118
+ enqueue({
119
+ type: 'delete',
120
+ path: [...metadata.path, property],
121
+ oldValue,
122
+ newValue: undefined
123
+ }, metadata.root)
124
+ }
125
+ return success
126
+ }
127
+ }
128
+
129
+ const createProxy = (target, root = null, path = []) => {
130
+ if (target === null || typeof target !== 'object') return target
131
+ if (target[symbols.IS_OBSERVABLE] === true) return target
132
+
133
+ const { proxy, revoke } = Proxy.revocable(target, handler)
134
+ const metadata = {
135
+ target,
136
+ revoke,
137
+ root: root ?? proxy,
138
+ path
139
+ }
140
+
141
+ register(proxy, metadata)
142
+ return proxy
143
+ }
144
+
145
+ const subscribe = (root, callback) => {
146
+ if (!subscribers.has(root)) subscribers.set(root, new Set())
147
+ subscribers.get(root).add(callback)
148
+
149
+ return () => {
150
+ const set = subscribers.get(root)
151
+ if (set) {
152
+ set.delete(callback)
153
+ if (set.size === 0) subscribers.delete(root)
154
+ }
155
+ }
156
+ }
157
+
158
+ export const Observer = {
159
+ create: (target) => {
160
+ if (target === null || typeof target !== 'object') {
161
+ throw new TypeError('Observer.create requires an Object or an Array')
162
+ }
163
+
164
+ return createProxy(target)
165
+ },
166
+ observe: (observable, callback) => {
167
+ if (!observable[symbols.IS_OBSERVABLE]) {
168
+ throw new TypeError('Observer.observe must have an observable')
169
+ }
170
+
171
+ if (typeof callback !== 'function') {
172
+ throw new TypeError('Observer.observe must have a callback function')
173
+ }
174
+
175
+ return subscribe(observable, callback)
176
+ },
177
+ destroy: (observable) => {
178
+ const metadata = getMetadata(observable)
179
+ if (!metadata) return
180
+ subscribers.delete(observable)
181
+ unregister(observable)
182
+ metadata.revoke()
183
+ },
184
+ symbols
185
+ }
186
+
187
+
@@ -0,0 +1 @@
1
+ var a={IS_OBSERVABLE:Symbol("observer.isObservable"),RAW:Symbol("observer.raw"),ROOT:Symbol("observer.root"),PATH:Symbol("observer.path")},f=[],i=!1,l=new WeakMap,d=new WeakMap,c=new Map,y=(e,t)=>{return d.set(t.target,e),l.set(e,t)},u=(e)=>{return l.get(e)},S=(e)=>{let t=l.get(e);if(t)d.delete(t.target);return l.delete(e)},O=(e,t)=>{let o=c.get(e);if(!o||o.size===0)return;for(let r of o)try{r(t)}catch(s){console.error("Subscriber error:",s)}},V=()=>{let e=f,t=new Map;f=[],i=!1;for(let o of e){if(!t.has(o.root))t.set(o.root,[]);t.get(o.root).push({type:o.type,path:o.path,oldValue:o.oldValue,newValue:o.newValue})}for(let[o,r]of t)O(o,r)},w=(e,t)=>{if(f.push({...e,root:t}),!i)i=!0,queueMicrotask(V)},m={get:(e,t,o)=>{if(t===a.IS_OBSERVABLE)return!0;if(t===a.RAW)return e;if(t===a.ROOT)return u(o).root;if(t===a.PATH)return u(o).path;let r=Reflect.get(e,t,o);if(r===null||typeof r!=="object")return r;let s=u(o);return b(r,s.root,[...s.path,t])},set:(e,t,o,r)=>{let s=e[t],n=u(r);if(o!==null&&typeof o==="object")o=b(o,n.root,[...n.path,t]);let h=Reflect.set(e,t,o,r);if(h&&s!==o)w({type:"set",path:[...n.path,t],oldValue:s,newValue:o},n.root);return h},deleteProperty:(e,t)=>{if(!(t in e))return!0;let o=e[t],r=d.get(e),s=u(r),n=Reflect.deleteProperty(e,t);if(n)w({type:"delete",path:[...s.path,t],oldValue:o,newValue:void 0},s.root);return n}},b=(e,t=null,o=[])=>{if(e===null||typeof e!=="object")return e;if(e[a.IS_OBSERVABLE]===!0)return e;let{proxy:r,revoke:s}=Proxy.revocable(e,m);return y(r,{target:e,revoke:s,root:t??r,path:o}),r},p=(e,t)=>{if(!c.has(e))c.set(e,new Set);return c.get(e).add(t),()=>{let o=c.get(e);if(o){if(o.delete(t),o.size===0)c.delete(e)}}},R={create:(e)=>{if(e===null||typeof e!=="object")throw TypeError("Observer.create requires an Object or an Array");return b(e)},observe:(e,t)=>{if(!e[a.IS_OBSERVABLE])throw TypeError("Observer.observe must have an observable");if(typeof t!=="function")throw TypeError("Observer.observe must have a callback function");return p(e,t)},destroy:(e)=>{let t=u(e);if(!t)return;c.delete(e),S(e),t.revoke()},symbols:a};export{R as Observer};
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@brinzl/observer",
3
+ "version": "0.1.0",
4
+ "description": "A lightweight, zero-dependency reactive object observation library.",
5
+ "main": "observer.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "echo \"Error: no test specified\" && exit 1"
9
+ },
10
+ "keywords": [],
11
+ "author": "Brinsil Elias",
12
+ "license": "MIT",
13
+ "bugs": {
14
+ "url": "https://github.com/brinzl/observer.js/issues"
15
+ },
16
+ "homepage": "https://github.com/brinzl/observer.js#readme",
17
+ "publishConfig": {
18
+ "access": "public"
19
+ }
20
+ }