
Security News
Axios Supply Chain Attack Reaches OpenAI macOS Signing Pipeline, Forces Certificate Rotation
OpenAI rotated macOS signing certificates after a malicious Axios package reached its CI pipeline in a broader software supply chain attack.
@cripty2001/whispr
Advanced tools
A minimal, observable-based state engine with safe-by-default cloning, reactive computed values, and lifecycle-aware observables.
A tiny observable state manager for TypeScript.
Whispr helps you build reactive state using plain values—arrays, objects, numbers, anything. It’s lightweight, flexible, and designed for long-lived applications where memory safety matters.
Observables in Whispr automatically clean themselves up: derived values are passively tracked and disconnected when no longer used, avoiding leaks without the boilerplate of manual unsubscriptions.
No decorators, no UI frameworks, no globals — just a simple core that’s easy to reason about and safe to embed anywhere.
Whispr is small on purpose. It won’t manage your app. But it will whisper when your data changes, and stay out of the way when they doesn’t.
npm install @cripty2001/whispr
import { Whispr } from "@cripty2001/whispr";
// Create a Whispr counter
const [counter, setCounter] = Whispr.create(
0, // Initial Value
() => {
// (optional) onDie callback
console.log("Counter is dead 😢");
},
);
// Subscribe to changes
const unsubscribe = counter.subscribe((value) => {
console.log("Counter is now", value);
});
// Update the Whispr value
const ok = setCounter(5);
if (!ok) {
console.log("Counter is dead 😢");
}
// Access the latest value directly
console.log(counter.value);
// Unsubscribe from changes
unsubscribe();
// Create a derived Whispr
const doubled = Whispr.from({ value: counter }, ({ value }) => value * 2);
doubled.subscribe((val) => {
console.log("Doubled:", val);
});
ℹ️ Once an observable is "dead", it will not be revived. If
setCounter()returnsfalseoronDie()is triggered, please clean up or stop updating. Whispr handles gracefully updates on dead items, but it is still a waste of resources.
Whispr automatically tracks the lifecycle of each observable. When the returned data is no longer strongly referenced (i.e. it's orphaned), the optional onDie callback is triggered—giving you a clean opportunity to stop ongoing tasks like:
This cleanup logic is automatically propagated through Whispr.from chains as well—meaning derived observables clean up when all of their sources are gone. You don’t need to manually manage chains or subscriptions.
Just declare what needs to happen on cleanup, and let Whispr take care of the dirty work.
Whispr observables support asynchronous reactive subscriptions using .subscribe(callback, immediate = true).
At first glance, this might seem simple, but reactive flows have subtle tradeoffs. The way listeners are fired, how errors are handled, and when (or whether) updates are awaited all affect how predictable your app is, especially as it grows.
Here are some key properties and design choices behind Whispr’s listener model, and how they may affect your expectations:
When set() is called, all listeners of that Whispr are fired synchronously, during the same update tick.
const [counter, setCounter] = Whispr.create(0);
counter.subscribe((val) => {
console.log("Received value:", val);
});
setCounter((prev) => prev + 1);
// Listener is fired *immediately* here
This means the state is guaranteed to be consistent across all listeners and reads. Also, if the listener is syncronous, there are no race conditions or async propagation delays.
Whispr does not wait for listeners to complete. If a listener is async, it’s still invoked synchronously and then left to run in the background:
counter.subscribe(async (val) => {
await delay(1000);
console.log("This ran later:", val);
});
setCounter((prev) => prev + 1);
console.log("next"); // This logs immediately. The async listener finishes later.
This is intentional: the component or logic performing the .set() operation does not need to know or wait for all listeners to finish.
Listeners (sync or async) can return "STOP" to unsubscribe themselves automatically.
const unsub = counter.subscribe((val) => {
if (val > 3) return "STOP";
});
This avoids manual unsubscribe logic in many simple cases.
All listener callbacks are wrapped in try-catch. If a listener throws an error, Whispr catches it and logs it to the console. The subscription is kept active, though.
counter.subscribe((val) => {
throw new Error("Oops!");
});
This prevents one faulty listener from disrupting the others or crashing the observable logic.
By default, .subscribe() fires the callback immediately with the current value:
const unsub = counter.subscribe((val) => {
console.log("Initial value:", val); // immediately logs current value
});
This behavior can be turned off by passing false as the second argument:
counter.subscribe((val) => {
console.log("Only future updates");
}, false);
⚠️ Note: Even immediate listeners are fire-and-forget. If the callback is async, there's no guarantee it completes before the .subscribe() call returns.
.valueIf you just need the latest value, use .value:
const now = counter.value;
Do not subscribe unless you need to react to future changes. This avoids bugs where your async listener may not fire exactly when you think it will.
If your listener isn't working as expected, here's a quick list of things to check before you panic and rewrite your app at 2am:
Common mistake: forgetting to call
.subscribe()
// ❌ Nothing happens here
counter.subscribe;
// ✅ You need to call it!
counter.subscribe((val) => { ... });
By default,
subscribe()fires the listener immediately. You can disable that:
counter.subscribe((val) => { ... }, false); // skips first fire
async?Async listeners don’t block the update cycle, and Whispr won’t wait for them:
counter.subscribe(async (val) => {
await delay(500);
console.log("This runs later");
});
→ If you need the current value immediately, use .value.
If it crashed, you’ll see a warning in the console. The error is caught and the listener is kept alive. If you want to unsubsribe a listener after an error, just wrap it into a try catch block and return "STOP" from the catch
counter.subscribe((val) => {
throw new Error("oops");
});
// Logs error, doesn't stop other listeners
If your listener returns "STOP", it won’t be called again. That’s on purpose:
counter.subscribe((val) => {
if (val > 10) return "STOP";
});
Check your conditions.
.value has the data you expectcounter.subscribe((val) => console.log("DEBUG:", val));
It might seem tempting to support async update functions like this:
async function update(cb: (curr: T) => Promise<T>);
But here's the issue: what is the curr value in this case?
update() was called?In an async environment, update interleaving becomes inevitable. What seems like a harmless API leads to race conditions, overwrite bugs, and subtle inconsistencies that are nearly impossible to track in production.
Any solution here would be based on assumptions about developer intent—and assumptions don't scale.
Whispr intentionally does not offer an async update method.
Instead, it gives you:
.value accessor (please note that the value is NOT cloned, so it is NOT safe to directly mutate it. Manually clone it if required)update(cb) setterThis mirrors the simplicity and reliability of React’s useState, ensuring you always work with predictable, up-to-date values, and never mutate data by mistake.
Need to manage async requests, intermediate results, or streaming data?
Enter (@cripty2001/fluctu - Coming Soon): a powerful async layer built on top of Whispr.
Fluctu uses Whispr under the hood for its reactivity core, and provides a flexible async interface designed to fit every data flow pattern—not just the common ones.
It includes convenient built-in modes for popular use cases:
| Mode | When it Publishes | Best For |
|---|---|---|
| Debounced Mode | Only if it's still the latest | Stable UI, no flicker, final answers only |
| Async Result Mode | Always, unless newer result won | Intermediate results are helpful |
| Streaming Mode | Anytime (if no newer result won) | Real-time, chunked, or partial data flows |
But this is just the beginning.
Like Whispr, Fluctu gives you a generic low-level interface—the building blocks to design any async behavior you want.
Whether you're implementing a data loader, a streaming API handler, or a debounce/cancel logic across changing parameters, Fluctu lets you express your intent without boilerplate.
And since it’s all powered by Whispr, your async flows remain fully observable, reactive, and memory-aware.
Wait until an observable matches a specific condition:
const [user, setUser] = Whispr.create<User | null>(null);
fetch("/user")
.then((data) => data.json())
.then((data) => setUser(data));
await user.wait((u) => u !== null);
// This will implicitly pause the execution until the fetch completed successfully
You are just waiting for a non-null and non-undefined value? Use load()
const [user, setUser] = Whispr.create<User | null>(null);
fetch("/user")
.then((data) => data.json())
.then((data) => setUser(data));
await user.load();
// This will implicitly pause the execution until the fetch completed successfully
Easily build merged Whisprs with Whispr.from, having it kept in sync automatically
// users is a list of users id
// profiles is a map of data indexed by user id
const merged = Whispr.from(
{
users: users,
profiles: profiles,
},
({ users, profiles }) => {
return users.map((item) => ({
id: item,
profile: profiles[id],
}));
},
);
✨ Merged is kept in sync with both
usersandprofiles, and, when it goes out of scope, it is automatically unsubscribed from both to save resources
If the derived Whispr is equal to its input, you can use the Whispr.consolidate instead.
This is particularly useful to merge a series of Whispr into a single one, to consolidate reactivity and improve developer experience.
const [userId] = Whispr.create('user');
const profile = loadUserProfile(userId) // Returns Whispr(UserProfile | null) - null while loading
const merged_from = Whispr.from(
{
id: userId,
profile: profile
},
({id, profile} => ({
id,
profile
}))
)
const merged_consolidate = Whispr.consolidate({id, profile})
// merged_from and merged_consolidate are practically the same
If you are just transforming a single Whispr, use w.transform
const user: Whispr<User>;
const uid = user.transform((u) => u.id);
const uid_from = Whispr.from({ user: user }, ({ user }) => {
return user.id;
});
// uid and uid_from are practically the same
Easily bind cleanup to object liveness
// Create a Whispr observable for the latest message
const [message, setMessage] = Whispr.create<string | null>(null, () => {
ws.close();
});
// Open a websocket
const ws = new WebSocket("wss://example.org");
// Listen for messages
ws.addEventListener("message", (event) => {
set(event.data);
});
Due to the unsubscribe callback, the wss is automatically closed when message dies. The good thing? This can be applied to anything!
All the Whispr library is fully typed.
const a = Whispr.create<
T // Type of a.value
>()
const f = Whispr.from<
I // Type of the input, as a map,
O // Type of the output
>()
Whispr<T>A reactive observable container with safe updates, subscriptions, and lifecycle management.
Whispr.create<T>(initial: T, onDie?: () => void): [Whispr<T>, WhisprMutations<T> ]Creates a new observable instance. When the observable is no longer referenced, onDie will be called.
const [user, setUser] = Whispr.create({ name: "Alice" });
value: TReturns the current observable value. The value is NOT cloned, so it is NOT safe to directly mutate it.
subscribe(cb: (data: T) => void | "STOP", immediate?: boolean): () => voidSubscribes to the observable. The callback is called on every change. Return "STOP" to unsubscribe automatically. Please note that data is NOT cloned, so it is NOT safe to directly mutate it.
wait(cb: (data: T) => R | null | undefined): Promise<R>Waits for the first non-null result from cb(data). Automatically unsubscribes after resolution.
load(): Promise<NonNullable<T>>Waits until the observable emits a defined, non-null value. Equivalent to wait(data => data).
Whispr.from(...)Creates a computed observable from multiple source observables.
MIT
Built with care by Fabio Mauri (cripty2001[at]outlook[dot]com).
Contributions and issues welcome (especially on tests)!
FAQs
A minimal, observable-based state engine with safe-by-default cloning, reactive computed values, and lifecycle-aware observables.
We found that @cripty2001/whispr demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
OpenAI rotated macOS signing certificates after a malicious Axios package reached its CI pipeline in a broader software supply chain attack.

Security News
Open source is under attack because of how much value it creates. It has been the foundation of every major software innovation for the last three decades. This is not the time to walk away from it.

Security News
Socket CEO Feross Aboukhadijeh breaks down how North Korea hijacked Axios and what it means for the future of software supply chain security.