topic

js/context/topic.ts

fino:context/topic - Named pub/sub channels with Context binding.

A Topic is a named pub/sub channel. topic(name) gets or creates one in a process-wide registry, so independently loaded modules that use the same name share the same channel - the basis for cross-cutting concerns such as logging, tracing, and diagnostics. Delivery is synchronous: publish() calls every subscriber before returning, isolating each in its own try/catch and forwarding failures to the execution-flow:error topic so one bad subscriber never starves the rest. Topics are also async iterable, so a consumer can for await messages with per-iterator buffering.

When a Context is bound to a Topic, calling topic.runWithValue(msg, fn) automatically derives and installs the bound context value for the duration of fn. This separates the concerns of publishing (the library/framework) from consuming context (application code): the publisher never needs to know which contexts downstream code cares about.

import { topic } from 'fino:context/topic';
import { Context } from 'fino:context';

const requestCtx = new Context('request');
const httpTopic = topic('http.request');

httpTopic.bindContext(requestCtx, (req) => req);

// In a handler:
httpTopic.runWithValue(request, () => {
  // requestCtx.get() === request here, and in any awaited code
  handleRequest();
});

Functions

function topic<T = unknown>(name: string): Topic<T>

Get or create a named Topic. Topics with the same name share state across all imports - useful for cross-cutting bindings between libraries.

A new topic starts with no subscribers and no context bindings. The function never returns null; invalid or empty names are accepted as ordinary map keys, so choose stable names such as package:event.

import { topic } from 'fino:context/topic';

const requests = topic<{ id: string }>('http:request');
requests.publish({ id: 'req-1' });

function subscribeMatching<T = unknown>( matcher: ( name: string ) => boolean, fn: ( msg: T, topicName: string ) => void ): SubscriptionHandle

Subscribe to all existing and future topics whose names match matcher.

The matcher is evaluated for topics already in the registry and for each topic created later. The callback receives every published message along with the name of the topic it came from, so one callback can fan in a whole family of topics. Callback errors are handled by the matched topic's normal publish() error path. Returns a handle that removes every attached subscription (and stops watching for new topics) when disposed.

import { subscribeMatching, topic } from 'fino:context/topic';

const handle = subscribeMatching(
  (name) => name.startsWith('audit:'),
  (event, topicName) => console.log(topicName, event),
);
topic('audit:login').publish({ user: 'ana' });
handle.dispose();

Classes

class Topic<T = unknown> {

Named publish/subscribe channel that can bind messages into async contexts.

Topics deliver messages synchronously to current subscribers. Use runWithValue() when subscribers and a callback should execute with context values derived from the message.

import { topic } from 'fino:context/topic';

const updates = topic<string>('status:update');
updates.subscribe((message) => console.log(message));
updates.publish('ready');

Constructors

constructor(name: string)

Create an unregistered topic instance.

Direct construction is useful for private channels. Use topic(name) when other modules should retrieve the same shared instance by name.

import { Topic } from 'fino:context/topic';

const privateTopic = new Topic<number>('local:count');
privateTopic.publish(1);

Getters

get name(): string

Read the topic name.

The name is not required to be globally unique for directly constructed topics, but registry-created topics use it as their lookup key.

import { topic } from 'fino:context/topic';

console.log(topic('metrics:tick').name);
get hasSubscribers(): boolean

Report whether the topic currently has one or more subscribers.

This is a synchronous snapshot. It may be used to avoid constructing expensive messages, but a subscriber can still be added or disposed immediately after the check.

import { topic } from 'fino:context/topic';

const events = topic('metrics:event');
if (events.hasSubscribers) events.publish({ count: 1 });

Methods

subscribe(fn: (msg: T) => void): SubscriptionHandle

Register a subscriber callback. Returns a SubscriptionHandle with a dispose() method to remove the subscription.

The same function can be subscribed multiple times; each call returns an independent handle.

Delivery is synchronous during publish() and runWithValue(). A thrown subscriber error is forwarded to the execution-flow:error topic and does not prevent later subscribers from receiving the same message.

import { topic } from 'fino:context/topic';

const messages = topic<string>('chat:message');
const handle = messages.subscribe((message) => console.log(message));
handle.dispose();
unsubscribe(handle: SubscriptionHandle): void

Remove a subscription via its handle.

This is equivalent to calling handle.dispose(). Passing a handle from a different topic is harmless when the handle itself is still valid because handles close over their own removal logic.

import { topic } from 'fino:context/topic';

const events = topic('app:event');
const handle = events.subscribe(() => {});
events.unsubscribe(handle);
publish(msg: T): void

Fire all subscribers with msg. Each subscriber runs in its own try/catch; errors are forwarded to the "execution-flow:error" topic so a failing subscriber never blocks delivery to others.

Note: does NOT enter bound context scopes. Use runWithValue() for that.

Publishing to a topic with no subscribers is a no-op. Messages are passed by reference; mutating an object in one subscriber affects later subscribers that receive the same object.

import { topic } from 'fino:context/topic';

const events = topic<{ ok: boolean }>('service:event');
events.subscribe((event) => console.log(event.ok));
events.publish({ ok: true });
bindContext<C>(ctx: { runWithValue<R>(val: C, fn: () => R): R; }, transform: (msg: T) => C): BindingHandle

Declare that when this topic fires via runWithValue(), ctx should be set to transform(msg) for the duration of the call. Multiple bindings are entered in registration order (outermost first) and restored in reverse.

Anything with a runWithValue(value, fn) method works as the binding target, though Context from fino:context is the usual choice. The returned handle removes the binding when disposed; publish() alone never enters bound scopes.

import { Context } from 'fino:context';
import { topic } from 'fino:context/topic';

const requestId = new Context<string>('requestId');
const requests = topic<{ id: string }>('http:request');
const binding = requests.bindContext(requestId, (request) => request.id);
binding.dispose();
unbindContext(handle: BindingHandle): void

Remove a context binding via its handle.

This is equivalent to calling handle.dispose(). Once removed, future runWithValue() calls no longer enter the associated context.

import { Context } from 'fino:context';
import { topic } from 'fino:context/topic';

const ctx = new Context<string>('tenant');
const events = topic<{ tenant: string }>('tenant:event');
const handle = events.bindContext(ctx, (event) => event.tenant);
events.unbindContext(handle);
runWithValue<R>(msg: T, fn: () => R): R

Enter all bound context scopes (in registration order), publish msg to subscribers, then run fn - all within those scopes. Context values are derived by calling each binding's transform(msg). Scopes are restored in reverse order after fn returns or throws, and the return value of fn is passed through.

import { Context } from 'fino:context';
import { topic } from 'fino:context/topic';

const ctx = new Context<string>('requestId');
const requests = topic<{ id: string }>('http:request');
requests.bindContext(ctx, (request) => request.id);
requests.runWithValue({ id: 'req-1' }, () => console.log(ctx.get()));

class SubscriptionHandle {

Disposable handle returned from topic subscriptions.

Disposing removes the callback from the topic that created the handle. The current implementation tolerates repeated disposal.

import { topic } from 'fino:context/topic';

const handle = topic('logs').subscribe((line) => console.log(line));
handle.dispose();

Constructors

constructor(disposeFn: () => void)

Create a subscription handle from a disposal callback.

Application code normally receives handles from Topic.subscribe() or subscribeMatching(). The callback is called every time dispose() is invoked, so make custom callbacks idempotent.

import { SubscriptionHandle } from 'fino:context/topic';

const handle = new SubscriptionHandle(() => console.log('disposed'));
handle.dispose();

Methods

dispose(): void

Remove the subscription.

Calling more than once is safe for handles created by this module. Custom handles depend on the callback passed to the constructor.

import { topic } from 'fino:context/topic';

const handle = topic('events').subscribe(() => {});
handle.dispose();

class BindingHandle {

Disposable handle returned from context bindings.

Disposing removes one registered binding from a topic. Other bindings and subscriptions remain active.

import { Context } from 'fino:context';
import { topic } from 'fino:context/topic';

const ctx = new Context<string>('trace');
const handle = topic<{ trace: string }>('trace:event')
  .bindContext(ctx, (event) => event.trace);
handle.dispose();

Constructors

constructor(disposeFn: () => void)

Create a binding handle from a disposal callback.

Application code normally receives handles from Topic.bindContext(). Custom callbacks should be idempotent if callers may dispose repeatedly.

import { BindingHandle } from 'fino:context/topic';

const handle = new BindingHandle(() => console.log('unbound'));
handle.dispose();

Methods

dispose(): void

Remove the context binding.

Calling more than once is safe for handles created by this module. After disposal, future Topic.runWithValue() calls skip the removed binding.

import { Context } from 'fino:context';
import { topic } from 'fino:context/topic';

const ctx = new Context<string>('trace');
const handle = topic<string>('trace').bindContext(ctx, (trace) => trace);
handle.dispose();