@isdk/tool-event
v0.1.2
Published
Brings powerful, real-time, bidirectional event communication to the @isdk/tool-rpc ecosystem. Seamlessly integrates a publish/subscribe model into the familiar RPC/RESTful architecture.
Maintainers
Readme
@isdk/tool-event
@isdk/tool-event brings powerful, real-time, bidirectional event communication to the @isdk/tool-rpc ecosystem.
Its core design philosophy is to seamlessly integrate a publish/subscribe model into the familiar RPC/RESTful architecture you already use. Instead of manually managing a separate WebSocket or SSE connection, you treat real-time events as just another "tool" that is discoverable and callable through the standard tool-rpc framework. This approach dramatically simplifies the complexity of building interactive AI agents, live data dashboards, notification systems, and any application requiring real-time updates.
In short, @isdk/tool-event lets you handle all remote communication—whether it's a regular RPC or a real-time event stream—in a unified and simple way.
This package is built upon @isdk/tool-func and @isdk/tool-rpc. Please ensure you are familiar with their core concepts before proceeding.
✨ Core Features
- 🚀 Real-Time Communication: Provides a robust Pub/Sub model for real-time, bidirectional event flow between server and clients.
- 🔌 Pluggable Transport Layer: Abstracted transport layer allows using different communication protocols. Comes with a built-in implementation for Server-Sent Events (SSE).
- 🔗 Seamless Integration: Extends
@isdk/tool-rpc'sResServerToolsandResClientTools, making event endpoints behave like any other RESTful/RPC tool. - 🔄 Automatic Forwarding: Easily forward events from a server-side event bus to clients, or from a client-side event bus to the server.
- 🎯 Targeted Publishing: Publish events from the server to all subscribed clients or target specific clients by their ID.
- 🔐 Secure by Default: Client-published events are sandboxed and are not automatically injected into the server's main event bus unless explicitly enabled, preventing unintended side effects.
🏛️ Architecture
@isdk/tool-event introduces EventClient and EventServer which work in tandem with a pluggable transport layer to facilitate real-time communication.
graph TD
subgraph Client-Side
A[Your Application] --> B[EventClient];
B -- Uses --> C[IPubSubClientTransport (e.g., SseClientPubSubTransport)];
end
subgraph Server-Side
G[EventServer] --> H[IPubSubServerTransport (e.g., SseServerPubSubTransport)];
H -- Manages --> I[Underlying Protocol (SSE, WebSocket, etc.)];
J[Server-Side EventBus] -.->|Forwarded by EventServer| H;
end
subgraph Network
C -- HTTP/SSE Connection --> I;
end
A -- 1. subscribe('my-event') --> B;
B -- 2. connect() --> C;
C -- 3. Establishes persistent connection --> I;
I -- 4. Creates session --> H;
H -- 5. Registers subscription --> H;
J -- 6. emit('my-event', data) --> G;
G -- 7. publish(data) --> H;
H -- 8. Sends data to subscribed client --> I;
I -- 9. Pushes event data --> C;
C -- 10. Triggers event --> B;
B -- 11. emit('my-event', data) --> A;- EventClient/EventServer: These are specialized
ResClientTools/ResServerToolsthat handle the logic of subscribing, unsubscribing, and publishing events. - Transport Layer (
IPubSub...Transport): This is the abstraction responsible for the actual communication protocol. The library provides a defaultSseServerPubSubTransportandSseClientPubSubTransportfor Server-Sent Events. You can create your own transport for WebSockets, IPC, or other protocols. - Event Flow: The client uses
EventClienttosubscribeto topics. The transport establishes a connection. When the server'sEventServerpublishesan event, the transport delivers it only to subscribed clients.
📦 Installation
npm install @isdk/tool-event @isdk/tool-rpc @isdk/tool-func🚀 Quick Start
This example demonstrates setting up a server that pushes the current time every 3 seconds and a client that subscribes to it.
Step 1: Define the Event Server
The EventServer acts as the central hub for event management. We'll use the built-in SSE transport and host it using @isdk/tool-rpc's HTTP transport.
// ./server.ts
import { HttpServerToolTransport } from '@isdk/tool-rpc';
import { EventServer, SseServerPubSubTransport } from '@isdk/tool-event';
async function startServer() {
// 1. Instantiate the SSE transport for the server.
const sseTransport = new SseServerPubSubTransport();
// 2. Statically set the transport on the EventServer class.
EventServer.setPubSubTransport(sseTransport);
// 3. Instantiate and register the main event tool.
// The name 'event' will be part of the URL (e.g., /api/event).
const eventTool = new EventServer('event');
eventTool.register();
// 4. Forward a server-side event named 'server-time' to clients.
// Any client subscribed to 'server-time' will receive it.
eventTool.forward('server-time');
// 5. Use the standard HTTP transport from tool-rpc to host our tools.
const httpTransport = new HttpServerToolTransport();
// 6. Mount the EventServer base class. The transport will find the registered 'event' tool.
// This creates the necessary endpoints under the '/api' prefix.
httpTransport.mount(EventServer, '/api');
// 7. Start the server.
const port = 3000;
await httpTransport.start({ port });
console.log(`✅ Event server started at http://localhost:${port}`);
// 8. Publish the 'server-time' event every 3 seconds.
setInterval(() => {
const data = { now: new Date().toISOString() };
console.log(`\n[Server] Publishing 'server-time':`, data);
// Use the static publish method to send the event.
EventServer.publish('server-time', data);
}, 3000);
}
startServer();Step 2: Set up and Use the Client
The EventClient connects to the server, subscribes to events, and can also publish events back to the server.
// ./client.ts
import { HttpClientToolTransport } from '@isdk/tool-rpc';
import { EventClient, SseClientPubSubTransport } from '@isdk/tool-event';
async function main() {
const apiRoot = 'http://localhost:3000/api';
// 1. Statically set the SSE transport on the EventClient class.
EventClient.setPubSubTransport(new SseClientPubSubTransport());
// 2. Use the standard HTTP transport to discover the remote tools.
const httpTransport = new HttpClientToolTransport(apiRoot);
await httpTransport.mount(EventClient);
// 3. Get the dynamically created proxy for the remote 'event' tool.
const eventClient = EventClient.get('event');
if (!eventClient) {
throw new Error('Remote event tool not found!');
}
// 4. Listen for the 'server-time' event on the client's local event bus.
eventClient.on('server-time', (data) => {
console.log(`[Client] Received 'server-time' event:`, data);
});
// 5. Subscribe to the 'server-time' event from the server.
// This will open the SSE connection.
console.log("[Client] Subscribing to 'server-time'...");
await eventClient.subscribe('server-time');
console.log('✅ [Client] Subscribed successfully!');
// 6. Demonstrate publishing an event FROM the client TO the server.
setTimeout(() => {
const message = { text: 'Hello from the client!' };
console.log('\n[Client] Publishing "client-greeting":', message);
eventClient.publish({ event: 'client-greeting', data: message });
}, 5000);
}
main();Step 3: Run the Example
- Run the server:
ts-node ./server.ts - In a new terminal, run the client:
ts-node ./client.ts
You will see the client receive time updates from the server every 3 seconds.
Core Concepts: The Design Philosophy
To fully grasp EventServer and EventClient, it's crucial to understand their design goal: to seamlessly integrate real-time events into the existing RPC/RESTful architecture of @isdk/tool-rpc. They are more than just event handlers; they are intelligent bridges connecting local events to the remote world.
1. Why Inherit from ResServerTools / ResClientTools?
This core design decision provides several major benefits by extending a familiar framework rather than reinventing the wheel:
Unified Discovery and Client Proxying: Because
EventServeris a standardResServerTools, theHttpClientToolTransportcan automatically discover it and dynamically create a full-featuredEventClientproxy on the client-side. You don't need to write any special client configuration for event handling.Unified API Invocation: Actions like subscribing, unsubscribing, and publishing are cleverly mapped to standard RPC calls.
eventClient.subscribe(...)becomes an RPC call (act: '$sub') to the server behind the scenes.eventClient.publish(...)is likewise an RPC call (act: '$publish'). This means developers can interact with the event system in the exact same way they interact with other tools in the project, dramatically reducing the learning curve.
Transport Reuse: The entire transport and middleware ecosystem of
@isdk/tool-rpcis reused out-of-the-box.
2. The Event Stream as a "Resource"
The library elegantly abstracts a stateful, persistent connection (like SSE) into a stateless, REST-style "resource."
Getting the Event Stream: When a client needs to subscribe to an event for the first time, the
EventClientmakes a request toGET /api/event(which is thelistmethod of theEventServer). The response to this request is a persistent stream of typetext/event-stream. Conceptually, this is equivalent to "getting" a resource that represents the real-time event flow.Managing the Event Stream: Subsequent actions like
subscribeandpublishcan be seen as modifications to the state of this "resource," and they are handled through separate, conventional RPC requests.
This design simplifies the complexity of real-time connection management into a clean REST/RPC model that developers are already very familiar with.
3. The Role as a "Bridge"
The core function of EventServer and EventClient is to act as a bridge:
EventServeris the bridge between the internal server-side event bus and networked clients.- Outbound: Through the
forward()method, it listens to internal events (e.g., a database update) and "publishes" them over the network for all subscribed clients to receive. - Inbound: It receives events "published" from clients and, via the
autoInjectToLocalBusoption, selectively "emits" them (prefixed withclient:) onto the internal event bus for other parts of the server to process.
- Outbound: Through the
EventClientis the bridge between the network and the client application's local event bus.- Inbound: It listens for events pushed from the server over the network and "emits" them on its own instance (which is itself an
EventEmitter). This allows your application code to consume them easily witheventClient.on(...). - Outbound: Through the
publish()orforwardEvent()methods, it "publishes" local client-side events over the network to the server.
- Inbound: It listens for events pushed from the server over the network and "emits" them on its own instance (which is itself an
In summary, this design allows developers to ignore the complex details of network protocols and connection management most of the time. You simply listen for or emit events on the appropriate event bus, and @isdk/tool-event handles all the tedious work in between.
🚀 Advanced Usage
1. Handling Client-Published Events on the Server
By default, for security, events published from a client do not trigger on the server's event bus. To enable this, set EventServer.autoInjectToLocalBus = true. You can then listen for events prefixed with client:.
Server-Side (server.ts):
import { event } from '@isdk/tool-event'; // Import the underlying event tool
const eventBus = event.runSync(); // Get the event bus instance
// ... in your server startup code ...
// Enable auto-injection
EventServer.autoInjectToLocalBus = true;
// Listen for the 'client-greeting' event from any client
eventBus.on('client:client-greeting', function(data, ctx) {
// 'this' is the event object, 'ctx' contains metadata
const senderId = ctx.sender?.clientId;
console.log(`[Server] Received greeting from client ${senderId}:`, data);
// As a response, echo back a private event only to the sender
EventServer.publish('private-reply', { message: 'I got your message!' }, {
clientId: senderId,
});
});When the client from the Quick Start sends its client-greeting event, the server will now log it and send a private reply back to that specific client.
2. Publishing to a Specific Client (Targeted Publishing)
Instead of broadcasting to all subscribers, you can send an event to a specific user by providing their clientId in the publish method.
Client-Side (client.ts):
// ... in your main function ...
// Subscribe to a private event
eventClient.subscribe('private-reply');
// Listen for it
eventClient.on('private-reply', (data) => {
console.log(`[Client] Received a private reply:`, data);
});This setup creates a request-response pattern where a client initiates a public event, and the server responds with a private event that only the originating client receives.
3. Dynamic Subscriptions
A client can change its subscriptions at any time, not just on initial connection, by calling subscribe or unsubscribe. This is useful for scenarios like allowing users to dynamically join or leave "rooms" or "channels".
// client.ts
// ... assuming eventClient is already initialized ...
async function manageSubscriptions() {
console.log('Subscribing to the "news" channel...');
await eventClient.subscribe('news');
// Simulate losing interest in "news" after a while
setTimeout(async () => {
console.log('Unsubscribing from the "news" channel...');
await eventClient.unsubscribe('news');
}, 10000);
}4. Client-Side Event Forwarding
The forwardEvent method is a powerful way to seamlessly sync local client-side activity to the server. Imagine your client application has its own internal event bus for UI interactions. You can selectively forward certain events to the server for processing or broadcasting.
// client.ts
// ... assuming eventClient is initialized and is eventable ...
// Let's say 'ui-event-bus' is a local EventEmitter used in your app.
// For demonstration, we'll have the eventClient play this role.
const localEventBus = eventClient;
// 1. Configure forwarding: any 'user-action' emitted on localEventBus will be sent to the server.
eventClient.forwardEvent('user-action');
console.log('[Client] Set up forwarding for "user-action" events.');
// 2. Simulate a local UI event
setTimeout(() => {
const actionData = { action: 'button-click', elementId: 'save-button' };
console.log('[Client] Emitting "user-action" on local bus:', actionData);
localEventBus.emit('user-action', actionData);
}, 2000);
// On the server, you can now handle 'client:user-action' just like any other client-published event.This pattern is excellent for syncing client behaviors (like analytics, logging, or state changes) to a backend without needing to write manual publish calls at every event site.
5. Implementing and Using Pluggable Transports
One of the library's core strengths is its pluggable transport layer. While it ships with an SSE implementation, you can easily create and swap in your own (e.g., based on WebSockets or IPC).
To do this, you need to implement the IPubSubServerTransport interface. Here is a conceptual skeleton example for a transport based on ws, a popular WebSocket library:
// transports/WebSocketServerTransport.ts
import { WebSocketServer } from 'ws';
import type { IPubSubServerTransport, PubSubServerSession } from '@isdk/tool-event';
export class WebSocketServerTransport implements IPubSubServerTransport {
readonly name = 'websocket';
readonly protocol = 'ws';
private wss: WebSocketServer;
private sessions = new Map<string, PubSubServerSession>();
private onMsg: (session: PubSubServerSession, event: string, data: any) => void;
constructor(options: { port: number }) {
this.wss = new WebSocketServer({ port: options.port });
this.wss.on('connection', (ws) => {
const clientId = uuid(); // Generate a unique ID
const session: PubSubServerSession = {
id: clientId,
clientId,
protocol: 'ws',
send: (event, data) => {
ws.send(JSON.stringify({ event, data }));
},
close: () => ws.close(),
raw: ws,
};
this.sessions.set(clientId, session);
ws.on('message', (message) => {
const { event, data } = JSON.parse(message.toString());
// Invoke the callback registered by EventServer to handle inbound messages
this.onMsg?.(session, event, data);
});
ws.on('close', () => {
this.sessions.delete(clientId);
});
});
}
// EventServer will call this to register its message handler
onMessage(cb) {
this.onMsg = cb;
}
publish(event: string, data: any, target?: { clientId: string | string[] }) {
const payload = JSON.stringify({ event, data });
if (target?.clientId) {
const ids = Array.isArray(target.clientId) ? target.clientId : [target.clientId];
ids.forEach(id => this.sessions.get(id)?.raw.send(payload));
} else {
this.wss.clients.forEach(client => client.send(payload));
}
}
// Note: For WebSockets, connect/subscribe/unsubscribe are often handled
// within the connection and message events, so these might be no-ops or for logging.
connect(options) { /* ... */ }
subscribe(session, events) { /* ... */ }
unsubscribe(session, events) { /* ... */ }
onConnection(cb) { /* ... */ }
onDisconnect(cb) { /* ... */ }
}How to use it:
You would simply replace the transport on your EventServer during startup.
// server.ts
// import { SseServerPubSubTransport } from '@isdk/tool-event'; // Old
import { WebSocketServerTransport } from './transports/WebSocketServerTransport'; // New
// ...
// const sseTransport = new SseServerPubSubTransport(); // Old
const wsTransport = new WebSocketServerTransport({ port: 8080 }); // New
// EventServer.setPubSubTransport(sseTransport); // Old
EventServer.setPubSubTransport(wsTransport); // New
// ... the rest of your code remains the sameThis way, your core business logic in EventServer remains completely decoupled from the underlying communication protocol.
🤝 Contributing
Contributions are welcome! Please read the CONTRIBUTING.md file for guidelines on how to get started.
📄 License
This project is licensed under the MIT License. See the LICENSE-MIT file for more details.
