Motivation
A server signal is a reactive primitive that holds a value which can be read once or observed continuously as it changes over time.
Examples of useful server signals:
- a counter or accumulator updated by server events,
- a DB query result that reflects live data,
- any async value that multiple consumers need to observe independently.
Unlike client-side state — which is local to a single user session — a server signal is shared across all consumers simultaneously. This means a server signal is also inherently a cache: its value is computed once and served to all observers, rather than recomputed per request.
Note: Signals are process-local by default. In multi-process or multi-instance deployments, shared global state requires an external backing store or coordinator.
Today Primate has no general primitive for this. Each use case requires bespoke solutions: manual WebSocket handling, ad-hoc caching, or repeated computation.
Proposed Solution
1) Introduce primate/signal
A new core module exporting two factory functions:
import signal from "primate/signal";
// readable — driven by a push-based initialiser that receives a private set function
const s = signal.readable(async set => { ... });
// writable — value is controlled externally
const s = signal.writable(initialValue);
2) The signal contract
All signals implement ReadableSignal<T>:
interface ReadableSignal<T> {
get(): Promise<T>; // authoritative async read
peek(): T | undefined; // best-known cached value, synchronous
subscribe(cb: (value: T) => void): () => void; // returns unsubscribe function
}
Writable signals extend this with WritableSignal<T>:
interface WritableSignal<T> extends ReadableSignal<T> {
set(value: T): Promise<void>;
update(fn: (prev: T) => T | Promise<T>): Promise<void>;
}
subscribe always returns a cleanup function:
const unsubscribe = s.subscribe(value => console.log(value));
// later
unsubscribe();
peek() returns the last known value synchronously, without triggering a fetch. It returns undefined before the first get() resolves:
s.peek(); // T | undefined — fast, synchronous, may be stale
await s.get(); // T — latest known canonical value
3) Readable vs writable
A readable signal is driven by its initialiser, which receives a private set function and is responsible for establishing and updating the signal's value over time. The initialiser is called eagerly at creation — the signal is always warm:
const clock = signal.readable(async set => {
// establish initial value
await set(new Date());
// push updates every second
setInterval(() => set(new Date()), 1000);
});
await clock.get(); // current time
clock.subscribe(value => console.log(value)); // logs every second
set is injected into the initialiser and never exposed publicly. The outside world can only read the signal, not drive it.
Server signals are designed to be long-lived. Unlike client-side reactive primitives, a server signal runs for the lifetime of the server process regardless of subscriber count. There is no start/stop lifecycle tied to subscriptions — a signal keeps updating whether or not anyone is currently listening.
A writable signal's value is set explicitly from outside. Because writes may involve server coordination or validation, set and update are async and internally serialized — concurrent writes are sequenced, not interleaved:
const s = signal.writable(0);
s.peek(); // undefined (before first get)
await s.get(); // returns 0
s.peek(); // 0 (now cached)
await s.set(1); // updates value, notifies subscribers
await s.update(n => n + 1); // updates relative to current value
WritableSignal extends ReadableSignal — a writable signal is always also readable.
4) get() semantics
get() returns the signal's latest known canonical value. It does not trigger a recompute on every call:
- if a value is already cached, it resolves immediately with that value
- if no value is known yet, it waits until the initialiser's first
set call resolves
- it never forces recomputation
5) subscribe() semantics
subscribe(cb) immediately calls cb with the current cached value if one exists, then continues pushing future updates. This avoids the common race condition of reading and then subscribing separately:
// without immediate emit, this would be race-prone:
const value = await s.get();
const unsubcribe = s.subscribe(...);
// with immediate emit, subscribe alone is sufficient:
const unsubcribe = s.subscribe(value => socket.send(value));
6) Error model
- if the readable initialiser throws before the first
set call, get() rejects with that error
- if the initialiser throws after a value has already been established, the signal retains its last known value — subscribers are not notified of the error
- producer errors after first value are surfaced via logging or hooks (to be defined), not through
subscribe
subscribe remains value-only in v1
7) Example: live counter over WebSockets
A writable signal shared across routes, pushing live updates to all connected clients:
import route from "primate/route";
import response from "primate/response";
import signal from "primate/signal";
const count = signal.writable(0);
// HTTP route increments the counter
route.post(async () => {
await count.update(n => n + 1);
return null;
});
// WS route pushes live value to all connected clients
route.get(() => response.ws({
open(socket) {
const unsub = count.subscribe(value => socket.send(value));
socket.on("close", unsub);
},
}));
Every connected client receives the updated count whenever the HTTP route is called, with no manual broadcast logic required. Note that subscribe immediately emits the current value on connection, so a newly connected client receives the latest count without waiting for the next update.
Security
No new security surface is introduced. Signals are server-side primitives with no network exposure of their own. Consumers are responsible for what they expose over any transport.
Summary of changes
| Area |
Change |
| New module |
primate/signal exporting signal.readable and signal.writable |
| Contract |
ReadableSignal: .get(), .peek(), .subscribe(); WritableSignal extends it with async .set(), .update() |
| Semantics |
get() returns latest known value, waits for first value if needed; subscribe() emits current value immediately on subscription; writes are serialized |
| Documentation |
Add "Server Signals" section with readable/writable examples, get/subscribe semantics, and error model |
| Tests |
Add tests for get, peek, set, update, subscribe, unsubscribe, immediate emit, serialized writes, and error handling |
Open questions
- In a future revision of
response.ws, returning a cleanup function from open could replace socket.on("close", unsubscribe):
open(socket) {
return count.subscribe(value => socket.send(value));
}
This would make signal subscriptions over WebSockets significantly more ergonomic and remove the need to interact with the underlying ws socket directly.
Motivation
A server signal is a reactive primitive that holds a value which can be read once or observed continuously as it changes over time.
Examples of useful server signals:
Unlike client-side state — which is local to a single user session — a server signal is shared across all consumers simultaneously. This means a server signal is also inherently a cache: its value is computed once and served to all observers, rather than recomputed per request.
Today Primate has no general primitive for this. Each use case requires bespoke solutions: manual WebSocket handling, ad-hoc caching, or repeated computation.
Proposed Solution
1) Introduce
primate/signalA new core module exporting two factory functions:
2) The signal contract
All signals implement
ReadableSignal<T>:Writable signals extend this with
WritableSignal<T>:subscribealways returns a cleanup function:peek()returns the last known value synchronously, without triggering a fetch. It returnsundefinedbefore the firstget()resolves:3) Readable vs writable
A readable signal is driven by its initialiser, which receives a private
setfunction and is responsible for establishing and updating the signal's value over time. The initialiser is called eagerly at creation — the signal is always warm:setis injected into the initialiser and never exposed publicly. The outside world can only read the signal, not drive it.Server signals are designed to be long-lived. Unlike client-side reactive primitives, a server signal runs for the lifetime of the server process regardless of subscriber count. There is no start/stop lifecycle tied to subscriptions — a signal keeps updating whether or not anyone is currently listening.
A writable signal's value is set explicitly from outside. Because writes may involve server coordination or validation,
setandupdateare async and internally serialized — concurrent writes are sequenced, not interleaved:WritableSignalextendsReadableSignal— a writable signal is always also readable.4)
get()semanticsget()returns the signal's latest known canonical value. It does not trigger a recompute on every call:setcall resolves5)
subscribe()semanticssubscribe(cb)immediately callscbwith the current cached value if one exists, then continues pushing future updates. This avoids the common race condition of reading and then subscribing separately:6) Error model
setcall,get()rejects with that errorsubscribesubscriberemains value-only in v17) Example: live counter over WebSockets
A writable signal shared across routes, pushing live updates to all connected clients:
Every connected client receives the updated count whenever the HTTP route is called, with no manual broadcast logic required. Note that
subscribeimmediately emits the current value on connection, so a newly connected client receives the latest count without waiting for the next update.Security
No new security surface is introduced. Signals are server-side primitives with no network exposure of their own. Consumers are responsible for what they expose over any transport.
Summary of changes
primate/signalexportingsignal.readableandsignal.writableReadableSignal:.get(),.peek(),.subscribe();WritableSignalextends it with async.set(),.update()get()returns latest known value, waits for first value if needed;subscribe()emits current value immediately on subscription; writes are serializedget,peek,set,update,subscribe, unsubscribe, immediate emit, serialized writes, and error handlingOpen questions
response.ws, returning a cleanup function fromopencould replacesocket.on("close", unsubscribe):wssocket directly.