@signal-kernel/core 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 LucianoLee
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,331 @@
1
+ # `@signal-kernel/core`
2
+ ### A minimal, deterministic, fine-grained reactivity engine.
3
+
4
+ `@signal-kernel/core` provides the foundational primitives for building reactive systems:
5
+ signals, computed values, effects, a deterministic scheduler, and a dependency graph.
6
+
7
+ This package is framework-agnostic and has no DOM or framework dependencies, making it suitable for:
8
+ - UI frameworks (via adapters)
9
+ - Server-side reactive pipelines
10
+ - Async dataflow runtimes
11
+ - State machines & reactive graphs
12
+ - Compiler-driven reactivity transforms
13
+
14
+ It is the core runtime powering the broader Signal Kernel ecosystem.
15
+
16
+ ---
17
+
18
+ # Installation
19
+ ```bash
20
+ npm install @signal-kernel/core
21
+ ```
22
+
23
+ ---
24
+
25
+ # Overview
26
+ `@signal-kernel/core` implements a modern fine-grained reactive graph, consisting of:
27
+
28
+ 1. **`signal()`**
29
+ Mutable state unit, with dependency tracking and change propagation.
30
+ 2. **`computed()`**
31
+ Lazy, memoized derivations with automatic dependency tracking.
32
+ 3. **`createEffect()`**
33
+ Reactive side-effects with cleanup, disposal, and deterministic scheduling.
34
+ 4. **`batch()`**
35
+ Coalesces multiple updates into a single scheduler flush.
36
+ 5. **Deterministic Scheduler**
37
+ A two-phase flush model that first stabilizes computed nodes, then executes effects.
38
+
39
+ All primitives are minimal yet expressive enough to build a complete reactivity system or compose higher-level runtimes (e.g., async pipelines).
40
+
41
+ ---
42
+
43
+ # Public API
44
+ ```ts
45
+ export { signal } from "./signal.js";
46
+ export { computed } from "./computed.js";
47
+ export { createEffect, onCleanup } from "./effect.js";
48
+ export { batch } from "./scheduler.js";
49
+ ```
50
+
51
+ *(Internal APIs such as `atomic()`, `transaction()`, and `flushSync()` are intentionally not exported at this stage.)*
52
+
53
+ ---
54
+ ---------------------------------------------------------
55
+ # 1. `signal()`
56
+ ---------------------------------------------------------
57
+
58
+ ### Signature
59
+ ```ts
60
+ const { get, set, subscribe, peek } = signal<T>(initial, equals?);
61
+ ```
62
+
63
+ ### Behavior
64
+ `get()`
65
+ - Returns the current value.
66
+ - If called within an active observer, registers a dependency via track().
67
+
68
+ `set(next)`
69
+ - Updates the stored value.
70
+ - Uses a customizable equality comparator to prevent unnecessary propagation.
71
+ - Notifies downstream:
72
+ - computed nodes → marked stale
73
+ - effects → scheduled for execution
74
+
75
+ `peek()`
76
+ - Reads value without tracking dependencies.
77
+
78
+ `subscribe(node)`
79
+ Manually links an observer to the signal.
80
+ Useful for framework adapters (React/Vue/Solid) that manage their own lifecycle.
81
+
82
+ **Key properties**
83
+ - Signals **cannot depend** on others (enforced by runtime errors).
84
+ - They are the leaf nodes of the reactive graph.
85
+ - Changes propagate deterministically through the scheduler.
86
+
87
+ ---
88
+
89
+ ---------------------------------------------------------
90
+ # 2. `computed()`
91
+ ---------------------------------------------------------
92
+ ### Signature
93
+ ```ts
94
+ const { get, peek, dispose } = computed<T>(fn, equals?);
95
+ ```
96
+
97
+ ### Behavior
98
+ ##### Lazy evaluation
99
+ Computed values only evaluate when `get()` is called.
100
+ If the node is stale—or has never computed before—it runs its `fn()` and updates dependencies.
101
+
102
+ ##### Automatic dependency tracking
103
+ During execution, all accessed signals/computeds are recorded via `track()`.
104
+
105
+ ##### Memoization & equality
106
+ A computed node only updates its value when:
107
+ ```rs
108
+ !equals(prev, next)
109
+ ```
110
+
111
+ ##### Stale propagation
112
+ When an upstream signal or computed changes:
113
+ ```ts
114
+ computed.stale = true;
115
+ ```
116
+
117
+ Downstream effects are scheduled, but computation still occurs lazily.
118
+
119
+ ##### Cycle detection
120
+ If a computed re-enters itself:
121
+ ```ts
122
+ throw new Error("Cycle detected in computed");
123
+ ```
124
+
125
+ `dispose()`
126
+ Fully removes the node from the graph:
127
+ - Unlinks all deps and subs
128
+ - Clears cached value
129
+ - Leaves the graph consistent
130
+
131
+ ---
132
+
133
+ ---------------------------------------------------------
134
+ # 3. `createEffect()`
135
+ ---------------------------------------------------------
136
+ Effects are the imperative “side-effect” layer of the reactive system.
137
+
138
+ ### Signature
139
+ ```ts
140
+ const dispose = createEffect(() => {
141
+ // ...
142
+ return () => cleanup();
143
+ });
144
+ ```
145
+
146
+ ### Behavior
147
+
148
+ ##### Automatic dependency tracking
149
+ During effect execution, reads from signals or computed values create graph edges via `track()`.
150
+
151
+ ##### Cleanup handling
152
+ Before each re-run:
153
+ 1. All previous cleanups run
154
+ 2. All previous dependencies are removed
155
+ 3. The effect function re-executes and re-establishes its dependencies
156
+
157
+ ##### Disposal
158
+ Calling `dispose()`:
159
+ - Runs remaining cleanups
160
+ - Deletes the effect from the registry
161
+ - Removes all graph links
162
+ - Prevents future scheduling
163
+
164
+ ##### Integration with scheduler
165
+ Effects are not run synchronously—they are **scheduled**.
166
+ This ensures deterministic ordering and batching behavior.
167
+
168
+ ---
169
+
170
+ ---------------------------------------------------------
171
+ # 4. Scheduler
172
+ ---------------------------------------------------------
173
+
174
+ Your runtime's scheduler is a defining feature.
175
+ It implements a two-phase deterministic flush, ensuring stable, predictable updates.
176
+
177
+ ### Two-phase flush model
178
+ ```mermaid
179
+ flowchart TD
180
+ A["Began flushJobsTwoPhase()"] --> B{computeQ and effectQ are empty?}
181
+ B -- Yes --> Z[return]
182
+ B -- No --> C[scheduled = false]
183
+
184
+ C --> D{computeQ is empty?}
185
+ D -- No --> E[deal with one computeQ]
186
+ E --> F["iterate job.run()<br/>Maybe enqueue a new computed/effect"]
187
+ F --> D
188
+ D -- Yes --> G[Deal with Phase B]
189
+
190
+ G --> H{effectQ is empty?}
191
+ H -- No --> I["Follow priority sort effectQ"]
192
+ I --> J["iterate effect.run()<br/>Maybe enqueue a new computed/effect"]
193
+ J --> K{"Have a new task?(computeQ or effectQ)"}
194
+ K -- Yes --> C
195
+ K -- No --> Z
196
+
197
+ H -- Yes --> Z
198
+
199
+ ```
200
+
201
+ ### Properties
202
+ - Computed nodes always stabilize before effects run.
203
+ - Effects run in priority order.
204
+ - No interleaving of computed and effect execution.
205
+ - Infinite loops guarded by a safety counter.
206
+ - Batching defers flush until the outermost batch completes.
207
+
208
+ ##### `batch()`
209
+ ```ts
210
+ batch(() => {
211
+ setA(1);
212
+ setB(2);
213
+ });
214
+ ```
215
+ Updates are collapsed; effects execute once.
216
+
217
+ ##### Internal APIs (not exported)
218
+ - `atomic()`
219
+ - `transaction()`
220
+ - `flushSync()`
221
+
222
+ These provide:
223
+ - nested transaction support
224
+ - rollback semantics
225
+ - temporary scheduler muting
226
+ - consistent reconciling of reactive state
227
+
228
+ They are intentionally excluded from the public API until higher-level packages require them.
229
+
230
+ ---
231
+
232
+ ---------------------------------------------------------
233
+ # 5. Dependency Graph
234
+ ---------------------------------------------------------
235
+ From `graph.ts`, each reactive node has:
236
+ ```ts
237
+ kind: "signal" | "computed" | "effect"
238
+ deps: Set<Node>
239
+ subs: Set<Node>
240
+ ```
241
+
242
+ `track(dep)`
243
+ Creates a directional edge:
244
+ observer → dep
245
+
246
+ `withObserver(observer, fn)`
247
+ Temporarily sets the active observer during execution.
248
+
249
+ `unlink(from, to)`
250
+ Removes a dependency edge; used by effects and computed nodes on re-run.
251
+
252
+ ---
253
+
254
+ ---------------------------------------------------------
255
+ # 6. Internal Architecture Diagram
256
+ ---------------------------------------------------------
257
+ ```mermaid
258
+ flowchart TD
259
+ subgraph Core["Signal Kernel Core (Reactive Graph)"]
260
+ S["signal()"]
261
+ C["computed()"]
262
+ E["createEffect()"]
263
+ end
264
+
265
+ subgraph Scheduler["Scheduler (two-phase flush)"]
266
+ PA["Phase A:<br/>recompute all stale computed"]
267
+ PB["Phase B:<br/>run effects (by priority)"]
268
+ end
269
+
270
+ %% Signal update path
271
+ S -- "set()" --> MS["markStale(computed)"]
272
+ MS --> C
273
+
274
+ %% Computed recomputation
275
+ C -- "get() / stale" --> RC["recompute()"]
276
+ RC --> C
277
+
278
+ %% Effects depend on signals / computed
279
+ S -- "track()" --> E
280
+ C -- "track()" --> E
281
+
282
+ %% Scheduling
283
+ E -- "scheduleJob()" --> PA
284
+ C -. "stale" .-> PA
285
+
286
+ %% Two-phase loop
287
+ PA --> C
288
+ PA --> PB
289
+ PB --> E
290
+
291
+ %% New work can re-enter the loop
292
+ PB -. "new stale computed / effects" .-> PA
293
+ ```
294
+
295
+ ---
296
+
297
+ ---------------------------------------------------------
298
+ # 7. Why This Runtime?
299
+ ---------------------------------------------------------
300
+ Compared to other fine-grained reactive engines, Signal Kernel Core offers:
301
+ ### Deterministic scheduling
302
+ Computed nodes always settle before effects.
303
+
304
+ ### Lazy derivations
305
+ Computed values are only evaluated when needed.
306
+
307
+ ### Cycle detection
308
+ Prevents accidental recursive dependencies.
309
+
310
+ ### Zero framework assumptions
311
+ No VDOM, no compiler, no DOM APIs.
312
+
313
+ ### Adapter-friendly design
314
+ `subscribe()`, `onCleanup()`, and effect disposal make framework integration clean.
315
+
316
+ ### Extensibility
317
+ The internal architecture supports advanced features such as async pipelines, transactions, and server-side reactive graphs.
318
+
319
+ ---
320
+
321
+ ---------------------------------------------------------
322
+ # 8. Summary
323
+ ---------------------------------------------------------
324
+ `@signal-kernel/core` provides a minimal yet robust foundation for building reactive systems.
325
+ Its combination of:
326
+ - signals
327
+ - lazy computed values
328
+ - deterministic effects
329
+ - a two-phase scheduler
330
+ - an explicit dependency graph
331
+
package/dist/index.cjs ADDED
@@ -0,0 +1,296 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ batch: () => batch,
24
+ computed: () => computed,
25
+ createEffect: () => createEffect,
26
+ onCleanup: () => onCleanup,
27
+ signal: () => signal
28
+ });
29
+ module.exports = __toCommonJS(index_exports);
30
+
31
+ // src/graph.ts
32
+ function link(from, to) {
33
+ if (from.kind === "signal") throw new Error("Signal nodes cannot depend on others");
34
+ if (from.deps.has(to)) return;
35
+ from.deps.add(to);
36
+ to.subs.add(from);
37
+ }
38
+ function unlink(from, to) {
39
+ from.deps.delete(to);
40
+ to.subs.delete(from);
41
+ }
42
+ var currentObserver = null;
43
+ function withObserver(obs, fn) {
44
+ const prev = currentObserver;
45
+ currentObserver = obs;
46
+ try {
47
+ return fn();
48
+ } finally {
49
+ currentObserver = prev;
50
+ }
51
+ }
52
+ function track(dep) {
53
+ if (!currentObserver) return;
54
+ link(currentObserver, dep);
55
+ }
56
+
57
+ // src/registry.ts
58
+ var EffectSlot = /* @__PURE__ */ Symbol("EffectSlot");
59
+ var SymbolRegistry = {
60
+ get(node) {
61
+ return node[EffectSlot];
62
+ },
63
+ set(node, inst) {
64
+ Object.defineProperty(node, EffectSlot, {
65
+ value: inst,
66
+ enumerable: false,
67
+ configurable: true
68
+ });
69
+ },
70
+ delete(node) {
71
+ Reflect.deleteProperty(node, EffectSlot);
72
+ }
73
+ };
74
+
75
+ // src/computed.ts
76
+ var defaultEquals = Object.is;
77
+ function markStale(node) {
78
+ if (node.kind !== "computed") return;
79
+ const c = node;
80
+ if (c.stale) return;
81
+ c.stale = true;
82
+ for (const sub of node.subs) {
83
+ if (sub.kind === "computed") {
84
+ markStale(sub);
85
+ } else if (sub.kind === "effect") {
86
+ SymbolRegistry.get(sub)?.schedule();
87
+ }
88
+ }
89
+ }
90
+ function computed(fn, equals = defaultEquals) {
91
+ const node = {
92
+ kind: "computed",
93
+ deps: /* @__PURE__ */ new Set(),
94
+ subs: /* @__PURE__ */ new Set(),
95
+ value: void 0,
96
+ stale: true,
97
+ equals,
98
+ computing: false,
99
+ hasValue: false
100
+ };
101
+ function recompute() {
102
+ if (node.computing) throw new Error("Cycle detected in computed");
103
+ node.computing = true;
104
+ for (const d of [...node.deps]) unlink(node, d);
105
+ const next = withObserver(node, fn);
106
+ if (!node.hasValue || !node.equals(node.value, next)) {
107
+ node.value = next;
108
+ node.hasValue = true;
109
+ }
110
+ node.stale = false;
111
+ node.computing = false;
112
+ }
113
+ const get = () => {
114
+ track(node);
115
+ if (node.stale || !node.hasValue) recompute();
116
+ return node.value;
117
+ };
118
+ const peek = () => node.value;
119
+ const dispose = () => {
120
+ for (const d of [...node.deps]) unlink(node, d);
121
+ for (const s of [...node.subs]) unlink(s, node);
122
+ node.deps.clear();
123
+ node.subs.clear();
124
+ node.stale = true;
125
+ node.hasValue = false;
126
+ };
127
+ return { get, peek, dispose };
128
+ }
129
+
130
+ // src/scheduler.ts
131
+ var computeQ = /* @__PURE__ */ new Set();
132
+ var effectQ = /* @__PURE__ */ new Set();
133
+ var scheduled = false;
134
+ var batchDepth = 0;
135
+ var atomicDepth = 0;
136
+ var atomicLogs = [];
137
+ var muted = 0;
138
+ function scheduleJob(job) {
139
+ const j = job;
140
+ if (j.disposed) return;
141
+ if (muted > 0) return;
142
+ const kind = j.kind ?? "effect";
143
+ if (kind === "computed") computeQ.add(j);
144
+ else effectQ.add(j);
145
+ if (!scheduled && batchDepth === 0) {
146
+ scheduled = true;
147
+ queueMicrotask(flushJobsTwoPhase);
148
+ }
149
+ }
150
+ function batch(fn) {
151
+ batchDepth++;
152
+ try {
153
+ return fn();
154
+ } finally {
155
+ batchDepth--;
156
+ if (batchDepth === 0) flushJobsTwoPhase();
157
+ }
158
+ }
159
+ function inAtomic() {
160
+ return atomicDepth > 0;
161
+ }
162
+ function recordAtomicWrite(node, prevValue) {
163
+ const log = atomicLogs[atomicLogs.length - 1];
164
+ if (!log) return;
165
+ if (!log.has(node)) log.set(node, prevValue);
166
+ }
167
+ function flushJobsTwoPhase() {
168
+ scheduled = false;
169
+ let guard = 0;
170
+ while (computeQ.size > 0 || effectQ.size > 0) {
171
+ if (++guard > 1e4) throw new Error("Infinite update loop");
172
+ while (computeQ.size > 0) {
173
+ const batch2 = Array.from(computeQ);
174
+ computeQ.clear();
175
+ for (const job of batch2) {
176
+ job.kind = "computed";
177
+ job.run();
178
+ }
179
+ }
180
+ if (effectQ.size > 0) {
181
+ const batch2 = Array.from(effectQ).sort(
182
+ (a, b) => (a.priority ?? 0) - (b.priority ?? 0)
183
+ );
184
+ effectQ.clear();
185
+ for (const job of batch2) {
186
+ job.kind = "effect";
187
+ job.run();
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ // src/signal.ts
194
+ var defaultEquals2 = Object.is;
195
+ function signal(initial, equals = defaultEquals2) {
196
+ const node = {
197
+ kind: "signal",
198
+ deps: /* @__PURE__ */ new Set(),
199
+ subs: /* @__PURE__ */ new Set(),
200
+ value: initial,
201
+ equals
202
+ };
203
+ const get = () => {
204
+ track(node);
205
+ return node.value;
206
+ };
207
+ const set = (next) => {
208
+ const prev = node.value;
209
+ const nxtVal = typeof next === "function" ? next(node.value) : next;
210
+ if (node.equals(node.value, nxtVal)) return;
211
+ if (inAtomic()) recordAtomicWrite(node, prev);
212
+ node.value = nxtVal;
213
+ if (node.subs.size === 0) return;
214
+ for (const sub of node.subs) {
215
+ if (sub.kind === "effect") {
216
+ SymbolRegistry.get(sub)?.schedule();
217
+ } else if (sub.kind === "computed") {
218
+ markStale(sub);
219
+ }
220
+ }
221
+ };
222
+ const subscribe = (observer) => {
223
+ if (observer.kind === "signal") {
224
+ throw new Error("A signal cannot subscribe to another node");
225
+ }
226
+ link(observer, node);
227
+ return () => unlink(observer, node);
228
+ };
229
+ return { get, set, subscribe, peek: () => node.value };
230
+ }
231
+
232
+ // src/effect.ts
233
+ function drainCleanups(list, onError) {
234
+ for (let i = list.length - 1; i >= 0; i--) {
235
+ const cb = list[i];
236
+ try {
237
+ cb?.();
238
+ } catch (e) {
239
+ onError?.(e);
240
+ }
241
+ }
242
+ list.length = 0;
243
+ }
244
+ var activeEffect = null;
245
+ function onCleanup(cb) {
246
+ if (activeEffect) activeEffect.cleanups.push(cb);
247
+ }
248
+ var EffectInstance = class {
249
+ constructor(fn) {
250
+ this.fn = fn;
251
+ this.node = {
252
+ kind: "effect",
253
+ deps: /* @__PURE__ */ new Set(),
254
+ subs: /* @__PURE__ */ new Set()
255
+ };
256
+ this.cleanups = [];
257
+ this.disposed = false;
258
+ SymbolRegistry.set(this.node, this);
259
+ }
260
+ run() {
261
+ if (this.disposed) return;
262
+ drainCleanups(this.cleanups);
263
+ for (const dep of [...this.node.deps]) unlink(this.node, dep);
264
+ activeEffect = this;
265
+ try {
266
+ const ret = withObserver(this.node, this.fn);
267
+ if (typeof ret === "function") this.cleanups.push(ret);
268
+ } finally {
269
+ activeEffect = null;
270
+ }
271
+ }
272
+ schedule() {
273
+ scheduleJob(this);
274
+ }
275
+ dispose() {
276
+ if (this.disposed) return;
277
+ this.disposed = true;
278
+ drainCleanups(this.cleanups);
279
+ for (const dep of [...this.node.deps]) unlink(this.node, dep);
280
+ this.node.deps.clear();
281
+ SymbolRegistry.delete(this.node);
282
+ }
283
+ };
284
+ function createEffect(fn) {
285
+ const inst = new EffectInstance(fn);
286
+ inst.run();
287
+ return () => inst.dispose();
288
+ }
289
+ // Annotate the CommonJS export names for ESM import in node:
290
+ 0 && (module.exports = {
291
+ batch,
292
+ computed,
293
+ createEffect,
294
+ onCleanup,
295
+ signal
296
+ });
@@ -0,0 +1,29 @@
1
+ type Kind = 'signal' | 'computed' | 'effect';
2
+ interface Node {
3
+ kind: Kind;
4
+ deps: Set<Node>;
5
+ subs: Set<Node>;
6
+ }
7
+
8
+ type Comparator$1<T> = (a: T, b: T) => boolean;
9
+ declare function signal<T>(initial: T, equals?: Comparator$1<T>): {
10
+ get: () => T;
11
+ set: (next: T | ((prev: T) => T)) => void;
12
+ subscribe: (observer: Node) => () => void;
13
+ peek: () => T;
14
+ };
15
+
16
+ type Comparator<T> = (a: T, b: T) => boolean;
17
+ declare function computed<T>(fn: () => T, equals?: Comparator<T>): {
18
+ get: () => T;
19
+ peek: () => T;
20
+ dispose: () => void;
21
+ };
22
+
23
+ type Cleanup = () => void;
24
+ declare function onCleanup(cb: Cleanup): void;
25
+ declare function createEffect(fn: () => void | Cleanup): () => void;
26
+
27
+ declare function batch<T>(fn: () => T): T;
28
+
29
+ export { batch, computed, createEffect, onCleanup, signal };
@@ -0,0 +1,29 @@
1
+ type Kind = 'signal' | 'computed' | 'effect';
2
+ interface Node {
3
+ kind: Kind;
4
+ deps: Set<Node>;
5
+ subs: Set<Node>;
6
+ }
7
+
8
+ type Comparator$1<T> = (a: T, b: T) => boolean;
9
+ declare function signal<T>(initial: T, equals?: Comparator$1<T>): {
10
+ get: () => T;
11
+ set: (next: T | ((prev: T) => T)) => void;
12
+ subscribe: (observer: Node) => () => void;
13
+ peek: () => T;
14
+ };
15
+
16
+ type Comparator<T> = (a: T, b: T) => boolean;
17
+ declare function computed<T>(fn: () => T, equals?: Comparator<T>): {
18
+ get: () => T;
19
+ peek: () => T;
20
+ dispose: () => void;
21
+ };
22
+
23
+ type Cleanup = () => void;
24
+ declare function onCleanup(cb: Cleanup): void;
25
+ declare function createEffect(fn: () => void | Cleanup): () => void;
26
+
27
+ declare function batch<T>(fn: () => T): T;
28
+
29
+ export { batch, computed, createEffect, onCleanup, signal };
package/dist/index.js ADDED
@@ -0,0 +1,265 @@
1
+ // src/graph.ts
2
+ function link(from, to) {
3
+ if (from.kind === "signal") throw new Error("Signal nodes cannot depend on others");
4
+ if (from.deps.has(to)) return;
5
+ from.deps.add(to);
6
+ to.subs.add(from);
7
+ }
8
+ function unlink(from, to) {
9
+ from.deps.delete(to);
10
+ to.subs.delete(from);
11
+ }
12
+ var currentObserver = null;
13
+ function withObserver(obs, fn) {
14
+ const prev = currentObserver;
15
+ currentObserver = obs;
16
+ try {
17
+ return fn();
18
+ } finally {
19
+ currentObserver = prev;
20
+ }
21
+ }
22
+ function track(dep) {
23
+ if (!currentObserver) return;
24
+ link(currentObserver, dep);
25
+ }
26
+
27
+ // src/registry.ts
28
+ var EffectSlot = /* @__PURE__ */ Symbol("EffectSlot");
29
+ var SymbolRegistry = {
30
+ get(node) {
31
+ return node[EffectSlot];
32
+ },
33
+ set(node, inst) {
34
+ Object.defineProperty(node, EffectSlot, {
35
+ value: inst,
36
+ enumerable: false,
37
+ configurable: true
38
+ });
39
+ },
40
+ delete(node) {
41
+ Reflect.deleteProperty(node, EffectSlot);
42
+ }
43
+ };
44
+
45
+ // src/computed.ts
46
+ var defaultEquals = Object.is;
47
+ function markStale(node) {
48
+ if (node.kind !== "computed") return;
49
+ const c = node;
50
+ if (c.stale) return;
51
+ c.stale = true;
52
+ for (const sub of node.subs) {
53
+ if (sub.kind === "computed") {
54
+ markStale(sub);
55
+ } else if (sub.kind === "effect") {
56
+ SymbolRegistry.get(sub)?.schedule();
57
+ }
58
+ }
59
+ }
60
+ function computed(fn, equals = defaultEquals) {
61
+ const node = {
62
+ kind: "computed",
63
+ deps: /* @__PURE__ */ new Set(),
64
+ subs: /* @__PURE__ */ new Set(),
65
+ value: void 0,
66
+ stale: true,
67
+ equals,
68
+ computing: false,
69
+ hasValue: false
70
+ };
71
+ function recompute() {
72
+ if (node.computing) throw new Error("Cycle detected in computed");
73
+ node.computing = true;
74
+ for (const d of [...node.deps]) unlink(node, d);
75
+ const next = withObserver(node, fn);
76
+ if (!node.hasValue || !node.equals(node.value, next)) {
77
+ node.value = next;
78
+ node.hasValue = true;
79
+ }
80
+ node.stale = false;
81
+ node.computing = false;
82
+ }
83
+ const get = () => {
84
+ track(node);
85
+ if (node.stale || !node.hasValue) recompute();
86
+ return node.value;
87
+ };
88
+ const peek = () => node.value;
89
+ const dispose = () => {
90
+ for (const d of [...node.deps]) unlink(node, d);
91
+ for (const s of [...node.subs]) unlink(s, node);
92
+ node.deps.clear();
93
+ node.subs.clear();
94
+ node.stale = true;
95
+ node.hasValue = false;
96
+ };
97
+ return { get, peek, dispose };
98
+ }
99
+
100
+ // src/scheduler.ts
101
+ var computeQ = /* @__PURE__ */ new Set();
102
+ var effectQ = /* @__PURE__ */ new Set();
103
+ var scheduled = false;
104
+ var batchDepth = 0;
105
+ var atomicDepth = 0;
106
+ var atomicLogs = [];
107
+ var muted = 0;
108
+ function scheduleJob(job) {
109
+ const j = job;
110
+ if (j.disposed) return;
111
+ if (muted > 0) return;
112
+ const kind = j.kind ?? "effect";
113
+ if (kind === "computed") computeQ.add(j);
114
+ else effectQ.add(j);
115
+ if (!scheduled && batchDepth === 0) {
116
+ scheduled = true;
117
+ queueMicrotask(flushJobsTwoPhase);
118
+ }
119
+ }
120
+ function batch(fn) {
121
+ batchDepth++;
122
+ try {
123
+ return fn();
124
+ } finally {
125
+ batchDepth--;
126
+ if (batchDepth === 0) flushJobsTwoPhase();
127
+ }
128
+ }
129
+ function inAtomic() {
130
+ return atomicDepth > 0;
131
+ }
132
+ function recordAtomicWrite(node, prevValue) {
133
+ const log = atomicLogs[atomicLogs.length - 1];
134
+ if (!log) return;
135
+ if (!log.has(node)) log.set(node, prevValue);
136
+ }
137
+ function flushJobsTwoPhase() {
138
+ scheduled = false;
139
+ let guard = 0;
140
+ while (computeQ.size > 0 || effectQ.size > 0) {
141
+ if (++guard > 1e4) throw new Error("Infinite update loop");
142
+ while (computeQ.size > 0) {
143
+ const batch2 = Array.from(computeQ);
144
+ computeQ.clear();
145
+ for (const job of batch2) {
146
+ job.kind = "computed";
147
+ job.run();
148
+ }
149
+ }
150
+ if (effectQ.size > 0) {
151
+ const batch2 = Array.from(effectQ).sort(
152
+ (a, b) => (a.priority ?? 0) - (b.priority ?? 0)
153
+ );
154
+ effectQ.clear();
155
+ for (const job of batch2) {
156
+ job.kind = "effect";
157
+ job.run();
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ // src/signal.ts
164
+ var defaultEquals2 = Object.is;
165
+ function signal(initial, equals = defaultEquals2) {
166
+ const node = {
167
+ kind: "signal",
168
+ deps: /* @__PURE__ */ new Set(),
169
+ subs: /* @__PURE__ */ new Set(),
170
+ value: initial,
171
+ equals
172
+ };
173
+ const get = () => {
174
+ track(node);
175
+ return node.value;
176
+ };
177
+ const set = (next) => {
178
+ const prev = node.value;
179
+ const nxtVal = typeof next === "function" ? next(node.value) : next;
180
+ if (node.equals(node.value, nxtVal)) return;
181
+ if (inAtomic()) recordAtomicWrite(node, prev);
182
+ node.value = nxtVal;
183
+ if (node.subs.size === 0) return;
184
+ for (const sub of node.subs) {
185
+ if (sub.kind === "effect") {
186
+ SymbolRegistry.get(sub)?.schedule();
187
+ } else if (sub.kind === "computed") {
188
+ markStale(sub);
189
+ }
190
+ }
191
+ };
192
+ const subscribe = (observer) => {
193
+ if (observer.kind === "signal") {
194
+ throw new Error("A signal cannot subscribe to another node");
195
+ }
196
+ link(observer, node);
197
+ return () => unlink(observer, node);
198
+ };
199
+ return { get, set, subscribe, peek: () => node.value };
200
+ }
201
+
202
+ // src/effect.ts
203
+ function drainCleanups(list, onError) {
204
+ for (let i = list.length - 1; i >= 0; i--) {
205
+ const cb = list[i];
206
+ try {
207
+ cb?.();
208
+ } catch (e) {
209
+ onError?.(e);
210
+ }
211
+ }
212
+ list.length = 0;
213
+ }
214
+ var activeEffect = null;
215
+ function onCleanup(cb) {
216
+ if (activeEffect) activeEffect.cleanups.push(cb);
217
+ }
218
+ var EffectInstance = class {
219
+ constructor(fn) {
220
+ this.fn = fn;
221
+ this.node = {
222
+ kind: "effect",
223
+ deps: /* @__PURE__ */ new Set(),
224
+ subs: /* @__PURE__ */ new Set()
225
+ };
226
+ this.cleanups = [];
227
+ this.disposed = false;
228
+ SymbolRegistry.set(this.node, this);
229
+ }
230
+ run() {
231
+ if (this.disposed) return;
232
+ drainCleanups(this.cleanups);
233
+ for (const dep of [...this.node.deps]) unlink(this.node, dep);
234
+ activeEffect = this;
235
+ try {
236
+ const ret = withObserver(this.node, this.fn);
237
+ if (typeof ret === "function") this.cleanups.push(ret);
238
+ } finally {
239
+ activeEffect = null;
240
+ }
241
+ }
242
+ schedule() {
243
+ scheduleJob(this);
244
+ }
245
+ dispose() {
246
+ if (this.disposed) return;
247
+ this.disposed = true;
248
+ drainCleanups(this.cleanups);
249
+ for (const dep of [...this.node.deps]) unlink(this.node, dep);
250
+ this.node.deps.clear();
251
+ SymbolRegistry.delete(this.node);
252
+ }
253
+ };
254
+ function createEffect(fn) {
255
+ const inst = new EffectInstance(fn);
256
+ inst.run();
257
+ return () => inst.dispose();
258
+ }
259
+ export {
260
+ batch,
261
+ computed,
262
+ createEffect,
263
+ onCleanup,
264
+ signal
265
+ };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@signal-kernel/core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^22.0.0",
24
+ "@typescript-eslint/eslint-plugin": "^8.48.1",
25
+ "@typescript-eslint/parser": "^8.48.1",
26
+ "eslint": "^9.39.1",
27
+ "tsup": "^8.0.0",
28
+ "typescript": "^5.6.0",
29
+ "vitest": "^2.0.0"
30
+ },
31
+ "scripts": {
32
+ "build": "tsup src/index.ts --format cjs,esm --dts --tsconfig tsconfig.dts.json",
33
+ "lint": "eslint src --ext .ts",
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "vitest run"
36
+ }
37
+ }