pulse-sdk 0.0.3 → 0.0.4

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.
package/README.md CHANGED
@@ -149,6 +149,25 @@ The tests in `tests/` start a lightweight gRPC test server automatically; run th
149
149
  - Can I use decorators for handlers? Yes — TypeScript supports decorators, but this SDK does not use them by default. We can add decorator helpers later if desired.
150
150
  - How do I enable TLS / secure credentials? The client factory currently uses `createInsecure()` by default. We can expose a `credentials` option on `PulseConfig` to accept `grpc.credentials.createSsl(...)` or other credential objects.
151
151
 
152
+ **Grouped consumption**
153
+
154
+ - **What it does:** When `grouped` is `true` (the default) the SDK coalesces consumers inside the same process that use the same `consumerName` into a single streaming connection. Messages are distributed (round-robin) among the registered handlers so each message is delivered to only one handler in the group (1x per client id).
155
+ - **Why:** This mirrors the Python SDK behavior where handlers registered with `grouped=True` share a single consumer stream and avoid duplicate processing inside the same client.
156
+ - **How to configure:**
157
+ - `pulse.yml` (recommended): set `grouped: true` or `grouped: false` under top-level config.
158
+ - Environment variable: `PULSE_GROUPED=true|false`.
159
+ - Programmatically: pass `grouped` in the `PulseConfig` passed to `Producer`/`Consumer`.
160
+ - **Default:** `grouped` defaults to `true`.
161
+ - **Grouped=false behavior:** When `grouped` is `false`, the SDK will ensure consumers use unique consumer IDs (if you pass the default client name), so multiple consumers in the same process each receive all messages independently (useful for testing or when you want duplicate consumption).
162
+ - **Example `pulse.yml` entry:**
163
+
164
+ ```yaml
165
+ grpcUrl: localhost:5556
166
+ grouped: true
167
+ ```
168
+
169
+ The test-suite includes integration tests that validate both `grouped=true` and `grouped=false` behaviour.
170
+
152
171
  ## Contributing
153
172
 
154
173
  Please open a PR with tests. The existing tests validate basic Producer/Consumer behaviour.
package/dist/config.d.ts CHANGED
@@ -8,6 +8,7 @@ export interface PulseConfig {
8
8
  grpcUrl: string;
9
9
  eventTypes: string[];
10
10
  consumerName?: string;
11
+ grouped?: boolean;
11
12
  topics?: TopicConfig[];
12
13
  }
13
14
  export declare function loadConfig(configPath?: string): PulseConfig;
package/dist/config.js CHANGED
@@ -13,6 +13,7 @@ const client_1 = require("./proto/client");
13
13
  const DEFAULTS = {
14
14
  grpcUrl: 'localhost:50052',
15
15
  eventTypes: ['events'],
16
+ grouped: true,
16
17
  };
17
18
  function loadConfig(configPath) {
18
19
  const candidates = [
@@ -37,10 +38,14 @@ function loadConfig(configPath) {
37
38
  envCfg.eventTypes = process.env.PULSE_EVENT_TYPES.split(',');
38
39
  if (process.env.PULSE_CONSUMER_NAME)
39
40
  envCfg.consumerName = process.env.PULSE_CONSUMER_NAME;
41
+ if (process.env.PULSE_GROUPED)
42
+ envCfg.grouped = process.env.PULSE_GROUPED === 'true';
40
43
  const merged = Object.assign({}, DEFAULTS, fileCfg, envCfg);
41
44
  // Ensure eventTypes array exists
42
45
  if (!merged.eventTypes)
43
46
  merged.eventTypes = DEFAULTS.eventTypes;
47
+ if (merged.grouped === undefined)
48
+ merged.grouped = true;
44
49
  return merged;
45
50
  }
46
51
  // Initialize topics from config using gRPC CreateTopic RPC.
@@ -4,7 +4,9 @@ export declare class Consumer {
4
4
  private config;
5
5
  private handlers;
6
6
  private client;
7
+ private unregisterFns;
7
8
  constructor(config: PulseConfig);
8
9
  on(eventType: string, handler: EventHandler): void;
9
10
  start(topic?: string, consumerName?: string): Promise<void>;
11
+ close(): void;
10
12
  }
package/dist/consumer.js CHANGED
@@ -2,10 +2,14 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Consumer = void 0;
4
4
  const client_1 = require("./proto/client");
5
+ const consumerManager_1 = require("./consumerManager");
6
+ const crypto_1 = require("crypto");
7
+ const message_1 = require("./message");
5
8
  class Consumer {
6
9
  constructor(config) {
7
10
  this.config = config;
8
11
  this.handlers = {};
12
+ this.unregisterFns = [];
9
13
  this.client = (0, client_1.createClient)(config.grpcUrl);
10
14
  }
11
15
  on(eventType, handler) {
@@ -15,24 +19,81 @@ class Consumer {
15
19
  this.handlers[eventType].push(handler);
16
20
  }
17
21
  async start(topic, consumerName = 'default') {
18
- const req = { topic: topic || this.config.eventTypes[0], consumer_name: consumerName, offset: 0 };
19
- const stream = this.client.Consume(req);
20
- stream.on('data', (msg) => {
21
- const payloadBuf = msg.payload;
22
- let parsed = payloadBuf;
23
- try {
24
- parsed = JSON.parse(payloadBuf.toString());
22
+ const topicName = topic || this.config.eventTypes[0];
23
+ // If grouped is enabled (default true) use shared in-process stream,
24
+ // otherwise create a dedicated stream (each consumer gets all messages).
25
+ const grouped = this.config.grouped !== false;
26
+ if (grouped) {
27
+ // register handlers for this topic to shared stream
28
+ const handlers = this.handlers[topicName] || [];
29
+ for (const h of handlers) {
30
+ const unregister = (0, consumerManager_1.registerSharedHandler)(this.config.grpcUrl, topicName, consumerName, (msg, stub, offset) => {
31
+ // run handler with context so commit() works
32
+ // debug: console.log('consumer.wrapper.invoke', consumerName, topicName);
33
+ (0, message_1.runWithContext)({ stub: stub || this.client, topic: topicName, consumerName, offset: offset ?? msg.offset }, () => {
34
+ try {
35
+ h(msg);
36
+ }
37
+ catch (e) { /* ignore */ }
38
+ });
39
+ });
40
+ this.unregisterFns.push(unregister);
25
41
  }
26
- catch (e) {
27
- // keep raw buffer if not JSON
42
+ // Return a promise that never resolves (stream runs until process exits)
43
+ return new Promise(() => { });
44
+ }
45
+ // If grouped is explicitly false, and the consumerName equals the configured
46
+ // client name or the default literal, generate a unique consumer id so each
47
+ // consumer receives messages independently (mirrors Python behaviour).
48
+ if (!grouped) {
49
+ const base = this.config.consumerName || 'default';
50
+ if (consumerName === base || consumerName === 'default') {
51
+ consumerName = `${base}-${(0, crypto_1.randomUUID)().replace(/-/g, '')}`;
28
52
  }
53
+ }
54
+ const req = { topic: topicName, consumer_name: consumerName, offset: 0 };
55
+ let stream = null;
56
+ try {
57
+ stream = this.client.Consume(req);
58
+ }
59
+ catch (err) {
60
+ // If the client throws synchronously (e.g. CANCELLED), return a promise
61
+ // that is rejected but handled to avoid an unhandled rejection when
62
+ // callers don't await `start()` (tests call start() without awaiting).
63
+ const p = Promise.reject(err);
64
+ p.catch(() => { }); // swallow to avoid uncaught rejection
65
+ return p;
66
+ }
67
+ stream.on('data', (msg) => {
68
+ const message = new message_1.Message(msg);
29
69
  const handlers = this.handlers[req.topic] || [];
30
- handlers.forEach(h => h({ offset: msg.offset, timestamp: msg.timestamp, payload: parsed, headers: msg.headers }));
70
+ for (const h of handlers) {
71
+ // run handler within AsyncLocalStorage context so commit() can access stub and offset
72
+ (0, message_1.runWithContext)({ stub: this.client, topic: req.topic, consumerName, offset: message.offset }, () => {
73
+ try {
74
+ h(message);
75
+ }
76
+ catch (e) { /* handler error ignored here */ }
77
+ });
78
+ }
31
79
  });
32
- return new Promise((resolve, reject) => {
80
+ const p = new Promise((resolve, reject) => {
33
81
  stream.on('end', () => resolve());
34
82
  stream.on('error', (e) => reject(e));
35
83
  });
84
+ // prevent unhandled rejections when callers don't await the returned promise
85
+ p.catch(() => { });
86
+ return p;
87
+ }
88
+ // unregister any shared handlers when this consumer is discarded
89
+ close() {
90
+ for (const u of this.unregisterFns) {
91
+ try {
92
+ u();
93
+ }
94
+ catch (e) { /* ignore */ }
95
+ }
96
+ this.unregisterFns = [];
36
97
  }
37
98
  }
38
99
  exports.Consumer = Consumer;
@@ -0,0 +1,4 @@
1
+ type Handler = (msg: any) => void;
2
+ export declare function shutdownAll(): Promise<void>;
3
+ export declare function registerSharedHandler(grpcUrl: string, topic: string, consumerName: string, handler: Handler): () => void;
4
+ export {};
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.shutdownAll = shutdownAll;
4
+ exports.registerSharedHandler = registerSharedHandler;
5
+ const client_1 = require("./proto/client");
6
+ const message_1 = require("./message");
7
+ let suppressStreamWarnings = false;
8
+ async function shutdownAll() {
9
+ // When shutting down tests/teardown, suppress stream warnings so Jest doesn't
10
+ // complain about logs after tests are finished.
11
+ suppressStreamWarnings = true;
12
+ for (const [k, entry] of Array.from(registry.entries())) {
13
+ try {
14
+ if (entry.stream) {
15
+ try {
16
+ entry.stream.removeAllListeners();
17
+ }
18
+ catch (_) { }
19
+ try {
20
+ if (entry.stream.cancel)
21
+ entry.stream.cancel();
22
+ }
23
+ catch (_) { }
24
+ entry.stream = null;
25
+ }
26
+ try {
27
+ entry.handlers.clear();
28
+ }
29
+ catch (_) { }
30
+ }
31
+ catch (_) {
32
+ // ignore individual errors
33
+ }
34
+ try {
35
+ if (entry.client && typeof entry.client.close === 'function')
36
+ entry.client.close();
37
+ }
38
+ catch (_) { }
39
+ try {
40
+ registry.delete(k);
41
+ }
42
+ catch (_) { }
43
+ }
44
+ }
45
+ const registry = new Map();
46
+ function keyFor(grpcUrl, topic, consumerName) {
47
+ return `${grpcUrl}::${topic}::${consumerName}`;
48
+ }
49
+ function registerSharedHandler(grpcUrl, topic, consumerName, handler) {
50
+ const k = keyFor(grpcUrl, topic, consumerName);
51
+ let entry = registry.get(k);
52
+ if (!entry) {
53
+ const client = (0, client_1.createClient)(grpcUrl);
54
+ entry = { topic, consumerName, client, stream: null, handlers: new Set(), nextIndex: 0, grpcUrl };
55
+ // add handler before starting the stream to avoid losing early messages
56
+ entry.handlers.add(handler);
57
+ registry.set(k, entry);
58
+ startStream(entry);
59
+ return () => {
60
+ // unregister
61
+ const e = registry.get(k);
62
+ if (!e)
63
+ return;
64
+ e.handlers.delete(handler);
65
+ if (e.handlers.size === 0) {
66
+ try {
67
+ if (e.stream && e.stream.cancel)
68
+ e.stream.cancel();
69
+ }
70
+ catch (err) { }
71
+ registry.delete(k);
72
+ }
73
+ };
74
+ }
75
+ entry.handlers.add(handler);
76
+ return () => {
77
+ // unregister
78
+ const e = registry.get(k);
79
+ if (!e)
80
+ return;
81
+ e.handlers.delete(handler);
82
+ if (e.handlers.size === 0) {
83
+ // cleanup stream
84
+ try {
85
+ if (e.stream && e.stream.cancel)
86
+ e.stream.cancel();
87
+ }
88
+ catch (e) {
89
+ // ignore
90
+ }
91
+ registry.delete(k);
92
+ }
93
+ };
94
+ }
95
+ function startStream(entry) {
96
+ const req = { topic: entry.topic, consumer_name: entry.consumerName, offset: 0 };
97
+ const stream = entry.client.Consume(req);
98
+ entry.stream = stream;
99
+ // debug: console.log('consumerManager.startStream', entry.grpcUrl, entry.topic, entry.consumerName);
100
+ stream.on('data', (msg) => {
101
+ const message = new message_1.Message(msg);
102
+ const handlers = Array.from(entry.handlers);
103
+ if (handlers.length === 0)
104
+ return;
105
+ if (entry.nextIndex === undefined)
106
+ entry.nextIndex = 0;
107
+ const h = handlers[entry.nextIndex % handlers.length];
108
+ entry.nextIndex = (entry.nextIndex + 1) % handlers.length;
109
+ try {
110
+ // run handler inside context providing the stub for commit()
111
+ // debug: console.log('consumerManager.dispatch', entry.topic, 'offset', message.offset, 'handlerIndex', entry.nextIndex);
112
+ (0, message_1.runWithContext)({ stub: entry.client, topic: entry.topic, consumerName: entry.consumerName, offset: message.offset }, () => {
113
+ h(message);
114
+ });
115
+ }
116
+ catch (e) {
117
+ console.warn('handler error', e);
118
+ }
119
+ });
120
+ stream.on('error', (e) => {
121
+ // Log once and clean up the registry entry to avoid reconnect storms and test leaks
122
+ if (!suppressStreamWarnings) {
123
+ // Ignore normal client-side cancellations which happen during unregister
124
+ // and shutdown; only log unexpected stream errors.
125
+ try {
126
+ const code = e && typeof e.code !== 'undefined' ? e.code : null;
127
+ if (code !== 1) {
128
+ console.warn('shared consumer stream error for', entry.topic, e);
129
+ }
130
+ }
131
+ catch (_) {
132
+ // if anything goes wrong determining code, log the error
133
+ console.warn('shared consumer stream error for', entry.topic, e);
134
+ }
135
+ }
136
+ try {
137
+ if (entry.stream) {
138
+ try {
139
+ entry.stream.removeAllListeners();
140
+ }
141
+ catch (_) { }
142
+ try {
143
+ if (entry.stream.cancel)
144
+ entry.stream.cancel();
145
+ }
146
+ catch (_) { }
147
+ entry.stream = null;
148
+ }
149
+ }
150
+ catch (err) {
151
+ // ignore
152
+ }
153
+ try {
154
+ try {
155
+ if (entry.client && typeof entry.client.close === 'function')
156
+ entry.client.close();
157
+ }
158
+ catch (_) { }
159
+ registry.delete(keyFor(entry.grpcUrl || '', entry.topic, entry.consumerName));
160
+ }
161
+ catch (err) {
162
+ // ignore
163
+ }
164
+ });
165
+ stream.on('end', () => {
166
+ // stream ended; clean up entry
167
+ try {
168
+ if (entry.stream) {
169
+ try {
170
+ entry.stream.removeAllListeners();
171
+ }
172
+ catch (_) { }
173
+ try {
174
+ if (entry.stream.cancel)
175
+ entry.stream.cancel();
176
+ }
177
+ catch (_) { }
178
+ entry.stream = null;
179
+ }
180
+ try {
181
+ if (entry.client && typeof entry.client.close === 'function')
182
+ entry.client.close();
183
+ }
184
+ catch (_) { }
185
+ registry.delete(keyFor(entry.grpcUrl || '', entry.topic, entry.consumerName));
186
+ }
187
+ catch (err) {
188
+ // ignore
189
+ }
190
+ });
191
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export * from './producer';
2
2
  export * from './consumer';
3
3
  export * from './config';
4
+ export * from './message';
5
+ export * from './consumerManager';
package/dist/index.js CHANGED
@@ -17,3 +17,5 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./producer"), exports);
18
18
  __exportStar(require("./consumer"), exports);
19
19
  __exportStar(require("./config"), exports);
20
+ __exportStar(require("./message"), exports);
21
+ __exportStar(require("./consumerManager"), exports);
@@ -0,0 +1,24 @@
1
+ export interface IMessage {
2
+ offset: number;
3
+ timestamp: number;
4
+ payload: any;
5
+ headers: Record<string, string>;
6
+ }
7
+ interface MessageContext {
8
+ stub: any;
9
+ topic: string;
10
+ consumerName: string;
11
+ offset: number;
12
+ committed?: boolean;
13
+ }
14
+ export declare function runWithContext(ctx: MessageContext, fn: () => void): void;
15
+ export declare function getContext(): MessageContext | undefined;
16
+ export declare function commit(): Promise<void>;
17
+ export declare class Message implements IMessage {
18
+ offset: number;
19
+ timestamp: number;
20
+ payload: any;
21
+ headers: Record<string, string>;
22
+ constructor(protoMsg: any);
23
+ }
24
+ export {};
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Message = void 0;
4
+ exports.runWithContext = runWithContext;
5
+ exports.getContext = getContext;
6
+ exports.commit = commit;
7
+ const async_hooks_1 = require("async_hooks");
8
+ const storage = new async_hooks_1.AsyncLocalStorage();
9
+ function runWithContext(ctx, fn) {
10
+ storage.run(ctx, fn);
11
+ }
12
+ function getContext() {
13
+ return storage.getStore();
14
+ }
15
+ async function commit() {
16
+ const ctx = storage.getStore();
17
+ if (!ctx)
18
+ throw new Error('commit() called outside of a consumer handler');
19
+ if (ctx.committed)
20
+ return;
21
+ return new Promise((resolve, reject) => {
22
+ try {
23
+ ctx.stub.CommitOffset({ topic: ctx.topic, consumer_name: ctx.consumerName, offset: ctx.offset + 1 }, (err, res) => {
24
+ if (err)
25
+ return reject(err);
26
+ ctx.committed = true;
27
+ resolve();
28
+ });
29
+ }
30
+ catch (e) {
31
+ reject(e);
32
+ }
33
+ });
34
+ }
35
+ class Message {
36
+ constructor(protoMsg) {
37
+ this.offset = protoMsg.offset;
38
+ this.timestamp = protoMsg.timestamp;
39
+ this.headers = {};
40
+ try {
41
+ this.headers = Object.assign({}, protoMsg.headers);
42
+ }
43
+ catch (e) {
44
+ this.headers = {};
45
+ }
46
+ const buf = protoMsg.payload;
47
+ const ptype = this.headers['payload-type'];
48
+ if (ptype === 'json') {
49
+ try {
50
+ this.payload = JSON.parse(buf.toString());
51
+ }
52
+ catch (e) {
53
+ this.payload = buf;
54
+ }
55
+ }
56
+ else if (ptype === 'string') {
57
+ try {
58
+ this.payload = buf.toString('utf8');
59
+ }
60
+ catch (e) {
61
+ this.payload = buf;
62
+ }
63
+ }
64
+ else if (ptype === 'bytes') {
65
+ this.payload = buf;
66
+ }
67
+ else {
68
+ try {
69
+ this.payload = JSON.parse(buf.toString());
70
+ }
71
+ catch (e) {
72
+ this.payload = buf;
73
+ }
74
+ }
75
+ }
76
+ }
77
+ exports.Message = Message;
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "pulse-sdk",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Pulse SDK for Node.js/TypeScript",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "scripts": {
8
8
  "build": "tsc",
9
9
  "postbuild": "node scripts/copy-proto.js",
10
- "test": "jest"
10
+ "test": "npm run build && jest"
11
11
  },
12
12
  "author": "",
13
13
  "license": "MIT",
@@ -22,21 +22,21 @@
22
22
  "devDependencies": {
23
23
  "typescript": "^5.0.0",
24
24
  "jest": "^29.0.0",
25
- "ts-jest": "^29.0.0",
26
- "@types/jest": "^29.0.0",
25
+ "ts-jest": "^29.0.0"
26
+ ,"@types/jest": "^29.0.0",
27
27
  "@types/js-yaml": "^4.0.5"
28
28
  },
29
29
  "dependencies": {
30
30
  "@grpc/grpc-js": "^1.8.0",
31
31
  "protobufjs": "^7.2.0",
32
- "@grpc/proto-loader": "^0.7.0",
33
- "js-yaml": "^4.1.0"
34
- },
32
+ "@grpc/proto-loader": "^0.7.0"
33
+ ,"js-yaml": "^4.1.0"
34
+ }
35
+ ,
35
36
  "jest": {
36
37
  "preset": "ts-jest",
37
38
  "testEnvironment": "node",
38
- "testMatch": [
39
- "**/tests/**/*.test.ts"
40
- ]
39
+ "testMatch": ["**/tests/**/*.test.ts"],
40
+ "globalTeardown": "<rootDir>/tests/globalTeardown.js"
41
41
  }
42
42
  }