Skip to content

Proposal: Server signals #259

Description

@terrablue

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions