Skip to content

Commit 507e7b8

Browse files
authored
76 pub sub (#77)
1 parent 61841af commit 507e7b8

File tree

11 files changed

+1662
-337
lines changed

11 files changed

+1662
-337
lines changed

.circleci/config.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ version: 2.1
22
parameters:
33
node-version:
44
type: string
5-
default: "16.13.2"
5+
default: "18.13.0"
66
orbs:
77
node: circleci/node@5.0.0
88
slack: circleci/slack@4.5.3
@@ -72,7 +72,7 @@ commands:
7272
jobs:
7373
test:
7474
docker:
75-
- image: circleci/node:16-stretch
75+
- image: cimg/node:18.13.0
7676
steps:
7777
- checkout
7878
- node/install:
@@ -82,13 +82,13 @@ jobs:
8282
override-ci-command: npm install
8383
- run:
8484
name: Audit Dependencies
85-
command: npm run audit
85+
command: npm audit --production --audit-level=high
8686
- run:
8787
name: Running Mocha Tests
8888
command: npm test
8989
build:
9090
docker:
91-
- image: circleci/node:16-stretch
91+
- image: cimg/node:18.13.0
9292
user: root
9393
steps:
9494
- checkout
@@ -124,4 +124,4 @@ workflows:
124124
branches:
125125
ignore: /.*/
126126
tags:
127-
only: /^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?$/
127+
only: /^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?$/

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
## 2.8.0 (December 21, 2023)
2+
* Added new `Subscribe to PubSub` trigger
3+
14
## 2.7.3 (November 09, 2023)
25
* Fixed [issue](https://github.com/elasticio/salesforce-component-v2/issues/72) when real-time flows have authentication errors sometimes
36

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* [Get Updated Objects Polling](#get-updated-objects-polling)
1313
* [Query Trigger](#query-trigger)
1414
* [Subscribe to platform events (REALTIME FLOWS ONLY)](#subscribe-to-platform-events-realtime-flows-only)
15+
* [Subscribe to PubSub](#subscribe-to-pubsub)
1516
* [Deprecated triggers](#deprecated-triggers)
1617
* [Actions](#actions)
1718
* [Bulk Create/Update/Delete/Upsert](#bulk-createupdatedeleteupsert)
@@ -122,6 +123,30 @@ You can find more detail information in the [Platform Events Intro Documentation
122123
#### Limitations:
123124
At the moment this trigger can be used only for **"Realtime"** flows.
124125

126+
### Subscribe to PubSub
127+
This trigger will subscribe for any platform Event using [Pub/Sub API](https://developer.salesforce.com/docs/platform/pub-sub-api/overview).
128+
129+
#### Configuration Fields
130+
* **Event object name** - (dropdown, required): Input field where you should select the type of platform event to which you want to subscribe E.g. `My platform event`
131+
* **Pub/Sub API Endpoint** - (string, optional): You can set Pub/Sub API Endpoint manually or leave it blank for default: `api.pubsub.salesforce.com:7443`. Details about Pub/Sub API Endpoints can be found [here](https://developer.salesforce.com/docs/platform/pub-sub-api/guide/pub-sub-endpoints.html)
132+
* **Number of events per request** - (positive integer, optional, defaults to 10, max 100): Salesforce uses batches of events to deliver to the component, the bigger number may increase processing speed, but if the batch size is too big, you can get out of memory error. If there are fewer events ready than the batch size, they will be delivered anyway.
133+
* **Start from Replay Id** - (positive integer, optional): In the Salesforce platform events and change data capture events are retained in the event bus for 3 days and you can subscribe at any position in the stream by providing here Replay Id from the last event. This field is used only for the first execution, following executions will use the Replay Id from the latest event to get a new one.
134+
135+
#### Input Metadata
136+
137+
None.
138+
139+
#### Output Metadata
140+
141+
* **event** - (object, required): Store `replayId` of this message which can be used to retrieve records that were created after (using it as `Start from Replay Id` in configuration)
142+
* **payload** - (object, required): Dynamically generated content of the event
143+
144+
#### Limitations:
145+
* If you use **"Ordinary"** flow:
146+
* Make sure that you execute it at least once per 3 days - according to the [documentation](https://developer.salesforce.com/docs/platform/pub-sub-api/references/methods/subscribe-rpc.html#replaying-an-event-stream) Salesforce stores events for up to 3 days.
147+
* The component starts tracking changes after the first execution
148+
* To `Retrieve new sample from Salesforce v2` you need to trigger an event on Salesforce side or provide a sample manually
149+
125150
### Deprecated triggers
126151

127152
<details>

component.json

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"description": "Customer relationship management (CRM) software & cloud computing from the leader in CRM solutions for businesses large & small.",
44
"docsUrl": "https://github.com/elasticio/salesforce-component-v2",
55
"url": "http://www.salesforce.com/",
6-
"version": "2.7.3",
6+
"version": "2.8.0",
77
"authClientTypes": [
88
"oauth2"
99
],
@@ -253,6 +253,71 @@
253253
"prompt": "Please select a Event object"
254254
}
255255
}
256+
},
257+
"streamPlatformEventsPubSub": {
258+
"title": "Subscribe to PubSub",
259+
"order": 97,
260+
"help": {
261+
"description": "Can be used for subscription to the specified in the configuration Platform Event object",
262+
"link": "/components/salesforce/index.html#subscribe-to-pubsub"
263+
},
264+
"main": "./lib/triggers/streamPlatformEventsPubSub.js",
265+
"type": "polling",
266+
"fields": {
267+
"object": {
268+
"viewClass": "SelectView",
269+
"label": "Event object name",
270+
"required": true,
271+
"model": "objectTypes",
272+
"prompt": "Please select Event object"
273+
},
274+
"pubsubEndpoint": {
275+
"label": "Pub/Sub API Endpoint",
276+
"viewClass": "TextFieldView",
277+
"required": false,
278+
"placeholder": "api.pubsub.salesforce.com:7443",
279+
"help": {
280+
"description": "You can set Pub/Sub API Endpoint manually or leave it blank for default: \"api.pubsub.salesforce.com:7443\""
281+
}
282+
},
283+
"eventCountPerRequest": {
284+
"label": "Number of events per request",
285+
"viewClass": "TextFieldView",
286+
"required": false,
287+
"placeholder": "10",
288+
"help": {
289+
"description": "Salesforce uses batches of events to deliver to the component, the bigger number may increase processing speed, but if the batch size is too big, you can get out of memory error. If there are fewer events ready than the batch size, they will be delivered anyway. Positive integer, defaults to 10, max 100"
290+
}
291+
},
292+
"initialReplayId": {
293+
"label": "Start from Replay Id",
294+
"viewClass": "TextFieldView",
295+
"required": false,
296+
"placeholder": "31142963",
297+
"help": {
298+
"description": "In the Salesforce platform events and change data capture events are retained in the event bus for 3 days and you can subscribe at any position in the stream by providing here Replay Id from the last event. This field is used only for the first execution, following executions will use the Replay Id from the latest event to get a new one. Positive integer"
299+
}
300+
}
301+
},
302+
"metadata": {
303+
"out": {
304+
"type": "object",
305+
"properties": {
306+
"event": {
307+
"type": "object",
308+
"properties": {
309+
"replayId": {
310+
"type": "number"
311+
}
312+
}
313+
},
314+
"payload": {
315+
"type": "object",
316+
"properties": {}
317+
}
318+
}
319+
}
320+
}
256321
}
257322
},
258323
"actions": {
@@ -638,4 +703,4 @@
638703
}
639704
}
640705
}
641-
}
706+
}

lib/PubSubClient.js

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/* eslint-disable no-param-reassign, no-nested-ternary, no-undef */
2+
const grpc = require('@grpc/grpc-js');
3+
const protoLoader = require('@grpc/proto-loader');
4+
const certifi = require('certifi');
5+
const avro = require('avro-js');
6+
const { EventEmitter } = require('events');
7+
const fs = require('fs').promises;
8+
9+
const DEFAULT_PUBSUB_API_ENDPOINT = 'api.pubsub.salesforce.com:7443';
10+
const CUSTOM_LONG_AVRO_TYPE = avro.types.LongType.using({
11+
fromBuffer: (buf) => {
12+
const big = buf.readBigInt64LE();
13+
if (big < Number.MIN_SAFE_INTEGER || big > Number.MAX_SAFE_INTEGER) {
14+
return big;
15+
}
16+
return Number(BigInt.asIntN(64, big));
17+
},
18+
toBuffer: (n) => {
19+
const buf = Buffer.allocUnsafe(8);
20+
if (n instanceof BigInt) {
21+
buf.writeBigInt64LE(n);
22+
} else {
23+
buf.writeBigInt64LE(BigInt(n));
24+
}
25+
return buf;
26+
},
27+
fromJSON: BigInt,
28+
toJSON: Number,
29+
isValid: (n) => {
30+
const type = typeof n;
31+
return (type === 'number' && n % 1 === 0) || type === 'bigint';
32+
},
33+
compare: (n1, n2) => (n1 === n2 ? 0 : n1 < n2 ? -1 : 1),
34+
});
35+
36+
class PubSubClient {
37+
logger;
38+
39+
cfg;
40+
41+
topicName;
42+
43+
client;
44+
45+
schema;
46+
47+
lastKeepAlive;
48+
49+
subscription;
50+
51+
eventEmitter;
52+
53+
constructor(logger, cfg) {
54+
this.logger = logger;
55+
this.cfg = cfg;
56+
this.topicName = `/event/${this.cfg.object}`;
57+
}
58+
59+
async connect(accessToken, instanceUrl) {
60+
const rootCert = await fs.readFile(certifi);
61+
const tenantId = accessToken.split('!')[0];
62+
const packageDefinition = await protoLoader.load(`${__dirname}/helpers/pubsub_api.proto`);
63+
const grpcObj = grpc.loadPackageDefinition(packageDefinition);
64+
const sfdcPackage = grpcObj.eventbus.v1;
65+
const metaCallback = (_params, callback) => {
66+
const meta = new grpc.Metadata();
67+
meta.add('accesstoken', accessToken);
68+
meta.add('instanceurl', instanceUrl);
69+
meta.add('tenantid', tenantId);
70+
callback(null, meta);
71+
};
72+
const callCreds = grpc.credentials.createFromMetadataGenerator(metaCallback);
73+
const combCreds = grpc.credentials.combineChannelCredentials(grpc.credentials.createSsl(rootCert), callCreds);
74+
this.client = new sfdcPackage.PubSub(
75+
this.cfg.pubsubEndpoint || DEFAULT_PUBSUB_API_ENDPOINT,
76+
combCreds,
77+
);
78+
this.logger.info('Connected to Pub/Sub API endpoint');
79+
try {
80+
await this.loadSchema();
81+
this.logger.info('Schema loaded');
82+
} catch (err) {
83+
throw new Error(`Failed to load schema: ${err.message}`);
84+
}
85+
}
86+
87+
setLogger(logger) { this.logger = logger; }
88+
89+
async loadSchema() {
90+
if (this.schema) return;
91+
this.schema = await new Promise((resolve, reject) => {
92+
this.client.GetTopic({ topicName: this.topicName }, (topicError, response) => {
93+
if (topicError) {
94+
reject(topicError);
95+
} else {
96+
const { schemaId } = response;
97+
this.client.GetSchema({ schemaId }, (schemaError, res) => {
98+
if (schemaError) {
99+
reject(schemaError);
100+
} else {
101+
const schemaType = avro.parse(res.schemaJson, { registry: { long: CUSTOM_LONG_AVRO_TYPE } });
102+
resolve({
103+
id: schemaId,
104+
type: schemaType,
105+
});
106+
}
107+
});
108+
}
109+
});
110+
});
111+
}
112+
113+
flattenSinglePropertyObjects(theObject) {
114+
Object.entries(theObject).forEach(([key, value]) => {
115+
if (key !== 'ChangeEventHeader' && value && typeof value === 'object') {
116+
const subKeys = Object.keys(value);
117+
if (subKeys.length === 1) {
118+
const subValue = value[subKeys[0]];
119+
theObject[key] = subValue;
120+
if (subValue && typeof subValue === 'object') {
121+
this.flattenSinglePropertyObjects(theObject[key]);
122+
}
123+
}
124+
}
125+
});
126+
}
127+
128+
async subscribe(numRequested, fromReplayId) {
129+
const eventEmitter = new EventEmitter();
130+
this.subscription = this.client.Subscribe();
131+
const writeOptions = { topicName: this.topicName, numRequested };
132+
133+
if (fromReplayId) {
134+
const buf = Buffer.allocUnsafe(8);
135+
buf.writeBigUInt64BE(BigInt(fromReplayId), 0);
136+
writeOptions.replayPreset = 2;
137+
writeOptions.replayId = buf;
138+
}
139+
this.logger.info(`Requesting first ${numRequested} records`);
140+
this.subscription.write(writeOptions);
141+
142+
this.subscription.on('data', (data) => {
143+
const latestReplayId = Number(data.latestReplayId.readBigUInt64BE());
144+
if (data.events) {
145+
for (const event of data.events) {
146+
const replayId = Number(event.replayId.readBigUInt64BE());
147+
const payload = this.schema.type.fromBuffer(event.event.payload);
148+
this.flattenSinglePropertyObjects(payload);
149+
eventEmitter.emit('data', { event: { replayId }, payload });
150+
}
151+
if (!data.pendingNumRequested) {
152+
this.logger.info(`requesting ${numRequested} more records`);
153+
this.subscription.write({ topicName: this.topicName, numRequested });
154+
}
155+
} else {
156+
this.logger.debug(`Received keepalive message. Latest replay ID: ${latestReplayId}`);
157+
data.latestReplayId = latestReplayId;
158+
this.lastKeepAlive = new Date().getTime();
159+
eventEmitter.emit('keepalive', data);
160+
}
161+
});
162+
this.subscription.on('end', () => {
163+
this.logger.warn('gRPC stream ended');
164+
eventEmitter.emit('end');
165+
});
166+
this.subscription.on('error', (error) => {
167+
this.logger.error(`gRPC stream error: ${JSON.stringify(error)}`);
168+
eventEmitter.emit('error', error);
169+
});
170+
this.subscription.on('status', (status) => {
171+
this.logger.warn(`gRPC stream status: ${JSON.stringify(status)}`);
172+
eventEmitter.emit('status', status);
173+
});
174+
this.eventEmitter = eventEmitter;
175+
176+
return this.eventEmitter;
177+
}
178+
179+
getLastKeepAlive() {
180+
return this.lastKeepAlive;
181+
}
182+
183+
close() {
184+
try {
185+
this.eventEmitter.removeAllListeners();
186+
this.eventEmitter = null;
187+
this.subscription.removeAllListeners();
188+
this.subscription = null;
189+
this.client.close();
190+
this.client = null;
191+
// eslint-disable-next-line no-empty
192+
} catch (_e) {}
193+
}
194+
}
195+
196+
module.exports.PubSubClient = PubSubClient;

0 commit comments

Comments
 (0)