@nerdalytics/beacon 1.0.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 ADDED
@@ -0,0 +1,8 @@
1
+ The MIT License (MIT)
2
+ Copyright © 2025 Denny Trebbin (nerdalytics)
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
5
+
6
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
7
+
8
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # Beacon
2
+
3
+ A lightweight reactive signal library for Node.js backends. Enables reactive state management with automatic dependency tracking and efficient updates for server-side applications.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Features](#features)
8
+ - [Installation](#installation)
9
+ - [Usage](#usage)
10
+ - [API](#api)
11
+ - [state](#statetinitialvalue-t-signalt)
12
+ - [derived](#derivedfn--t-signalt)
13
+ - [effect](#effectfn--void--void)
14
+ - [batch](#batchfn--t-t)
15
+ - [Development](#development)
16
+ - [Node.js LTS Compatibility](#nodejs-lts-compatibility)
17
+ - [Key Differences vs TC39 Proposal](#key-differences-between-my-library-and-the-tc39-proposal)
18
+ - [Implementation Details](#implementation-details)
19
+ - [FAQ](#faq)
20
+ - [License](#license)
21
+
22
+ ## Features
23
+
24
+ - 🔄 **Reactive signals** - Create reactive values that automatically track dependencies
25
+ - 🧮 **Computed values** - Derive values from other signals with automatic updates
26
+ - 🔍 **Fine-grained reactivity** - Dependencies are tracked precisely at the signal level
27
+ - 🏎️ **Efficient updates** - Only recompute values when dependencies change
28
+ - 📦 **Batched updates** - Group multiple updates for performance
29
+ - 🧹 **Automatic cleanup** - Effects and computations automatically clean up dependencies
30
+ - 🔁 **Cycle handling** - Safely manages cyclic dependencies without crashing
31
+ - 🛠️ **TypeScript-first** - Full TypeScript support with generics
32
+ - 🪶 **Lightweight** - Zero dependencies, < 200 LOC
33
+ - ✅ **Node.js compatibility** - Works with Node.js LTS v20+ and v22+
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ npm install @nerdalytics/beacon
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ```typescript
44
+ import { state, derived, effect, batch } from "@nerdalytics/beacon";
45
+
46
+ // Create reactive state
47
+ const count = state(0);
48
+ const doubled = derived(() => count() * 2);
49
+
50
+ // Read values
51
+ console.log(count()); // => 0
52
+ console.log(doubled()); // => 0
53
+
54
+ // Setup an effect that automatically runs when dependencies change
55
+ // effect() returns a cleanup function that removes all subscriptions when called
56
+ const unsubscribe = effect(() => {
57
+ console.log(`Count is ${count()}, doubled is ${doubled()}`);
58
+ });
59
+ // => "Count is 0, doubled is 0" (effect runs immediately when created)
60
+
61
+ // Update values - effect automatically runs after each change
62
+ count.set(5);
63
+ // => "Count is 5, doubled is 10"
64
+
65
+ // Update with a function
66
+ count.update((n) => n + 1);
67
+ // => "Count is 6, doubled is 12"
68
+
69
+ // Batch updates (only triggers effects once at the end)
70
+ batch(() => {
71
+ count.set(10);
72
+ count.set(20);
73
+ });
74
+ // => "Count is 20, doubled is 40" (only once)
75
+
76
+ // Unsubscribe the effect to stop it from running on future updates
77
+ // and clean up all its internal subscriptions
78
+ unsubscribe();
79
+ ```
80
+
81
+ ## API
82
+
83
+ ### `state<T>(initialValue: T): Signal<T>`
84
+
85
+ Creates a new reactive signal with the given initial value.
86
+
87
+ ### `derived<T>(fn: () => T): Signal<T>`
88
+
89
+ Creates a derived signal that updates when its dependencies change.
90
+
91
+ ### `effect(fn: () => void): () => void`
92
+
93
+ Creates an effect that runs the given function immediately and whenever its dependencies change. Returns an unsubscribe function that stops the effect and cleans up all subscriptions when called.
94
+
95
+ ### `batch<T>(fn: () => T): T`
96
+
97
+ Batches multiple updates to only trigger effects once at the end.
98
+
99
+ ## Development
100
+
101
+ ```bash
102
+ # Install dependencies
103
+ npm install
104
+
105
+ # Run all tests
106
+ npm test
107
+
108
+ # Run all tests with coverage
109
+ npm run test:coverage
110
+
111
+ # Run specific test suites
112
+ # Core functionality
113
+ npm run test:unit:state
114
+ npm run test:unit:derived
115
+ npm run test:unit:effect
116
+ npm run test:unit:batch
117
+
118
+ # Advanced patterns
119
+ npm run test:unit:cleanup # Tests for effect cleanup behavior
120
+ npm run test:unit:cyclic # Tests for cyclic dependency handling
121
+
122
+ # Format code
123
+ npm run format
124
+
125
+ # Build for Node.js LTS compatibility (v20+)
126
+ npm run build:lts
127
+ ```
128
+
129
+ ### Node.js LTS Compatibility
130
+
131
+ Beacon supports the two most recent Node.js LTS versions (currently v20 and v22). When the package is published to npm, it includes transpiled code compatible with these LTS versions.
132
+
133
+ ## Key Differences Between My Library and the [TC39 Proposal][1]
134
+
135
+ | Aspect | @nerdalytics/beacon | TC39 Proposal |
136
+ |--------|---------------------|---------------|
137
+ | **API Style** | Functional approach (`state()`, `derived()`) | Class-based design (`Signal.State`, `Signal.Computed`) |
138
+ | **Reading/Writing Pattern** | Function call for reading (`count()`), methods for writing (`count.set(5)`) | Method-based access (`get()`/`set()`) |
139
+ | **Framework Support** | High-level abstractions like `effect()` and `batch()` | Lower-level primitives (`Signal.subtle.Watcher`) that frameworks build upon |
140
+ | **Advanced Features** | Focused on core reactivity | Includes introspection capabilities, watched/unwatched callbacks, and Signal.subtle namespace |
141
+ | **Scope and Purpose** | Practical Node.js use cases with minimal API surface | Standardization with robust interoperability between frameworks |
142
+
143
+ ## Implementation Details
144
+
145
+ Beacon is designed with a focus on simplicity, performance, and robust handling of complex dependency scenarios.
146
+
147
+ ### Key Implementation Concepts
148
+
149
+ - **Fine-grained reactivity**: Dependencies are tracked automatically at the signal level
150
+ - **Efficient updates**: Changes only propagate to affected parts of the dependency graph
151
+ - **Cyclical dependency handling**: Robust handling of circular references without crashing
152
+ - **Memory management**: Automatic cleanup of subscriptions when effects are disposed
153
+
154
+ For an in-depth explanation of Beacon's internal architecture, advanced features, and best practices for handling complex scenarios like cyclical dependencies, see the [TECHNICAL_DETAILS.md][2] document.
155
+
156
+ ## FAQ
157
+
158
+ <details>
159
+
160
+ <summary>Why "Beacon" Instead of "Signal"?</summary>
161
+ I chose "Beacon" because it clearly represents how the library broadcasts notifications when state changes—just like a lighthouse guides ships. While my library draws inspiration from Preact Signals, Angular Signals, and aspects of Svelte, I wanted to create something lighter and specifically designed for Node.js backends. Using "Beacon" instead of "Signal" helps avoid confusion with the TC39 proposal and similar libraries while still accurately describing the core functionality.
162
+
163
+ </details>
164
+
165
+ <details>
166
+
167
+ <summary>How does Beacon handle infinite update cycles?</summary>
168
+ Beacon uses a queue-based update system that won't crash even with cyclical dependencies. If signals form a cycle where values constantly change (A updates B updates A...), the system will continue processing these updates without stack overflows. However, this could potentially affect performance if updates never stabilize. See the [TECHNICAL_DETAILS.md][4] document for best practices on handling cyclical dependencies.
169
+
170
+ </details>
171
+
172
+ <details>
173
+
174
+ <summary>How performant is Beacon?</summary>
175
+ Beacon is designed with performance in mind for server-side Node.js environments. It achieves millions of operations per second for core operations like reading and writing signals.
176
+
177
+ </details>
178
+
179
+ ## License
180
+
181
+ This project is licensed under the MIT License. See the [LICENSE][3] file for details.
182
+
183
+ <!-- Links collection -->
184
+
185
+ [1]: https://github.com/tc39/proposal-signals
186
+ [2]: ./TECHNICAL_DETAILS.md
187
+ [3]: ./LICENSE
@@ -0,0 +1,23 @@
1
+ type Unsubscribe = () => void;
2
+ export interface Signal<T> {
3
+ (): T;
4
+ set(value: T): void;
5
+ update(fn: (currentValue: T) => T): void;
6
+ }
7
+ /**
8
+ * Creates a new reactive state with the provided initial value
9
+ */
10
+ export declare const state: <T>(initialValue: T) => Signal<T>;
11
+ /**
12
+ * Creates an effect that runs when its dependencies change
13
+ */
14
+ export declare const effect: (fn: () => void) => Unsubscribe;
15
+ /**
16
+ * Creates a derived signal that computes its value from other signals
17
+ */
18
+ export declare const derived: <T>(fn: () => T) => Signal<T>;
19
+ /**
20
+ * Batches multiple updates to run effects only once at the end
21
+ */
22
+ export declare const batch: <T>(fn: () => T) => T;
23
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,129 @@
1
+ // Global state for tracking
2
+ let currentEffect = null;
3
+ let batchDepth = 0;
4
+ const pendingEffects = new Set();
5
+ const subscriberDependencies = new WeakMap();
6
+ // Use a flag to prevent multiple updates from running the effects
7
+ let updateInProgress = false;
8
+ /**
9
+ * Creates a new reactive state with the provided initial value
10
+ */
11
+ export const state = (initialValue) => {
12
+ let value = initialValue;
13
+ const subscribers = new Set();
14
+ const read = () => {
15
+ if (currentEffect) {
16
+ subscribers.add(currentEffect);
17
+ let dependencies = subscriberDependencies.get(currentEffect);
18
+ if (!dependencies) {
19
+ dependencies = new Set();
20
+ subscriberDependencies.set(currentEffect, dependencies);
21
+ }
22
+ dependencies.add(subscribers);
23
+ }
24
+ return value;
25
+ };
26
+ const write = (newValue) => {
27
+ if (Object.is(value, newValue)) {
28
+ return; // No change
29
+ }
30
+ value = newValue;
31
+ if (subscribers.size === 0) {
32
+ return;
33
+ }
34
+ // Add subscribers to pendingEffects - always use loop for better performance
35
+ for (const sub of subscribers) {
36
+ pendingEffects.add(sub);
37
+ }
38
+ if (batchDepth === 0 && !updateInProgress) {
39
+ processEffects();
40
+ }
41
+ };
42
+ const update = (fn) => {
43
+ write(fn(value));
44
+ };
45
+ return Object.assign(read, { set: write, update });
46
+ };
47
+ /**
48
+ * Process all pending effects, ensuring full propagation through the dependency chain
49
+ */
50
+ const processEffects = () => {
51
+ if (pendingEffects.size === 0 || updateInProgress) {
52
+ return;
53
+ }
54
+ updateInProgress = true;
55
+ while (pendingEffects.size > 0) {
56
+ const currentEffects = [...pendingEffects];
57
+ pendingEffects.clear();
58
+ for (const effect of currentEffects) {
59
+ effect();
60
+ }
61
+ }
62
+ updateInProgress = false;
63
+ };
64
+ /**
65
+ * Helper to clean up effect subscriptions
66
+ */
67
+ const cleanupEffect = (effect) => {
68
+ const deps = subscriberDependencies.get(effect);
69
+ if (deps) {
70
+ for (const subscribers of deps) {
71
+ subscribers.delete(effect);
72
+ }
73
+ deps.clear();
74
+ }
75
+ };
76
+ /**
77
+ * Creates an effect that runs when its dependencies change
78
+ */
79
+ export const effect = (fn) => {
80
+ const runEffect = () => {
81
+ cleanupEffect(runEffect);
82
+ const prevEffect = currentEffect;
83
+ currentEffect = runEffect;
84
+ try {
85
+ fn();
86
+ }
87
+ finally {
88
+ currentEffect = prevEffect;
89
+ }
90
+ };
91
+ runEffect();
92
+ return () => {
93
+ cleanupEffect(runEffect);
94
+ };
95
+ };
96
+ /**
97
+ * Creates a derived signal that computes its value from other signals
98
+ */
99
+ export const derived = (fn) => {
100
+ // Initialize signal with the computed value
101
+ const signal = state(fn());
102
+ // Only run fn() again when dependencies change
103
+ effect(() => {
104
+ signal.set(fn());
105
+ });
106
+ return signal;
107
+ };
108
+ /**
109
+ * Batches multiple updates to run effects only once at the end
110
+ */
111
+ export const batch = (fn) => {
112
+ batchDepth++;
113
+ try {
114
+ return fn();
115
+ }
116
+ catch (error) {
117
+ if (batchDepth === 1) {
118
+ pendingEffects.clear();
119
+ }
120
+ throw error;
121
+ }
122
+ finally {
123
+ batchDepth--;
124
+ if (batchDepth === 0 && pendingEffects.size > 0) {
125
+ processEffects();
126
+ }
127
+ }
128
+ };
129
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@nerdalytics/beacon",
3
+ "version": "1.0.0",
4
+ "description": "A lightweight reactive signal library for Node.js backends. Enables reactive state management with automatic dependency tracking and efficient updates for server-side applications.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist/",
10
+ "src/",
11
+ "LICENSE"
12
+ ],
13
+ "scripts": {
14
+ "test": "node --test --experimental-config-file=node.config.json \"test/**/*.test.ts\"",
15
+ "test:coverage": "node --test --experimental-config-file=node.config.json --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info \"test/**/*.test.ts\"",
16
+ "test:unit": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^(State|Derived|Effect|Batch|Cleanup|Cyclic Dependencies)$/\"",
17
+ "test:unit:state": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^State$/\"",
18
+ "test:unit:derived": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^Derived$/\"",
19
+ "test:unit:effect": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^Effect$/\"",
20
+ "test:unit:batch": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^Batch$/\"",
21
+ "test:unit:cleanup": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^Cleanup$/\"",
22
+ "test:unit:cyclic": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^Cyclic Dependencies$/\"",
23
+ "test:integration": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^Deep Dependency Chains$/\"",
24
+ "test:perf": "node --test --experimental-config-file=node.config.json --test-name-pattern=\"/^Performance$/\"",
25
+ "test:perf:update-docs": "node scripts/update-performance-docs.ts",
26
+ "format": "biome format --write .",
27
+ "prebuild": "rm -rf dist/",
28
+ "build": "npm run build:lts",
29
+ "build:lts": "tsc -p tsconfig.build.json",
30
+ "prepublishOnly": "npm run build"
31
+ },
32
+ "keywords": [
33
+ "reactive",
34
+ "signals",
35
+ "state-management",
36
+ "nodejs",
37
+ "typescript",
38
+ "reactive-programming",
39
+ "dependency-tracking",
40
+ "esm",
41
+ "observable",
42
+ "backend",
43
+ "server-side",
44
+ "computed-values",
45
+ "effects",
46
+ "batching"
47
+ ],
48
+ "author": "Denny Trebbin",
49
+ "license": "MIT",
50
+ "devDependencies": {
51
+ "@biomejs/biome": "1.9.4",
52
+ "@types/node": "22.13.16",
53
+ "typescript": "5.8.2"
54
+ },
55
+ "engines": {
56
+ "node": ">=20.0.0"
57
+ }
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,175 @@
1
+ // Types
2
+ type Subscriber = () => void
3
+
4
+ type Unsubscribe = () => void
5
+
6
+ export interface Signal<T> {
7
+ (): T // get value
8
+ set(value: T): void // set value directly
9
+ update(fn: (currentValue: T) => T): void // update value with a function
10
+ }
11
+
12
+ // Global state for tracking
13
+ let currentEffect: Subscriber | null = null
14
+
15
+ let batchDepth = 0
16
+
17
+ const pendingEffects = new Set<Subscriber>()
18
+
19
+ const subscriberDependencies = new WeakMap<Subscriber, Set<Set<Subscriber>>>()
20
+
21
+ // Use a flag to prevent multiple updates from running the effects
22
+ let updateInProgress = false
23
+
24
+ /**
25
+ * Creates a new reactive state with the provided initial value
26
+ */
27
+ export const state = <T>(initialValue: T): Signal<T> => {
28
+ let value = initialValue
29
+
30
+ const subscribers = new Set<Subscriber>()
31
+
32
+ const read = (): T => {
33
+ if (currentEffect) {
34
+ subscribers.add(currentEffect)
35
+
36
+ let dependencies = subscriberDependencies.get(currentEffect)
37
+
38
+ if (!dependencies) {
39
+ dependencies = new Set()
40
+
41
+ subscriberDependencies.set(currentEffect, dependencies)
42
+ }
43
+
44
+ dependencies.add(subscribers)
45
+ }
46
+
47
+ return value
48
+ }
49
+
50
+ const write = (newValue: T): void => {
51
+ if (Object.is(value, newValue)) {
52
+ return // No change
53
+ }
54
+ value = newValue
55
+
56
+ if (subscribers.size === 0) {
57
+ return
58
+ }
59
+
60
+ // Add subscribers to pendingEffects - always use loop for better performance
61
+ for (const sub of subscribers) {
62
+ pendingEffects.add(sub)
63
+ }
64
+
65
+ if (batchDepth === 0 && !updateInProgress) {
66
+ processEffects()
67
+ }
68
+ }
69
+
70
+ const update = (fn: (currentValue: T) => T): void => {
71
+ write(fn(value))
72
+ }
73
+
74
+ return Object.assign(read, { set: write, update })
75
+ }
76
+
77
+ /**
78
+ * Process all pending effects, ensuring full propagation through the dependency chain
79
+ */
80
+ const processEffects = (): void => {
81
+ if (pendingEffects.size === 0 || updateInProgress) {
82
+ return
83
+ }
84
+
85
+ updateInProgress = true
86
+
87
+ while (pendingEffects.size > 0) {
88
+ const currentEffects = [...pendingEffects]
89
+ pendingEffects.clear()
90
+
91
+ for (const effect of currentEffects) {
92
+ effect()
93
+ }
94
+ }
95
+
96
+ updateInProgress = false
97
+ }
98
+
99
+ /**
100
+ * Helper to clean up effect subscriptions
101
+ */
102
+ const cleanupEffect = (effect: Subscriber): void => {
103
+ const deps = subscriberDependencies.get(effect)
104
+
105
+ if (deps) {
106
+ for (const subscribers of deps) {
107
+ subscribers.delete(effect)
108
+ }
109
+
110
+ deps.clear()
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Creates an effect that runs when its dependencies change
116
+ */
117
+ export const effect = (fn: () => void): Unsubscribe => {
118
+ const runEffect = (): void => {
119
+ cleanupEffect(runEffect)
120
+
121
+ const prevEffect = currentEffect
122
+
123
+ currentEffect = runEffect
124
+
125
+ try {
126
+ fn()
127
+ } finally {
128
+ currentEffect = prevEffect
129
+ }
130
+ }
131
+
132
+ runEffect()
133
+
134
+ return (): void => {
135
+ cleanupEffect(runEffect)
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Creates a derived signal that computes its value from other signals
141
+ */
142
+ export const derived = <T>(fn: () => T): Signal<T> => {
143
+ // Initialize signal with the computed value
144
+ const signal = state<T>(fn())
145
+
146
+ // Only run fn() again when dependencies change
147
+ effect((): void => {
148
+ signal.set(fn())
149
+ })
150
+
151
+ return signal
152
+ }
153
+
154
+ /**
155
+ * Batches multiple updates to run effects only once at the end
156
+ */
157
+ export const batch = <T>(fn: () => T): T => {
158
+ batchDepth++
159
+
160
+ try {
161
+ return fn()
162
+ } catch (error) {
163
+ if (batchDepth === 1) {
164
+ pendingEffects.clear()
165
+ }
166
+
167
+ throw error
168
+ } finally {
169
+ batchDepth--
170
+
171
+ if (batchDepth === 0 && pendingEffects.size > 0) {
172
+ processEffects()
173
+ }
174
+ }
175
+ }