@hamak/event-channel-backend 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @hamak/event-channel-backend
3
+ *
4
+ * SSE server and Express routes for the event channel.
5
+ */
6
+ export { EventChannelServer, type EventChannelServerConfig, type ServerEvent } from './server/event-channel-server';
7
+ export { createEventChannelRoutes, type EventChannelRoutesConfig } from './routes/event-channel-routes';
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,kBAAkB,EAAE,KAAK,wBAAwB,EAAE,KAAK,WAAW,EAAE,MAAM,+BAA+B,CAAC;AACpH,OAAO,EAAE,wBAAwB,EAAE,KAAK,wBAAwB,EAAE,MAAM,+BAA+B,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @hamak/event-channel-backend
3
+ *
4
+ * SSE server and Express routes for the event channel.
5
+ */
6
+ export { EventChannelServer } from './server/event-channel-server';
7
+ export { createEventChannelRoutes } from './routes/event-channel-routes';
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Event Channel Express Routes
3
+ *
4
+ * GET / - SSE stream endpoint
5
+ * POST /emit - Emit a custom event to all clients
6
+ * POST /dispatch - Dispatch a remote Redux action to all clients
7
+ * GET /status - Connection statistics
8
+ */
9
+ import { Router, type Request } from 'express';
10
+ import { EventChannelServer } from '../server/event-channel-server';
11
+ export interface EventChannelRoutesConfig {
12
+ /** The event channel server instance */
13
+ server: EventChannelServer;
14
+ /** Extract a client ID from the request (defaults to IP-based) */
15
+ getClientId?: (req: Request) => string;
16
+ /** Enable debug logging */
17
+ debug?: boolean;
18
+ }
19
+ export declare function createEventChannelRoutes(config: EventChannelRoutesConfig): Router;
20
+ //# sourceMappingURL=event-channel-routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-channel-routes.d.ts","sourceRoot":"","sources":["../../src/routes/event-channel-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,MAAM,EAAE,KAAK,OAAO,EAAiB,MAAM,SAAS,CAAC;AAC9D,OAAO,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AAEpE,MAAM,WAAW,wBAAwB;IACvC,wCAAwC;IACxC,MAAM,EAAE,kBAAkB,CAAC;IAC3B,kEAAkE;IAClE,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,MAAM,CAAC;IACvC,2BAA2B;IAC3B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,wBAAwB,GAAG,MAAM,CAyFjF"}
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Event Channel Express Routes
3
+ *
4
+ * GET / - SSE stream endpoint
5
+ * POST /emit - Emit a custom event to all clients
6
+ * POST /dispatch - Dispatch a remote Redux action to all clients
7
+ * GET /status - Connection statistics
8
+ */
9
+ import { Router } from 'express';
10
+ export function createEventChannelRoutes(config) {
11
+ const { server, debug } = config;
12
+ const getClientId = config.getClientId ?? ((req) => req.ip ?? 'anonymous');
13
+ const router = Router();
14
+ // SSE stream endpoint
15
+ router.get('/', (req, res) => {
16
+ const clientId = getClientId(req);
17
+ // Set SSE headers
18
+ res.writeHead(200, {
19
+ 'Content-Type': 'text/event-stream',
20
+ 'Cache-Control': 'no-cache',
21
+ 'Connection': 'keep-alive',
22
+ 'X-Accel-Buffering': 'no',
23
+ });
24
+ // Send initial connection event
25
+ res.write(`data: ${JSON.stringify({ type: 'connected', clientId })}\n\n`);
26
+ // Register client
27
+ server.addClient(clientId, res);
28
+ if (debug) {
29
+ console.log(`[event-channel] Client connected: ${clientId} (total: ${server.getClientCount()})`);
30
+ }
31
+ // Clean up on disconnect
32
+ req.on('close', () => {
33
+ server.removeClient(clientId, res);
34
+ if (debug) {
35
+ console.log(`[event-channel] Client disconnected: ${clientId} (total: ${server.getClientCount()})`);
36
+ }
37
+ });
38
+ });
39
+ // Emit a custom event
40
+ router.post('/emit', (req, res) => {
41
+ const { type, data, source } = req.body;
42
+ if (!type) {
43
+ res.status(400).json({ error: 'Missing required field: type' });
44
+ return;
45
+ }
46
+ server.broadcast(type, data ?? {}, source);
47
+ if (debug) {
48
+ console.log(`[event-channel] Event emitted: ${type}`);
49
+ }
50
+ res.json({ ok: true, clientCount: server.getClientCount() });
51
+ });
52
+ // Dispatch a remote Redux action
53
+ router.post('/dispatch', (req, res) => {
54
+ const { actionType, payload, source } = req.body;
55
+ if (!actionType) {
56
+ res.status(400).json({ error: 'Missing required field: actionType' });
57
+ return;
58
+ }
59
+ server.dispatchAction(actionType, payload, source);
60
+ if (debug) {
61
+ console.log(`[event-channel] Action dispatched: ${actionType}`);
62
+ }
63
+ res.json({ ok: true, actionType, clientCount: server.getClientCount() });
64
+ });
65
+ // Connection status
66
+ router.get('/status', (_req, res) => {
67
+ res.json({
68
+ connections: server.getClientCount(),
69
+ uniqueClients: server.getUniqueClientCount(),
70
+ });
71
+ });
72
+ return router;
73
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Event Channel Server
3
+ *
4
+ * Manages SSE connections and broadcasts events to connected clients.
5
+ */
6
+ import type { Response } from 'express';
7
+ export interface EventChannelServerConfig {
8
+ /** Heartbeat interval in ms. @default 30000 */
9
+ heartbeatInterval?: number;
10
+ }
11
+ export interface ServerEvent<T = unknown> {
12
+ id: string;
13
+ type: string;
14
+ data: T;
15
+ timestamp: string;
16
+ source?: string;
17
+ }
18
+ export declare class EventChannelServer {
19
+ private clients;
20
+ private heartbeatTimer;
21
+ private readonly heartbeatInterval;
22
+ constructor(config?: EventChannelServerConfig);
23
+ /** Add a new SSE client connection */
24
+ addClient(clientId: string, res: Response): void;
25
+ /** Remove a client connection */
26
+ removeClient(clientId: string, res: Response): void;
27
+ /** Broadcast event to all connected clients */
28
+ broadcast<T>(type: string, data: T, source?: string): void;
29
+ /** Send event to a specific client */
30
+ sendToClient<T>(clientId: string, type: string, data: T, source?: string): void;
31
+ /** Dispatch a remote Redux action to all connected clients */
32
+ dispatchAction(actionType: string, payload?: unknown, source?: string): void;
33
+ /** Get total connected client count */
34
+ getClientCount(): number;
35
+ /** Get unique client ID count */
36
+ getUniqueClientCount(): number;
37
+ /** Destroy and close all connections */
38
+ destroy(): void;
39
+ private formatSSE;
40
+ private startHeartbeat;
41
+ }
42
+ //# sourceMappingURL=event-channel-server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-channel-server.d.ts","sourceRoot":"","sources":["../../src/server/event-channel-server.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAGxC,MAAM,WAAW,wBAAwB;IACvC,+CAA+C;IAC/C,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,WAAW,CAAC,CAAC,GAAG,OAAO;IACtC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,CAAC,CAAC;IACR,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,OAAO,CAAoC;IACnD,OAAO,CAAC,cAAc,CAA+C;IACrE,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;gBAE/B,MAAM,GAAE,wBAA6B;IAKjD,sCAAsC;IACtC,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,QAAQ,GAAG,IAAI;IAOhD,iCAAiC;IACjC,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,QAAQ,GAAG,IAAI;IAUnD,+CAA+C;IAC/C,SAAS,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI;IAkB1D,sCAAsC;IACtC,YAAY,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI;IAmB/E,8DAA8D;IAC9D,cAAc,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI;IAW5E,uCAAuC;IACvC,cAAc,IAAI,MAAM;IAQxB,iCAAiC;IACjC,oBAAoB,IAAI,MAAM;IAI9B,wCAAwC;IACxC,OAAO,IAAI,IAAI;IAaf,OAAO,CAAC,SAAS;IAIjB,OAAO,CAAC,cAAc;CASvB"}
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Event Channel Server
3
+ *
4
+ * Manages SSE connections and broadcasts events to connected clients.
5
+ */
6
+ import { randomUUID } from 'crypto';
7
+ export class EventChannelServer {
8
+ constructor(config = {}) {
9
+ this.clients = new Map();
10
+ this.heartbeatTimer = null;
11
+ this.heartbeatInterval = config.heartbeatInterval ?? 30000;
12
+ this.startHeartbeat();
13
+ }
14
+ /** Add a new SSE client connection */
15
+ addClient(clientId, res) {
16
+ if (!this.clients.has(clientId)) {
17
+ this.clients.set(clientId, new Set());
18
+ }
19
+ this.clients.get(clientId).add(res);
20
+ }
21
+ /** Remove a client connection */
22
+ removeClient(clientId, res) {
23
+ const clientResponses = this.clients.get(clientId);
24
+ if (clientResponses) {
25
+ clientResponses.delete(res);
26
+ if (clientResponses.size === 0) {
27
+ this.clients.delete(clientId);
28
+ }
29
+ }
30
+ }
31
+ /** Broadcast event to all connected clients */
32
+ broadcast(type, data, source) {
33
+ const event = {
34
+ id: randomUUID(),
35
+ type,
36
+ data,
37
+ timestamp: new Date().toISOString(),
38
+ source,
39
+ };
40
+ const ssePayload = this.formatSSE(event);
41
+ for (const responses of this.clients.values()) {
42
+ for (const res of responses) {
43
+ res.write(ssePayload);
44
+ }
45
+ }
46
+ }
47
+ /** Send event to a specific client */
48
+ sendToClient(clientId, type, data, source) {
49
+ const responses = this.clients.get(clientId);
50
+ if (!responses)
51
+ return;
52
+ const event = {
53
+ id: randomUUID(),
54
+ type,
55
+ data,
56
+ timestamp: new Date().toISOString(),
57
+ source,
58
+ };
59
+ const ssePayload = this.formatSSE(event);
60
+ for (const res of responses) {
61
+ res.write(ssePayload);
62
+ }
63
+ }
64
+ /** Dispatch a remote Redux action to all connected clients */
65
+ dispatchAction(actionType, payload, source) {
66
+ this.broadcast('remote-action', {
67
+ actionType,
68
+ payload,
69
+ meta: {
70
+ source: source ?? 'server',
71
+ timestamp: new Date().toISOString(),
72
+ },
73
+ }, source);
74
+ }
75
+ /** Get total connected client count */
76
+ getClientCount() {
77
+ let count = 0;
78
+ for (const responses of this.clients.values()) {
79
+ count += responses.size;
80
+ }
81
+ return count;
82
+ }
83
+ /** Get unique client ID count */
84
+ getUniqueClientCount() {
85
+ return this.clients.size;
86
+ }
87
+ /** Destroy and close all connections */
88
+ destroy() {
89
+ if (this.heartbeatTimer) {
90
+ clearInterval(this.heartbeatTimer);
91
+ this.heartbeatTimer = null;
92
+ }
93
+ for (const responses of this.clients.values()) {
94
+ for (const res of responses) {
95
+ res.end();
96
+ }
97
+ }
98
+ this.clients.clear();
99
+ }
100
+ formatSSE(event) {
101
+ return `id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
102
+ }
103
+ startHeartbeat() {
104
+ this.heartbeatTimer = setInterval(() => {
105
+ for (const responses of this.clients.values()) {
106
+ for (const res of responses) {
107
+ res.write(': heartbeat\n\n');
108
+ }
109
+ }
110
+ }, this.heartbeatInterval);
111
+ }
112
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@hamak/event-channel-backend",
3
+ "version": "0.5.3",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Event channel backend - SSE server and Express routes for remote event dispatch",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "sideEffects": false,
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/amah/app-framework.git",
16
+ "directory": "packages/event-channel/backend/event-channel-backend"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc",
23
+ "clean": "rm -rf dist",
24
+ "test": "vitest run",
25
+ "test:watch": "vitest"
26
+ },
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js",
31
+ "default": "./dist/index.js"
32
+ }
33
+ },
34
+ "dependencies": {},
35
+ "peerDependencies": {
36
+ "express": "^4.18.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/express": "^4.17.17",
40
+ "@types/node": "^20.0.0",
41
+ "typescript": "~5.4.0",
42
+ "vitest": "^2.0.0"
43
+ }
44
+ }