pulse-sdk 0.0.2 → 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 +69 -3
- package/dist/config.d.ts +11 -1
- package/dist/config.js +72 -3
- package/dist/consumer.d.ts +2 -0
- package/dist/consumer.js +72 -11
- package/dist/consumerManager.d.ts +4 -0
- package/dist/consumerManager.js +191 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/message.d.ts +24 -0
- package/dist/message.js +77 -0
- package/package.json +10 -8
package/README.md
CHANGED
|
@@ -28,11 +28,58 @@ Run the lightweight integration tests (a test gRPC server is started automatical
|
|
|
28
28
|
npm test
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
## Configuration
|
|
31
|
+
## Configuration
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
The SDK supports configuration via (in priority order):
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
1. Programmatic: pass a `PulseConfig` object when creating `Producer` or `Consumer`.
|
|
36
|
+
2. Environment variables: `PULSE_GRPC_URL`, `PULSE_EVENT_TYPES` (comma-separated), `PULSE_CONSUMER_NAME`.
|
|
37
|
+
3. File: a `pulse.yml` or `pulse.yaml` file placed at the process working directory or in `~/.pulse/pulse.yml`.
|
|
38
|
+
|
|
39
|
+
The SDK exposes two helpers:
|
|
40
|
+
|
|
41
|
+
- `loadConfig(configPath?: string): PulseConfig` — reads `pulse.yml` / env and returns a merged config object.
|
|
42
|
+
- `initFromConfig(cfg: PulseConfig): Promise<void>` — calls the broker `CreateTopic` RPC for every topic declared in `cfg.topics`. This lets the SDK create any required topics automatically at startup (useful for tests or first-run).
|
|
43
|
+
|
|
44
|
+
Example `pulse.yml`:
|
|
45
|
+
|
|
46
|
+
```yaml
|
|
47
|
+
grpcUrl: localhost:5556
|
|
48
|
+
eventTypes:
|
|
49
|
+
- events
|
|
50
|
+
- transactions
|
|
51
|
+
consumerName: my-consumer
|
|
52
|
+
topics:
|
|
53
|
+
- name: events
|
|
54
|
+
fifo: false
|
|
55
|
+
- name: transactions
|
|
56
|
+
fifo: true
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Usage example (auto-create topics then start):
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { loadConfig, initFromConfig, Producer, Consumer } from 'pulse-sdk';
|
|
63
|
+
|
|
64
|
+
async function main() {
|
|
65
|
+
const cfg = loadConfig(); // reads pulse.yml or environment
|
|
66
|
+
await initFromConfig(cfg); // create topics listed in cfg.topics (no-op if none)
|
|
67
|
+
|
|
68
|
+
const producer = new Producer(cfg);
|
|
69
|
+
await producer.send('events', { type: 'user.created', id: 1 });
|
|
70
|
+
|
|
71
|
+
const consumer = new Consumer(cfg);
|
|
72
|
+
consumer.on('events', (msg) => console.log('received', msg.payload));
|
|
73
|
+
await consumer.start('events', cfg.consumerName || 'default');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main().catch(console.error);
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Notes:
|
|
80
|
+
- `initFromConfig` calls the gRPC `CreateTopic` RPC. If the broker responds with an error for a topic that already exists, the SDK logs a warning and continues.
|
|
81
|
+
- Ensure the broker is running and reachable at `cfg.grpcUrl` before calling `initFromConfig`.
|
|
82
|
+
- The SDK bundles `pulse.proto` inside the package so consumers do not need to copy proto files into their projects.
|
|
36
83
|
|
|
37
84
|
## Quick Start
|
|
38
85
|
|
|
@@ -102,6 +149,25 @@ The tests in `tests/` start a lightweight gRPC test server automatically; run th
|
|
|
102
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.
|
|
103
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.
|
|
104
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
|
+
|
|
105
171
|
## Contributing
|
|
106
172
|
|
|
107
173
|
Please open a PR with tests. The existing tests validate basic Producer/Consumer behaviour.
|
package/dist/config.d.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
+
export interface TopicConfig {
|
|
2
|
+
name: string;
|
|
3
|
+
fifo?: boolean;
|
|
4
|
+
retention_bytes?: number;
|
|
5
|
+
retention_time?: number;
|
|
6
|
+
}
|
|
1
7
|
export interface PulseConfig {
|
|
2
8
|
grpcUrl: string;
|
|
3
9
|
eventTypes: string[];
|
|
10
|
+
consumerName?: string;
|
|
11
|
+
grouped?: boolean;
|
|
12
|
+
topics?: TopicConfig[];
|
|
4
13
|
}
|
|
5
|
-
export declare function loadConfig(
|
|
14
|
+
export declare function loadConfig(configPath?: string): PulseConfig;
|
|
15
|
+
export declare function initFromConfig(cfg: PulseConfig): Promise<void>;
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,76 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.loadConfig = loadConfig;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
exports.initFromConfig = initFromConfig;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const os_1 = __importDefault(require("os"));
|
|
11
|
+
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
12
|
+
const client_1 = require("./proto/client");
|
|
13
|
+
const DEFAULTS = {
|
|
14
|
+
grpcUrl: 'localhost:50052',
|
|
15
|
+
eventTypes: ['events'],
|
|
16
|
+
grouped: true,
|
|
17
|
+
};
|
|
18
|
+
function loadConfig(configPath) {
|
|
19
|
+
const candidates = [
|
|
20
|
+
configPath,
|
|
21
|
+
path_1.default.resolve(process.cwd(), 'pulse.yml'),
|
|
22
|
+
path_1.default.resolve(process.cwd(), 'pulse.yaml'),
|
|
23
|
+
path_1.default.join(os_1.default.homedir(), '.pulse', 'pulse.yml'),
|
|
24
|
+
].filter(Boolean);
|
|
25
|
+
let fileCfg = {};
|
|
26
|
+
for (const p of candidates) {
|
|
27
|
+
if (p && fs_1.default.existsSync(p)) {
|
|
28
|
+
const raw = fs_1.default.readFileSync(p, 'utf8');
|
|
29
|
+
const parsed = js_yaml_1.default.load(raw);
|
|
30
|
+
fileCfg = parsed || {};
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const envCfg = {};
|
|
35
|
+
if (process.env.PULSE_GRPC_URL)
|
|
36
|
+
envCfg.grpcUrl = process.env.PULSE_GRPC_URL;
|
|
37
|
+
if (process.env.PULSE_EVENT_TYPES)
|
|
38
|
+
envCfg.eventTypes = process.env.PULSE_EVENT_TYPES.split(',');
|
|
39
|
+
if (process.env.PULSE_CONSUMER_NAME)
|
|
40
|
+
envCfg.consumerName = process.env.PULSE_CONSUMER_NAME;
|
|
41
|
+
if (process.env.PULSE_GROUPED)
|
|
42
|
+
envCfg.grouped = process.env.PULSE_GROUPED === 'true';
|
|
43
|
+
const merged = Object.assign({}, DEFAULTS, fileCfg, envCfg);
|
|
44
|
+
// Ensure eventTypes array exists
|
|
45
|
+
if (!merged.eventTypes)
|
|
46
|
+
merged.eventTypes = DEFAULTS.eventTypes;
|
|
47
|
+
if (merged.grouped === undefined)
|
|
48
|
+
merged.grouped = true;
|
|
49
|
+
return merged;
|
|
50
|
+
}
|
|
51
|
+
// Initialize topics from config using gRPC CreateTopic RPC.
|
|
52
|
+
async function initFromConfig(cfg) {
|
|
53
|
+
if (!cfg.topics || cfg.topics.length === 0)
|
|
54
|
+
return;
|
|
55
|
+
const client = (0, client_1.createClient)(cfg.grpcUrl);
|
|
56
|
+
for (const t of cfg.topics) {
|
|
57
|
+
const req = {
|
|
58
|
+
topic: t.name || t['name'],
|
|
59
|
+
fifo: !!t.fifo,
|
|
60
|
+
retention_bytes: t.retention_bytes || 0,
|
|
61
|
+
retention_time: t.retention_time || 0,
|
|
62
|
+
};
|
|
63
|
+
await new Promise((resolve, reject) => {
|
|
64
|
+
client.CreateTopic(req, (err, res) => {
|
|
65
|
+
if (err) {
|
|
66
|
+
// If topic exists the server may return an error; log and continue
|
|
67
|
+
console.warn('CreateTopic error for', req.topic, err.message || err);
|
|
68
|
+
resolve();
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
resolve();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
7
76
|
}
|
package/dist/consumer.d.ts
CHANGED
|
@@ -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
|
|
19
|
-
|
|
20
|
-
stream
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
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 {};
|
package/dist/message.js
ADDED
|
@@ -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
|
+
"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,19 +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
|
+
"@types/js-yaml": "^4.0.5"
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
30
|
"@grpc/grpc-js": "^1.8.0",
|
|
30
31
|
"protobufjs": "^7.2.0",
|
|
31
32
|
"@grpc/proto-loader": "^0.7.0"
|
|
32
|
-
|
|
33
|
+
,"js-yaml": "^4.1.0"
|
|
34
|
+
}
|
|
35
|
+
,
|
|
33
36
|
"jest": {
|
|
34
37
|
"preset": "ts-jest",
|
|
35
38
|
"testEnvironment": "node",
|
|
36
|
-
"testMatch": [
|
|
37
|
-
|
|
38
|
-
]
|
|
39
|
+
"testMatch": ["**/tests/**/*.test.ts"],
|
|
40
|
+
"globalTeardown": "<rootDir>/tests/globalTeardown.js"
|
|
39
41
|
}
|
|
40
42
|
}
|