├── .prettierignore ├── .husky └── pre-commit ├── .prettierrc ├── dist ├── utils │ ├── avroHelper.d.ts.map │ ├── toolingApiHelper.d.ts.map │ ├── auth.d.ts.map │ ├── avroHelper.d.ts │ ├── configurationLoader.d.ts.map │ ├── eventParseError.d.ts.map │ ├── eventParser.d.ts.map │ ├── toolingApiHelper.d.ts │ ├── types.d.ts.map │ ├── eventParser.d.ts │ ├── configurationLoader.d.ts │ ├── auth.d.ts │ ├── eventParseError.d.ts │ └── types.d.ts ├── client.d.ts.map ├── client.d.ts └── pubsub_api-07e1f84a.proto ├── spec ├── support │ └── jasmine.json ├── helper │ ├── asyncUtilities.js │ ├── clientUtilities.js │ ├── reporter.js │ ├── simpleFileLogger.js │ └── sfUtilities.js └── integration │ ├── clientFailures.spec.js │ └── client.spec.js ├── tsconfig.json ├── tsup.config.ts ├── .gitignore ├── eslint.config.js ├── src └── utils │ ├── avroHelper.js │ ├── toolingApiHelper.js │ ├── eventParseError.js │ ├── types.js │ ├── configurationLoader.js │ ├── auth.js │ └── eventParser.js ├── package.json ├── .github └── workflows │ ├── ci.yml │ └── ci-pr.yml ├── LICENSE ├── pubsub_api.proto ├── v4-documentation.md └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run precommit -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "singleQuote": true, 4 | "tabWidth": 4 5 | } 6 | -------------------------------------------------------------------------------- /dist/utils/avroHelper.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"avroHelper.d.ts","sourceRoot":"","sources":["../../src/utils/avroHelper.js"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,qCA0BG"} -------------------------------------------------------------------------------- /spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec", 3 | "spec_files": ["**/*.spec.js"], 4 | "env": { 5 | "stopSpecOnExpectationFailure": true, 6 | "random": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /dist/utils/toolingApiHelper.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"toolingApiHelper.d.ts","sourceRoot":"","sources":["../../src/utils/toolingApiHelper.js"],"names":[],"mappings":"AAKA;;;;;;GAMG;AACH,oDALW,MAAM,eACN,MAAM,wBACN,MAAM,gBAwChB"} -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/*.js"], 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "outDir": "dist", 8 | "declarationMap": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /dist/utils/auth.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/utils/auth.js"],"names":[],"mappings":"AAKA;;;;;;GAMG;AAEH;IAaI;;;;OAIG;IACH,oBAHW,aAAa,UACb,MAAM,EAKhB;IAED;;;OAGG;IACH,gBAFa,kBAAkB,CAoB9B;;CAyGJ;;iBA5Ja,MAAM;iBACN,MAAM;;;;qBACN,MAAM;;;;eACN,MAAM"} -------------------------------------------------------------------------------- /dist/utils/avroHelper.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom Long Avro type used for deserializing large numbers with BitInt. 3 | * This fixes a deserialization bug with Avro not supporting large values. 4 | * @private 5 | */ 6 | export const CustomLongAvroType: any; 7 | //# sourceMappingURL=avroHelper.d.ts.map -------------------------------------------------------------------------------- /dist/utils/configurationLoader.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"configurationLoader.d.ts","sourceRoot":"","sources":["../../src/utils/configurationLoader.js"],"names":[],"mappings":"AAKA;IACI;;;OAGG;IACH,oBAHW,aAAa,GACX,aAAa,CA+CzB;IAED;;;;;;;OAOG;IACH,8CAJW,aAAa,OACb,MAAM,gBACN,OAAO,QAwCjB;IAED;;;OAGG;IACH,kDAHW,aAAa,GACX,aAAa,CAoCzB;IAED,+EAQC;CACJ"} -------------------------------------------------------------------------------- /dist/utils/eventParseError.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"eventParseError.d.ts","sourceRoot":"","sources":["../../src/utils/eventParseError.js"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH;IA+BI;;;;;;;;;OASG;IACH,wBAMC;IA9CD;;;;OAIG;IACH,cAHU,KAAK,CAGT;IAEN;;;;;OAKG;IACH,iBAHU,MAAM,CAGP;IAET;;;;OAIG;IACH,kBAAM;IAEN;;;;;OAKG;IACH,uBAHU,MAAM,CAGD;CAmBlB"} -------------------------------------------------------------------------------- /dist/utils/eventParser.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"eventParser.d.ts","sourceRoot":"","sources":["../../src/utils/eventParser.js"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,mCALW,GAAC,SACD,GAAC,GACC,GAAC,CA2Cb;AA0GD;;;;;GAKG;AACH,gDAJW,MAAM,GACJ,MAAM,CAKlB;AAED;;;;;GAKG;AACH,yCAJW,MAAM,GACJ,MAAM,CAOlB;AAED;;;;;GAKG;AACH,oCAJW,GAAG,GACD,MAAM,CAQlB"} -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | import metaUrlPlugin from '@chialab/esbuild-plugin-meta-url'; 3 | 4 | export default defineConfig({ 5 | entry: ['src/client.js'], 6 | format: ['cjs', 'esm'], 7 | target: 'node18', 8 | clean: true, 9 | esbuildPlugins: [ 10 | metaUrlPlugin() 11 | ] 12 | }); 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Log files 2 | logs 3 | *.log 4 | *-debug.log 5 | *-error.log 6 | 7 | # Secret configuration 8 | .env 9 | keys 10 | 11 | # Tooling files 12 | node_modules 13 | jsconfig.json 14 | .vscode 15 | .idea 16 | 17 | # MacOS system files 18 | .DS_Store 19 | 20 | # Windows system files 21 | Thumbs.db 22 | ehthumbs.db 23 | [Dd]esktop.ini 24 | $RECYCLE.BIN/ 25 | 26 | # Sample code 27 | sample* -------------------------------------------------------------------------------- /dist/utils/toolingApiHelper.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Calls the Tooling API to retrieve a managed subscription from its ID or name 3 | * @param {string} instanceUrl 4 | * @param {string} accessToken 5 | * @param {string} subscriptionIdOrName 6 | * @returns topic name 7 | */ 8 | export function getManagedSubscription(instanceUrl: string, accessToken: string, subscriptionIdOrName: string): Promise; 9 | //# sourceMappingURL=toolingApiHelper.d.ts.map -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import jasmine from 'eslint-plugin-jasmine'; 3 | import globals from 'globals'; 4 | 5 | export default [ 6 | js.configs.recommended, 7 | jasmine.configs.recommended, 8 | { 9 | languageOptions: { 10 | ecmaVersion: 13, 11 | globals: { 12 | ...globals.node, 13 | ...globals.jasmine 14 | } 15 | }, 16 | plugins: { 17 | jasmine 18 | }, 19 | rules: { 20 | 'jasmine/new-line-before-expect': 'off' 21 | } 22 | } 23 | ]; 24 | -------------------------------------------------------------------------------- /dist/utils/types.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/utils/types.js"],"names":[],"mappings":";;;oCAEU,MAAM;;;;;;;;;;;;kCAaN,MAAM;;;;;;;;;;;;;uBAWN,MAAM;;;;;;;;;;0CAWN,MAAM;;;;;;cASF,MAAM;oBACN,MAAM;;;QAKN,MAAM;;;;;;+CAMT,gBAAgB,gBAChB,qBAAqB,iBAEnB,IAAI;;UAKH,gBAAgB;;uBAEhB,iBAAiB;;;eAKjB,OAAO;eACP,MAAM;oBACN,MAAM;sBACN,MAAM;yBACN,MAAM;wBACN,MAAM;kBACN,MAAM;4BACN,OAAO;;qCAKV,iBAAiB,gBACjB,mBAAmB,iBAEjB,IAAI;;UAKH,iBAAiB;;qBAEjB,eAAe;;;eAKf,MAAM;kBACN,MAAM;;;QAKN,MAAM;;;;;;;cAMN,QAAQ;;;;qBACR,MAAM;;;;eACN,MAAM;;;;eACN,MAAM;;;;eACN,MAAM;;;;gBACN,MAAM;;;;eACN,MAAM;;;;mBACN,MAAM;;;;iBACN,MAAM;;;;kBACN,MAAM;;;;kBACN,MAAM;;;;qBACN,MAAM;;;;4BACN,OAAO;;;;;;;;;eAaP,MAAM;kBACN,MAAM;qBACN,MAAM;mBACN,MAAM;eACN,MAAM"} -------------------------------------------------------------------------------- /spec/helper/asyncUtilities.js: -------------------------------------------------------------------------------- 1 | export async function sleep(duration) { 2 | return new Promise((resolve) => setTimeout(() => resolve(), duration)); 3 | } 4 | 5 | export async function waitFor(timeoutDuration, checkFunction) { 6 | return new Promise((resolve, reject) => { 7 | let checkInterval; 8 | const waitTimeout = setTimeout(() => { 9 | clearInterval(checkInterval); 10 | reject(`waitFor timed out after ${timeoutDuration} ms`); 11 | }, timeoutDuration); 12 | checkInterval = setInterval(() => { 13 | if (checkFunction()) { 14 | clearTimeout(waitTimeout); 15 | clearInterval(checkInterval); 16 | resolve(); 17 | } 18 | }, 100); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /spec/helper/clientUtilities.js: -------------------------------------------------------------------------------- 1 | import PubSubApiClient from '../../src/client.js'; 2 | import { AuthType } from '../../src/utils/types.js'; 3 | 4 | /** 5 | * Prepares a connected PubSub API client 6 | * @param {Logger} logger 7 | * @returns a connected PubSub API client 8 | */ 9 | export async function getConnectedPubSubApiClient(logger) { 10 | const client = new PubSubApiClient( 11 | { 12 | authType: AuthType.USERNAME_PASSWORD, 13 | loginUrl: process.env.SALESFORCE_LOGIN_URL, 14 | username: process.env.SALESFORCE_USERNAME, 15 | password: process.env.SALESFORCE_PASSWORD, 16 | userToken: process.env.SALESFORCE_TOKEN 17 | }, 18 | logger 19 | ); 20 | await client.connect(); 21 | return client; 22 | } 23 | -------------------------------------------------------------------------------- /spec/helper/reporter.js: -------------------------------------------------------------------------------- 1 | let isReporterInjected = false; 2 | 3 | export default function injectJasmineReporter(logger) { 4 | // Only inject report once 5 | if (isReporterInjected) { 6 | return; 7 | } 8 | isReporterInjected = true; 9 | 10 | // Build and inject reporter 11 | const customReporter = { 12 | specStarted: (result) => { 13 | logger.info('----------'); 14 | logger.info(`START TEST: ${result.description}`); 15 | logger.info('----------'); 16 | }, 17 | specDone: (result) => { 18 | logger.info('--------'); 19 | logger.info(`END TEST: [${result.status}] ${result.description}`); 20 | logger.info('--------'); 21 | } 22 | }; 23 | const env = jasmine.getEnv(); 24 | env.addReporter(customReporter); 25 | } 26 | -------------------------------------------------------------------------------- /dist/utils/eventParser.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses the Avro encoded data of an event agains a schema 3 | * @param {*} schema Avro schema 4 | * @param {*} event Avro encoded data of the event 5 | * @returns {*} parsed event data 6 | * @protected 7 | */ 8 | export function parseEvent(schema: any, event: any): any; 9 | /** 10 | * Decodes the value of a replay ID from a buffer 11 | * @param {Buffer} encodedReplayId 12 | * @returns {number} decoded replay ID 13 | * @protected 14 | */ 15 | export function decodeReplayId(encodedReplayId: Buffer): number; 16 | /** 17 | * Encodes the value of a replay ID 18 | * @param {number} replayId 19 | * @returns {Buffer} encoded replay ID 20 | * @protected 21 | */ 22 | export function encodeReplayId(replayId: number): Buffer; 23 | /** 24 | * Safely serializes an event into a JSON string 25 | * @param {any} event the event object 26 | * @returns {string} a string holding the JSON respresentation of the event 27 | * @protected 28 | */ 29 | export function toJsonString(event: any): string; 30 | //# sourceMappingURL=eventParser.d.ts.map -------------------------------------------------------------------------------- /dist/utils/configurationLoader.d.ts: -------------------------------------------------------------------------------- 1 | export default class ConfigurationLoader { 2 | /** 3 | * @param {Configuration} config the client configuration 4 | * @returns {Configuration} the sanitized client configuration 5 | */ 6 | static load(config: Configuration): Configuration; 7 | /** 8 | * Loads a boolean value from a config key. 9 | * Falls back to the provided default value if no value is specified. 10 | * Errors out if the config value can't be converted to a boolean value. 11 | * @param {Configuration} config 12 | * @param {string} key 13 | * @param {boolean} defaultValue 14 | */ 15 | static "__#private@#loadBooleanValue"(config: Configuration, key: string, defaultValue: boolean): void; 16 | /** 17 | * @param {Configuration} config the client configuration 18 | * @returns {Configuration} sanitized configuration 19 | */ 20 | static "__#private@#loadUserSuppliedAuth"(config: Configuration): Configuration; 21 | static "__#private@#checkMandatoryVariables"(config: any, varNames: any): void; 22 | } 23 | //# sourceMappingURL=configurationLoader.d.ts.map -------------------------------------------------------------------------------- /src/utils/avroHelper.js: -------------------------------------------------------------------------------- 1 | import avro from 'avro-js'; 2 | 3 | /** 4 | * Custom Long Avro type used for deserializing large numbers with BitInt. 5 | * This fixes a deserialization bug with Avro not supporting large values. 6 | * @private 7 | */ 8 | export const CustomLongAvroType = avro.types.LongType.using({ 9 | fromBuffer: (buf) => { 10 | const big = buf.readBigInt64LE(); 11 | if (big < Number.MIN_SAFE_INTEGER || big > Number.MAX_SAFE_INTEGER) { 12 | return big; 13 | } 14 | return Number(BigInt.asIntN(64, big)); 15 | }, 16 | toBuffer: (n) => { 17 | const buf = Buffer.allocUnsafe(8); 18 | if (n instanceof BigInt) { 19 | buf.writeBigInt64LE(n); 20 | } else { 21 | buf.writeBigInt64LE(BigInt(n)); 22 | } 23 | return buf; 24 | }, 25 | fromJSON: BigInt, 26 | toJSON: Number, 27 | isValid: (n) => { 28 | const type = typeof n; 29 | return (type === 'number' && n % 1 === 0) || type === 'bigint'; 30 | }, 31 | compare: (n1, n2) => { 32 | return n1 === n2 ? 0 : n1 < n2 ? -1 : 1; 33 | } 34 | }); 35 | -------------------------------------------------------------------------------- /spec/helper/simpleFileLogger.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | const LOG_LEVELS = ['debug', 'info', 'warn', 'error']; 4 | 5 | export default class SimpleFileLogger { 6 | #filePath; 7 | #level; 8 | 9 | constructor(filePath, levelString = 'info') { 10 | this.#filePath = filePath; 11 | const lcLevelString = levelString.toLowerCase(); 12 | const level = LOG_LEVELS.indexOf(lcLevelString); 13 | this.#level = level === -1 ? 1 : level; 14 | } 15 | 16 | clear() { 17 | fs.rmSync(this.#filePath, { force: true }); 18 | } 19 | 20 | debug(...data) { 21 | if (this.#level <= 0) this.log('DEBUG', data); 22 | } 23 | 24 | info(...data) { 25 | if (this.#level <= 1) this.log('INFO', data); 26 | } 27 | 28 | warn(...data) { 29 | if (this.#level <= 2) this.log('WARN', data); 30 | } 31 | 32 | error(...data) { 33 | if (this.#level <= 3) this.log('ERROR', data); 34 | } 35 | 36 | log(level, data) { 37 | const ts = new Date().toISOString(); 38 | fs.appendFileSync( 39 | this.#filePath, 40 | `${ts}\t${level}\t${data.join('')}\n` 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /dist/client.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.js"],"names":[],"mappings":"AAiDA;;GAEG;AACH;IA2CI;;;;OAIG;IACH,oBAHW,aAAa,WACb,MAAM,EAiBhB;IAED;;;;OAIG;IACH,WAFa,OAAO,CAAC,IAAI,CAAC,CAqEzB;IAED;;;OAGG;IACH,wBAFa,OAAO,CAAC,iBAAiB,CAAC,CAItC;IAED;;;;;OAKG;IACH,sCAJW,MAAM,qBACN,iBAAiB,iBACjB,MAAM,GAAG,IAAI,QAevB;IAED;;;;;;OAMG;IACH,iCALW,MAAM,qBACN,iBAAiB,gBACjB,MAAM,GAAG,IAAI,YACb,MAAM,QAiBhB;IAED;;;;;OAKG;IACH,qBAJW,MAAM,qBACN,iBAAiB,iBACjB,MAAM,GAAG,IAAI,QAUvB;IAkFD;;;;;;OAMG;IACH,uDALW,MAAM,qBACN,iBAAiB,iBACjB,MAAM,GAAG,IAAI,iBAwGvB;IAED;;;;OAIG;IACH,mCAHW,MAAM,gBACN,MAAM,QAwBhB;IAED;;;;OAIG;IACH,+CAHW,MAAM,gBACN,MAAM,QAyBhB;IAED;;;;;OAKG;IACH,+BAJW,MAAM,YACN,MAAM,GACJ,MAAM,CA4BlB;IAED;;;;;;OAMG;IACH,mBALW,MAAM,iCAEN,MAAM,GACJ,OAAO,CAAC,aAAa,CAAC,CA2ClC;IAED;;;;;OAKG;IACH,wBAJW,MAAM,UACN,aAAa,EAAE,mBACf,eAAe,iBAgHzB;IAED;;OAEG;IACH,cAiBC;;CAyQJ;4BAv8BY,OAAO,kBAAkB,EAAE,aAAa;qBACxC,OAAO,kBAAkB,EAAE,MAAM;4BACjC,OAAO,kBAAkB,EAAE,aAAa;8BACxC,OAAO,kBAAkB,EAAE,eAAe;4BAC1C,OAAO,kBAAkB,EAAE,aAAa;gCACxC,OAAO,kBAAkB,EAAE,iBAAiB;4BAC5C,OAAO,kBAAkB,EAAE,aAAa;qBACxC,OAAO,kBAAkB,EAAE,MAAM;gCACjC,OAAO,kBAAkB,EAAE,iBAAiB;+BAC5C,OAAO,kBAAkB,EAAE,gBAAgB;2BAC3C,OAAO,kBAAkB,EAAE,YAAY;+BACvC,OAAO,kBAAkB,EAAE,gBAAgB;kCAhCtB,eAAe"} -------------------------------------------------------------------------------- /dist/utils/auth.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} ConnectionMetadata 3 | * @property {string} accessToken 4 | * @property {string} instanceUrl 5 | * @property {string} [organizationId] Optional organization ID. Can be omitted when working with user-supplied authentication. 6 | * @property {string} [username] Optional username. Omitted when working with user-supplied authentication. 7 | */ 8 | export default class SalesforceAuth { 9 | /** 10 | * Builds a new Pub/Sub API client 11 | * @param {Configuration} config the client configuration 12 | * @param {Logger} logger a logger 13 | */ 14 | constructor(config: Configuration, logger: Logger); 15 | /** 16 | * Authenticates with the auth mode specified in configuration 17 | * @returns {ConnectionMetadata} 18 | */ 19 | authenticate(): ConnectionMetadata; 20 | #private; 21 | } 22 | export type ConnectionMetadata = { 23 | accessToken: string; 24 | instanceUrl: string; 25 | /** 26 | * Optional organization ID. Can be omitted when working with user-supplied authentication. 27 | */ 28 | organizationId?: string; 29 | /** 30 | * Optional username. Omitted when working with user-supplied authentication. 31 | */ 32 | username?: string; 33 | }; 34 | //# sourceMappingURL=auth.d.ts.map -------------------------------------------------------------------------------- /spec/helper/sfUtilities.js: -------------------------------------------------------------------------------- 1 | import jsforce from 'jsforce'; 2 | 3 | let sfConnection; 4 | 5 | export async function getSalesforceConnection() { 6 | if (!sfConnection) { 7 | sfConnection = new jsforce.Connection({ 8 | loginUrl: process.env.SALESFORCE_LOGIN_URL 9 | }); 10 | await sfConnection.login( 11 | process.env.SALESFORCE_USERNAME, 12 | process.env.SALESFORCE_TOKEN 13 | ? process.env.SALESFORCE_PASSWORD + process.env.SALESFORCE_TOKEN 14 | : process.env.SALESFORCE_PASSWORD 15 | ); 16 | } 17 | return sfConnection; 18 | } 19 | 20 | export async function getSampleAccount() { 21 | const res = await sfConnection.query( 22 | `SELECT Id, Name, BillingCity FROM Account WHERE Name='Sample Account'` 23 | ); 24 | let sampleAccount; 25 | if (res.totalSize === 0) { 26 | sampleAccount = { Name: 'Sample Account', BillingCity: 'SFO' }; 27 | const ret = await sfConnection.sobject('Account').create(sampleAccount); 28 | sampleAccount.Id = ret.id; 29 | } else { 30 | sampleAccount = res.records[0]; 31 | } 32 | return sampleAccount; 33 | } 34 | 35 | export async function updateSampleAccount(updatedAccount) { 36 | sfConnection.sobject('Account').update(updatedAccount, (err, ret) => { 37 | if (err || !ret.success) { 38 | throw new Error('Failed to update sample account'); 39 | } 40 | console.log('Record updated'); 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /dist/utils/eventParseError.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Holds the information related to an event parsing error. 3 | * This class attempts to extract the event replay ID from the event that caused the error. 4 | * @alias EventParseError 5 | * @global 6 | */ 7 | export default class EventParseError extends Error { 8 | /** 9 | * Builds a new ParseError error. 10 | * @param {string} message The error message. 11 | * @param {Error} cause The cause of the error. 12 | * @param {number} replayId The replay ID of the event at the origin of the error. 13 | * Could be undefined if we're not able to extract it from the event data. 14 | * @param {Object} event The un-parsed event data at the origin of the error. 15 | * @param {number} latestReplayId The latest replay ID that was received before the error. 16 | * @protected 17 | */ 18 | protected constructor(); 19 | /** 20 | * The cause of the error. 21 | * @type {Error} 22 | * @public 23 | */ 24 | public cause: Error; 25 | /** 26 | * The replay ID of the event at the origin of the error. 27 | * Could be undefined if we're not able to extract it from the event data. 28 | * @type {number} 29 | * @public 30 | */ 31 | public replayId: number; 32 | /** 33 | * The un-parsed event data at the origin of the error. 34 | * @type {Object} 35 | * @public 36 | */ 37 | public event: any; 38 | /** 39 | * The latest replay ID that was received before the error. 40 | * There could be more than one event between the replay ID and the event causing the error if the events were processed in batch. 41 | * @type {number} 42 | * @public 43 | */ 44 | public latestReplayId: number; 45 | } 46 | //# sourceMappingURL=eventParseError.d.ts.map -------------------------------------------------------------------------------- /src/utils/toolingApiHelper.js: -------------------------------------------------------------------------------- 1 | import jsforce from 'jsforce'; 2 | 3 | const API_VERSION = '62.0'; 4 | const MANAGED_SUBSCRIPTION_KEY_PREFIX = '18x'; 5 | 6 | /** 7 | * Calls the Tooling API to retrieve a managed subscription from its ID or name 8 | * @param {string} instanceUrl 9 | * @param {string} accessToken 10 | * @param {string} subscriptionIdOrName 11 | * @returns topic name 12 | */ 13 | export async function getManagedSubscription( 14 | instanceUrl, 15 | accessToken, 16 | subscriptionIdOrName 17 | ) { 18 | const conn = new jsforce.Connection({ instanceUrl, accessToken }); 19 | 20 | // Check for SOQL injection 21 | if (subscriptionIdOrName.indexOf("'") !== -1) { 22 | throw new Error( 23 | `Suspected SOQL injection in subscription ID or name string value: ${subscriptionIdOrName}` 24 | ); 25 | } 26 | // Guess input parameter type 27 | let filter; 28 | if ( 29 | (subscriptionIdOrName.length === 15 || 30 | subscriptionIdOrName.length === 18) && 31 | subscriptionIdOrName 32 | .toLowerCase() 33 | .startsWith(MANAGED_SUBSCRIPTION_KEY_PREFIX) 34 | ) { 35 | filter = `Id='${subscriptionIdOrName}'`; 36 | } else { 37 | filter = `DeveloperName='${subscriptionIdOrName}'`; 38 | } 39 | // Call Tooling API to retrieve topic name from 40 | const query = `SELECT Id, DeveloperName, Metadata FROM ManagedEventSubscription WHERE ${filter} LIMIT 1`; 41 | const res = await conn.request( 42 | `/services/data/v${API_VERSION}/tooling/query/?q=${encodeURIComponent(query)}` 43 | ); 44 | if (res.size === 0) { 45 | throw new Error( 46 | `Failed to retrieve managed event subscription with ${filter}` 47 | ); 48 | } 49 | return res.records[0]; 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/eventParseError.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Holds the information related to an event parsing error. 3 | * This class attempts to extract the event replay ID from the event that caused the error. 4 | * @alias EventParseError 5 | * @global 6 | */ 7 | export default class EventParseError extends Error { 8 | /** 9 | * The cause of the error. 10 | * @type {Error} 11 | * @public 12 | */ 13 | cause; 14 | 15 | /** 16 | * The replay ID of the event at the origin of the error. 17 | * Could be undefined if we're not able to extract it from the event data. 18 | * @type {number} 19 | * @public 20 | */ 21 | replayId; 22 | 23 | /** 24 | * The un-parsed event data at the origin of the error. 25 | * @type {Object} 26 | * @public 27 | */ 28 | event; 29 | 30 | /** 31 | * The latest replay ID that was received before the error. 32 | * There could be more than one event between the replay ID and the event causing the error if the events were processed in batch. 33 | * @type {number} 34 | * @public 35 | */ 36 | latestReplayId; 37 | 38 | /** 39 | * Builds a new ParseError error. 40 | * @param {string} message The error message. 41 | * @param {Error} cause The cause of the error. 42 | * @param {number} replayId The replay ID of the event at the origin of the error. 43 | * Could be undefined if we're not able to extract it from the event data. 44 | * @param {Object} event The un-parsed event data at the origin of the error. 45 | * @param {number} latestReplayId The latest replay ID that was received before the error. 46 | * @protected 47 | */ 48 | constructor(message, cause, replayId, event, latestReplayId) { 49 | super(message); 50 | this.cause = cause; 51 | this.replayId = replayId; 52 | this.event = event; 53 | this.latestReplayId = latestReplayId; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "salesforce-pubsub-api-client", 3 | "version": "5.5.1", 4 | "type": "module", 5 | "description": "A node client for the Salesforce Pub/Sub API", 6 | "author": "pozil", 7 | "license": "CC0-1.0", 8 | "homepage": "https://github.com/pozil/pub-sub-api-node-client", 9 | "main": "dist/client.js", 10 | "exports": { 11 | ".": { 12 | "types": "./dist/client.d.ts", 13 | "require": "./dist/client.cjs", 14 | "import": "./dist/client.js" 15 | } 16 | }, 17 | "scripts": { 18 | "build": "tsup && tsc", 19 | "test": "jasmine", 20 | "format": "prettier --write '**/*.{css,html,js,json,md,yaml,yml}'", 21 | "format:verify": "prettier --check '**/*.{css,html,js,json,md,yaml,yml}'", 22 | "lint": "eslint \"src/**\" \"spec/**\"", 23 | "prepare": "husky || true", 24 | "precommit": "lint-staged", 25 | "prepublishOnly": "npm run build" 26 | }, 27 | "dependencies": { 28 | "@grpc/grpc-js": "^1.14.1", 29 | "@grpc/proto-loader": "^0.8.0", 30 | "avro-js": "^1.12.1", 31 | "jsforce": "^3.10.8", 32 | "undici": "^7.16.0" 33 | }, 34 | "devDependencies": { 35 | "@chialab/esbuild-plugin-meta-url": "^0.19.0", 36 | "dotenv": "^17.2.3", 37 | "eslint": "^9.39.1", 38 | "eslint-plugin-jasmine": "^4.2.2", 39 | "husky": "^9.1.7", 40 | "jasmine": "^5.12.0", 41 | "lint-staged": "^16.2.7", 42 | "prettier": "^3.6.2", 43 | "tsup": "^8.5.1", 44 | "typescript": "^5.9.3" 45 | }, 46 | "lint-staged": { 47 | "**/*.{css,html,js,json,md,yaml,yml}": [ 48 | "prettier --write" 49 | ], 50 | "**/{src,spec}/**/*.js": [ 51 | "eslint" 52 | ] 53 | }, 54 | "repository": { 55 | "type": "git", 56 | "url": "git+https://github.com/pozil/pub-sub-api-node-client.git" 57 | }, 58 | "keywords": [ 59 | "salesforce", 60 | "pubsub", 61 | "api", 62 | "grpc" 63 | ], 64 | "files": [ 65 | "dist/*", 66 | "pubsub_api.proto" 67 | ], 68 | "volta": { 69 | "node": "22.17.0" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # Unique name for this workflow 2 | name: CI 3 | 4 | # Definition when the workflow should run 5 | on: 6 | workflow_dispatch: 7 | push: 8 | branches: 9 | - main 10 | 11 | # Jobs to be executed 12 | jobs: 13 | format-lint-test: 14 | runs-on: ubuntu-latest 15 | env: 16 | SALESFORCE_LOGIN_URL: ${{ secrets.SALESFORCE_LOGIN_URL }} 17 | SALESFORCE_USERNAME: ${{ secrets.SALESFORCE_USERNAME }} 18 | SALESFORCE_PASSWORD: ${{ secrets.SALESFORCE_PASSWORD }} 19 | SALESFORCE_TOKEN: ${{ secrets.SALESFORCE_TOKEN }} 20 | SALESFORCE_CLIENT_ID: ${{ secrets.SALESFORCE_CLIENT_ID }} 21 | SALESFORCE_CLIENT_SECRET: ${{ secrets.SALESFORCE_CLIENT_SECRET }} 22 | SALESFORCE_JWT_LOGIN_URL: ${{ secrets.SALESFORCE_JWT_LOGIN_URL }} 23 | SALESFORCE_JWT_CLIENT_ID: ${{ secrets.SALESFORCE_JWT_CLIENT_ID }} 24 | SALESFORCE_PRIVATE_KEY: ${{ secrets.SALESFORCE_PRIVATE_KEY }} 25 | 26 | steps: 27 | # Checkout the source code 28 | - name: 'Checkout source code' 29 | uses: actions/checkout@v4 30 | 31 | # Install Volta to enforce proper node and package manager versions 32 | - name: 'Install Volta' 33 | uses: volta-cli/action@v4 34 | 35 | # Cache node_modules to speed up the process 36 | - name: 'Restore node_modules cache' 37 | id: cache-npm 38 | uses: actions/cache@v4 39 | with: 40 | path: node_modules 41 | key: npm-${{ hashFiles('**/package-lock.json') }} 42 | restore-keys: | 43 | npm-${{ env.cache-name }}- 44 | npm- 45 | 46 | # Install npm dependencies 47 | - name: 'Install npm dependencies' 48 | if: steps.cache-npm.outputs.cache-hit != 'true' 49 | run: HUSKY=0 npm ci 50 | 51 | # Format 52 | - name: 'Format' 53 | run: npm run format:verify 54 | 55 | # Lint 56 | - name: 'Lint' 57 | run: npm run lint 58 | 59 | # Configure test env 60 | - name: 'Configure test environment' 61 | run: | 62 | touch .env 63 | echo SALESFORCE_LOGIN_URL=$SALESFORCE_LOGIN_URL >> .env 64 | echo SALESFORCE_USERNAME=$SALESFORCE_USERNAME >> .env 65 | echo SALESFORCE_PASSWORD=$SALESFORCE_PASSWORD >> .env 66 | echo SALESFORCE_TOKEN=$SALESFORCE_TOKEN >> .env 67 | echo SALESFORCE_CLIENT_ID=$SALESFORCE_CLIENT_ID >> .env 68 | echo SALESFORCE_CLIENT_SECRET=$SALESFORCE_CLIENT_SECRET >> .env 69 | echo SALESFORCE_PRIVATE_KEY_PATH=server.key >> .env 70 | echo SALESFORCE_JWT_LOGIN_URL=$SALESFORCE_JWT_LOGIN_URL >> .env 71 | echo SALESFORCE_JWT_CLIENT_ID=$SALESFORCE_JWT_CLIENT_ID >> .env 72 | echo "$SALESFORCE_PRIVATE_KEY" > server.key 73 | 74 | # Integration tests 75 | - name: 'Integration tests' 76 | run: npm run test 77 | 78 | # Housekeeping 79 | - name: 'Delete test environment configuration' 80 | if: always() 81 | run: rm -f .env server.key 82 | -------------------------------------------------------------------------------- /.github/workflows/ci-pr.yml: -------------------------------------------------------------------------------- 1 | # Unique name for this workflow 2 | name: CI on PR 3 | 4 | # Definition when the workflow should run 5 | on: 6 | workflow_dispatch: 7 | pull_request: 8 | types: [opened, edited, synchronize, reopened] 9 | 10 | # Jobs to be executed 11 | jobs: 12 | format-lint-test: 13 | runs-on: ubuntu-latest 14 | env: 15 | SALESFORCE_LOGIN_URL: ${{ secrets.SALESFORCE_LOGIN_URL }} 16 | SALESFORCE_USERNAME: ${{ secrets.SALESFORCE_USERNAME }} 17 | SALESFORCE_PASSWORD: ${{ secrets.SALESFORCE_PASSWORD }} 18 | SALESFORCE_TOKEN: ${{ secrets.SALESFORCE_TOKEN }} 19 | SALESFORCE_CLIENT_ID: ${{ secrets.SALESFORCE_CLIENT_ID }} 20 | SALESFORCE_CLIENT_SECRET: ${{ secrets.SALESFORCE_CLIENT_SECRET }} 21 | SALESFORCE_JWT_LOGIN_URL: ${{ secrets.SALESFORCE_JWT_LOGIN_URL }} 22 | SALESFORCE_JWT_CLIENT_ID: ${{ secrets.SALESFORCE_JWT_CLIENT_ID }} 23 | SALESFORCE_PRIVATE_KEY: ${{ secrets.SALESFORCE_PRIVATE_KEY }} 24 | 25 | steps: 26 | # Checkout the source code 27 | - name: 'Checkout source code' 28 | uses: actions/checkout@v4 29 | 30 | # Install Volta to enforce proper node and package manager versions 31 | - name: 'Install Volta' 32 | uses: volta-cli/action@v4 33 | 34 | # Cache node_modules to speed up the process 35 | - name: 'Restore node_modules cache' 36 | id: cache-npm 37 | uses: actions/cache@v4 38 | with: 39 | path: node_modules 40 | key: npm-${{ hashFiles('**/package-lock.json') }} 41 | restore-keys: | 42 | npm-${{ env.cache-name }}- 43 | npm- 44 | 45 | # Install npm dependencies 46 | - name: 'Install npm dependencies' 47 | if: steps.cache-npm.outputs.cache-hit != 'true' 48 | run: HUSKY=0 npm ci 49 | 50 | # Format 51 | - name: 'Format' 52 | run: npm run format:verify 53 | 54 | # Lint 55 | - name: 'Lint' 56 | run: npm run lint 57 | 58 | # Configure test env 59 | - name: 'Configure test environment' 60 | run: | 61 | touch .env 62 | echo SALESFORCE_LOGIN_URL=$SALESFORCE_LOGIN_URL >> .env 63 | echo SALESFORCE_USERNAME=$SALESFORCE_USERNAME >> .env 64 | echo SALESFORCE_PASSWORD=$SALESFORCE_PASSWORD >> .env 65 | echo SALESFORCE_TOKEN=$SALESFORCE_TOKEN >> .env 66 | echo SALESFORCE_CLIENT_ID=$SALESFORCE_CLIENT_ID >> .env 67 | echo SALESFORCE_CLIENT_SECRET=$SALESFORCE_CLIENT_SECRET >> .env 68 | echo SALESFORCE_PRIVATE_KEY_PATH=server.key >> .env 69 | echo SALESFORCE_JWT_LOGIN_URL=$SALESFORCE_JWT_LOGIN_URL >> .env 70 | echo SALESFORCE_JWT_CLIENT_ID=$SALESFORCE_JWT_CLIENT_ID >> .env 71 | echo "$SALESFORCE_PRIVATE_KEY" > server.key 72 | 73 | # Integration tests 74 | - name: 'Integration tests' 75 | run: npm run test 76 | 77 | # Housekeeping 78 | - name: 'Delete test environment configuration' 79 | if: always() 80 | run: rm -f .env server.key 81 | -------------------------------------------------------------------------------- /dist/utils/types.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Enum for subscribe callback type values 3 | */ 4 | export type SubscribeCallbackType = string; 5 | export namespace SubscribeCallbackType { 6 | let EVENT: string; 7 | let LAST_EVENT: string; 8 | let ERROR: string; 9 | let END: string; 10 | let GRPC_STATUS: string; 11 | let GRPC_KEEP_ALIVE: string; 12 | } 13 | /** 14 | * Enum for publish callback type values 15 | */ 16 | export type PublishCallbackType = string; 17 | export namespace PublishCallbackType { 18 | export let PUBLISH_RESPONSE: string; 19 | let ERROR_1: string; 20 | export { ERROR_1 as ERROR }; 21 | let GRPC_STATUS_1: string; 22 | export { GRPC_STATUS_1 as GRPC_STATUS }; 23 | let GRPC_KEEP_ALIVE_1: string; 24 | export { GRPC_KEEP_ALIVE_1 as GRPC_KEEP_ALIVE }; 25 | } 26 | /** 27 | * Enum for auth type values 28 | */ 29 | export type AuthType = string; 30 | export namespace AuthType { 31 | let USER_SUPPLIED: string; 32 | let USERNAME_PASSWORD: string; 33 | let OAUTH_CLIENT_CREDENTIALS: string; 34 | let OAUTH_JWT_BEARER: string; 35 | } 36 | /** 37 | * Enum for managed subscription state values 38 | */ 39 | export type EventSubscriptionAdminState = string; 40 | export namespace EventSubscriptionAdminState { 41 | let RUN: string; 42 | let STOP: string; 43 | } 44 | export type PublishResult = { 45 | replayId: number; 46 | correlationKey: string; 47 | }; 48 | export type Schema = { 49 | id: string; 50 | /** 51 | * Avro schema type 52 | */ 53 | type: any; 54 | }; 55 | export type SubscribeCallback = (subscription: SubscriptionInfo, callbackType: SubscribeCallbackType, data?: any) => void; 56 | export type Subscription = { 57 | info: SubscriptionInfo; 58 | grpcSubscription: any; 59 | subscribeCallback: SubscribeCallback; 60 | }; 61 | export type SubscriptionInfo = { 62 | isManaged: boolean; 63 | topicName: string; 64 | subscriptionId: string; 65 | subscriptionName: string; 66 | requestedEventCount: number; 67 | receivedEventCount: number; 68 | lastReplayId: number; 69 | isInfiniteEventRequest: boolean; 70 | }; 71 | export type PublishCallback = (info: PublishStreamInfo, callbackType: PublishCallbackType, data?: any) => void; 72 | export type PublishStream = { 73 | info: PublishStreamInfo; 74 | grpcPublishStream: any; 75 | publishCallback: PublishCallback; 76 | }; 77 | export type PublishStreamInfo = { 78 | topicName: string; 79 | lastReplayId: number; 80 | }; 81 | export type ProducerEvent = { 82 | id: string; 83 | payload: any; 84 | }; 85 | export type Configuration = { 86 | /** 87 | * Authentication type. One of `user-supplied`, `username-password`, `oauth-client-credentials` or `oauth-jwt-bearer`. 88 | */ 89 | authType: AuthType; 90 | /** 91 | * A custom Pub/Sub API endpoint. The default endpoint `api.pubsub.salesforce.com:7443` is used if none is supplied. 92 | */ 93 | pubSubEndpoint?: string; 94 | /** 95 | * Salesforce login host. One of `https://login.salesforce.com`, `https://test.salesforce.com` or your domain specific host. 96 | */ 97 | loginUrl?: string; 98 | /** 99 | * Salesforce username. 100 | */ 101 | username?: string; 102 | /** 103 | * Salesforce user password. 104 | */ 105 | password?: string; 106 | /** 107 | * Salesforce user security token. 108 | */ 109 | userToken?: string; 110 | /** 111 | * Connected app client ID. 112 | */ 113 | clientId?: string; 114 | /** 115 | * Connected app client secret. 116 | */ 117 | clientSecret?: string; 118 | /** 119 | * Private key content. 120 | */ 121 | privateKey?: string; 122 | /** 123 | * Salesforce access token. 124 | */ 125 | accessToken?: string; 126 | /** 127 | * Salesforce instance URL. 128 | */ 129 | instanceUrl?: string; 130 | /** 131 | * Optional organization ID. If you don't provide one, we'll attempt to parse it from the accessToken. 132 | */ 133 | organizationId?: string; 134 | /** 135 | * Optional flag used to accept self-signed SSL certificates for testing purposes when set to `false`. Default is `true` (client rejects self-signed SSL certificates). 136 | */ 137 | rejectUnauthorizedSsl?: boolean; 138 | }; 139 | export type Logger = { 140 | debug: Function; 141 | info: Function; 142 | error: Function; 143 | warn: Function; 144 | }; 145 | export type SubscribeRequest = { 146 | topicName: string; 147 | numRequested: number; 148 | subscriptionId?: string; 149 | replayPreset?: number; 150 | replayId?: number; 151 | }; 152 | //# sourceMappingURL=types.d.ts.map -------------------------------------------------------------------------------- /src/utils/types.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Enum for subscribe callback type values 3 | * @enum {string} 4 | */ 5 | export const SubscribeCallbackType = { 6 | EVENT: 'event', 7 | LAST_EVENT: 'lastEvent', 8 | ERROR: 'error', 9 | END: 'end', 10 | GRPC_STATUS: 'grpcStatus', 11 | GRPC_KEEP_ALIVE: 'grpcKeepAlive' 12 | }; 13 | 14 | /** 15 | * Enum for publish callback type values 16 | * @enum {string} 17 | */ 18 | export const PublishCallbackType = { 19 | PUBLISH_RESPONSE: 'publishResponse', 20 | ERROR: 'error', 21 | GRPC_STATUS: 'grpcStatus', 22 | GRPC_KEEP_ALIVE: 'grpcKeepAlive' 23 | }; 24 | 25 | /** 26 | * Enum for auth type values 27 | * @enum {string} 28 | */ 29 | export const AuthType = { 30 | USER_SUPPLIED: 'user-supplied', 31 | USERNAME_PASSWORD: 'username-password', 32 | OAUTH_CLIENT_CREDENTIALS: 'oauth-client-credentials', 33 | OAUTH_JWT_BEARER: 'oauth-jwt-bearer' 34 | }; 35 | 36 | /** 37 | * Enum for managed subscription state values 38 | * @enum {string} 39 | */ 40 | export const EventSubscriptionAdminState = { 41 | RUN: 'RUN', 42 | STOP: 'STOP' 43 | }; 44 | 45 | /** 46 | * @typedef {Object} PublishResult 47 | * @property {number} replayId 48 | * @property {string} correlationKey 49 | */ 50 | 51 | /** 52 | * @typedef {Object} Schema 53 | * @property {string} id 54 | * @property {Object} type Avro schema type 55 | */ 56 | 57 | /** 58 | * @callback SubscribeCallback 59 | * @param {SubscriptionInfo} subscription 60 | * @param {SubscribeCallbackType} callbackType 61 | * @param {Object} [data] 62 | * @returns {void} 63 | */ 64 | 65 | /** 66 | * @typedef {Object} Subscription 67 | * @property {SubscriptionInfo} info 68 | * @property {Object} grpcSubscription 69 | * @property {SubscribeCallback} subscribeCallback 70 | */ 71 | 72 | /** 73 | * @typedef {Object} SubscriptionInfo 74 | * @property {boolean} isManaged 75 | * @property {string} topicName 76 | * @property {string} subscriptionId 77 | * @property {string} subscriptionName 78 | * @property {number} requestedEventCount 79 | * @property {number} receivedEventCount 80 | * @property {number} lastReplayId 81 | * @property {boolean} isInfiniteEventRequest 82 | */ 83 | 84 | /** 85 | * @callback PublishCallback 86 | * @param {PublishStreamInfo} info 87 | * @param {PublishCallbackType} callbackType 88 | * @param {Object} [data] 89 | * @returns {void} 90 | */ 91 | 92 | /** 93 | * @typedef {Object} PublishStream 94 | * @property {PublishStreamInfo} info 95 | * @property {Object} grpcPublishStream 96 | * @property {PublishCallback} publishCallback 97 | */ 98 | 99 | /** 100 | * @typedef {Object} PublishStreamInfo 101 | * @property {string} topicName 102 | * @property {number} lastReplayId 103 | */ 104 | 105 | /** 106 | * @typedef {Object} ProducerEvent 107 | * @property {string} id 108 | * @property {Object} payload 109 | */ 110 | 111 | /** 112 | * @typedef {Object} Configuration 113 | * @property {AuthType} authType Authentication type. One of `user-supplied`, `username-password`, `oauth-client-credentials` or `oauth-jwt-bearer`. 114 | * @property {string} [pubSubEndpoint] A custom Pub/Sub API endpoint. The default endpoint `api.pubsub.salesforce.com:7443` is used if none is supplied. 115 | * @property {string} [loginUrl] Salesforce login host. One of `https://login.salesforce.com`, `https://test.salesforce.com` or your domain specific host. 116 | * @property {string} [username] Salesforce username. 117 | * @property {string} [password] Salesforce user password. 118 | * @property {string} [userToken] Salesforce user security token. 119 | * @property {string} [clientId] Connected app client ID. 120 | * @property {string} [clientSecret] Connected app client secret. 121 | * @property {string} [privateKey] Private key content. 122 | * @property {string} [accessToken] Salesforce access token. 123 | * @property {string} [instanceUrl] Salesforce instance URL. 124 | * @property {string} [organizationId] Optional organization ID. If you don't provide one, we'll attempt to parse it from the accessToken. 125 | * @property {boolean} [rejectUnauthorizedSsl] Optional flag used to accept self-signed SSL certificates for testing purposes when set to `false`. Default is `true` (client rejects self-signed SSL certificates). 126 | */ 127 | 128 | /** 129 | * @typedef {Object} Logger 130 | * @property {Function} debug 131 | * @property {Function} info 132 | * @property {Function} error 133 | * @property {Function} warn 134 | */ 135 | 136 | /** 137 | * @typedef {Object} SubscribeRequest 138 | * @property {string} topicName 139 | * @property {number} numRequested 140 | * @property {string} [subscriptionId] 141 | * @property {number} [replayPreset] 142 | * @property {number} [replayId] 143 | */ 144 | -------------------------------------------------------------------------------- /spec/integration/clientFailures.spec.js: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import PubSubApiClient from '../../src/client.js'; 3 | import { AuthType } from '../../src/utils/types.js'; 4 | import SimpleFileLogger from '../helper/simpleFileLogger.js'; 5 | import injectJasmineReporter from '../helper/reporter.js'; 6 | import { sleep, waitFor } from '../helper/asyncUtilities.js'; 7 | import { getConnectedPubSubApiClient } from '../helper/clientUtilities.js'; 8 | 9 | // Load config from .env file 10 | dotenv.config(); 11 | 12 | // Prepare logger 13 | let logger; 14 | if (process.env.TEST_LOGGER === 'simpleFileLogger') { 15 | logger = new SimpleFileLogger('test.log', 'debug'); 16 | logger.clear(); 17 | injectJasmineReporter(logger); 18 | } else { 19 | logger = console; 20 | } 21 | 22 | const PLATFORM_EVENT_TOPIC = '/event/Sample__e'; 23 | 24 | describe('Client failures', function () { 25 | var client; 26 | 27 | afterEach(async () => { 28 | if (client) { 29 | client.close(); 30 | await sleep(500); 31 | } 32 | }); 33 | 34 | it('fails to connect with invalid user supplied auth', async function () { 35 | let grpcStatusCode, errorCode; 36 | let isConnectionClosed = false; 37 | 38 | // Build PubSub client with invalid credentials 39 | client = new PubSubApiClient( 40 | { 41 | authType: AuthType.USER_SUPPLIED, 42 | accessToken: 'invalidToken', 43 | instanceUrl: 'https://pozil-dev-ed.my.salesforce.com', 44 | organizationId: '00D58000000arpq' 45 | }, 46 | logger 47 | ); 48 | await client.connect(); 49 | 50 | // Prepare callback & send subscribe request 51 | const callback = (subscription, callbackType, data) => { 52 | if (callbackType === 'error') { 53 | errorCode = data.code; 54 | } else if (callbackType === 'grpcStatus') { 55 | grpcStatusCode = data.code; 56 | } else if (callbackType === 'end') { 57 | isConnectionClosed = true; 58 | } 59 | }; 60 | client.subscribe(PLATFORM_EVENT_TOPIC, callback, 1); 61 | 62 | // Wait for subscribe to be effective and error to surface 63 | await waitFor(5000, () => errorCode !== undefined); 64 | 65 | // Check for gRPC auth error and closed connection 66 | expect(errorCode).toBe(16); 67 | expect(grpcStatusCode).toBe(16); 68 | expect(isConnectionClosed).toBeTrue(); 69 | }); 70 | 71 | it('fails to subscribe to an invalid topic name', async function () { 72 | let grpcStatusCode, errorCode; 73 | let isConnectionClosed = false; 74 | 75 | // Build PubSub client 76 | client = await getConnectedPubSubApiClient(logger); 77 | 78 | // Prepare callback & send subscribe request 79 | const callback = (subscription, callbackType, data) => { 80 | if (callbackType === 'error') { 81 | errorCode = data.code; 82 | } else if (callbackType === 'grpcStatus') { 83 | grpcStatusCode = data.code; 84 | } else if (callbackType === 'end') { 85 | isConnectionClosed = true; 86 | } 87 | }; 88 | client.subscribe('/event/INVALID', callback); 89 | 90 | // Wait for subscribe to be effective and error to surface 91 | await waitFor(5000, () => errorCode !== undefined); 92 | 93 | // Check for gRPC auth error or permission error and closed connection 94 | expect(errorCode === 5 || errorCode === 7).toBeTruthy(); 95 | expect(grpcStatusCode === 5 || grpcStatusCode === 7).toBeTruthy(); 96 | expect(isConnectionClosed).toBeTrue(); 97 | }); 98 | 99 | it('fails to subscribe to an invalid managed subscription name', async function () { 100 | // Build PubSub client 101 | client = await getConnectedPubSubApiClient(logger); 102 | 103 | // Send subscribe request 104 | try { 105 | await client.subscribeWithManagedSubscription('INVALID', () => {}); 106 | } catch (error) { 107 | expect(error.message).toMatch( 108 | 'Failed to retrieve managed event subscription' 109 | ); 110 | } finally { 111 | client.close(); 112 | } 113 | }); 114 | 115 | it('fails to subscribe to an managed subscription that is not running', async function () { 116 | // Build PubSub client 117 | client = await getConnectedPubSubApiClient(logger); 118 | 119 | // Send subscribe request 120 | try { 121 | await client.subscribeWithManagedSubscription( 122 | 'Managed_Inactive_Sample_PE', 123 | () => {} 124 | ); 125 | } catch (error) { 126 | expect(error.message).toMatch('subscription is in STOP state'); 127 | } finally { 128 | client.close(); 129 | } 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /src/utils/configurationLoader.js: -------------------------------------------------------------------------------- 1 | import { AuthType } from './types.js'; 2 | 3 | const DEFAULT_PUB_SUB_ENDPOINT = 'api.pubsub.salesforce.com:7443'; 4 | const DEFAULT_REJECT_UNAUTHORIZED_SSL = true; 5 | 6 | export default class ConfigurationLoader { 7 | /** 8 | * @param {Configuration} config the client configuration 9 | * @returns {Configuration} the sanitized client configuration 10 | */ 11 | static load(config) { 12 | // Set default pub sub endpoint if not specified 13 | config.pubSubEndpoint = 14 | config.pubSubEndpoint ?? DEFAULT_PUB_SUB_ENDPOINT; 15 | // Check config for specific auth types 16 | ConfigurationLoader.#checkMandatoryVariables(config, ['authType']); 17 | switch (config.authType) { 18 | case AuthType.USER_SUPPLIED: 19 | config = ConfigurationLoader.#loadUserSuppliedAuth(config); 20 | break; 21 | case AuthType.USERNAME_PASSWORD: 22 | ConfigurationLoader.#checkMandatoryVariables(config, [ 23 | 'loginUrl', 24 | 'username', 25 | 'password' 26 | ]); 27 | config.userToken = config.userToken ?? ''; 28 | break; 29 | case AuthType.OAUTH_CLIENT_CREDENTIALS: 30 | ConfigurationLoader.#checkMandatoryVariables(config, [ 31 | 'loginUrl', 32 | 'clientId', 33 | 'clientSecret' 34 | ]); 35 | break; 36 | case AuthType.OAUTH_JWT_BEARER: 37 | ConfigurationLoader.#checkMandatoryVariables(config, [ 38 | 'loginUrl', 39 | 'clientId', 40 | 'username', 41 | 'privateKey' 42 | ]); 43 | break; 44 | default: 45 | throw new Error( 46 | `Unsupported authType value: ${config.authType}` 47 | ); 48 | } 49 | // Sanitize rejectUnauthorizedSsl property 50 | ConfigurationLoader.#loadBooleanValue( 51 | config, 52 | 'rejectUnauthorizedSsl', 53 | DEFAULT_REJECT_UNAUTHORIZED_SSL 54 | ); 55 | return config; 56 | } 57 | 58 | /** 59 | * Loads a boolean value from a config key. 60 | * Falls back to the provided default value if no value is specified. 61 | * Errors out if the config value can't be converted to a boolean value. 62 | * @param {Configuration} config 63 | * @param {string} key 64 | * @param {boolean} defaultValue 65 | */ 66 | static #loadBooleanValue(config, key, defaultValue) { 67 | // Load the default value if no value is specified 68 | if ( 69 | !Object.hasOwn(config, key) || 70 | config[key] === undefined || 71 | config[key] === null 72 | ) { 73 | config[key] = defaultValue; 74 | return; 75 | } 76 | 77 | const value = config[key]; 78 | const type = typeof value; 79 | switch (type) { 80 | case 'boolean': 81 | // Do nothing, value is valid 82 | break; 83 | case 'string': 84 | { 85 | switch (value.toUppercase()) { 86 | case 'TRUE': 87 | config[key] = true; 88 | break; 89 | case 'FALSE': 90 | config[key] = false; 91 | break; 92 | default: 93 | throw new Error( 94 | `Expected boolean value for ${key}, found ${type} with value ${value}` 95 | ); 96 | } 97 | } 98 | break; 99 | default: 100 | throw new Error( 101 | `Expected boolean value for ${key}, found ${type} with value ${value}` 102 | ); 103 | } 104 | } 105 | 106 | /** 107 | * @param {Configuration} config the client configuration 108 | * @returns {Configuration} sanitized configuration 109 | */ 110 | static #loadUserSuppliedAuth(config) { 111 | ConfigurationLoader.#checkMandatoryVariables(config, [ 112 | 'accessToken', 113 | 'instanceUrl' 114 | ]); 115 | // Check instance URL format 116 | if (!config.instanceUrl.startsWith('https://')) { 117 | throw new Error( 118 | `Invalid Salesforce Instance URL format supplied: ${config.instanceUrl}` 119 | ); 120 | } 121 | // Extract org ID from access token 122 | if (!config.organizationId) { 123 | try { 124 | config.organizationId = config.accessToken.split('!').at(0); 125 | } catch (error) { 126 | throw new Error( 127 | 'Unable to parse organizationId from access token', 128 | { 129 | cause: error 130 | } 131 | ); 132 | } 133 | } 134 | // Check org ID length 135 | if ( 136 | config.organizationId.length !== 15 && 137 | config.organizationId.length !== 18 138 | ) { 139 | throw new Error( 140 | `Invalid Salesforce Org ID format supplied: ${config.organizationId}` 141 | ); 142 | } 143 | return config; 144 | } 145 | 146 | static #checkMandatoryVariables(config, varNames) { 147 | varNames.forEach((varName) => { 148 | if (!config[varName]) { 149 | throw new Error( 150 | `Missing value for ${varName} mandatory configuration key` 151 | ); 152 | } 153 | }); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/utils/auth.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import jsforce from 'jsforce'; 3 | import { fetch } from 'undici'; 4 | import { AuthType } from './types.js'; 5 | 6 | /** 7 | * @typedef {Object} ConnectionMetadata 8 | * @property {string} accessToken 9 | * @property {string} instanceUrl 10 | * @property {string} [organizationId] Optional organization ID. Can be omitted when working with user-supplied authentication. 11 | * @property {string} [username] Optional username. Omitted when working with user-supplied authentication. 12 | */ 13 | 14 | export default class SalesforceAuth { 15 | /** 16 | * Client configuration 17 | * @type {Configuration} 18 | */ 19 | #config; 20 | 21 | /** 22 | * Logger 23 | * @type {Logger} 24 | */ 25 | #logger; 26 | 27 | /** 28 | * Builds a new Pub/Sub API client 29 | * @param {Configuration} config the client configuration 30 | * @param {Logger} logger a logger 31 | */ 32 | constructor(config, logger) { 33 | this.#config = config; 34 | this.#logger = logger; 35 | } 36 | 37 | /** 38 | * Authenticates with the auth mode specified in configuration 39 | * @returns {ConnectionMetadata} 40 | */ 41 | async authenticate() { 42 | this.#logger.debug(`Authenticating with ${this.#config.authType} mode`); 43 | switch (this.#config.authType) { 44 | case AuthType.USER_SUPPLIED: 45 | throw new Error( 46 | 'Authenticate method should not be called in user-supplied mode.' 47 | ); 48 | case AuthType.USERNAME_PASSWORD: 49 | return this.#authWithUsernamePassword(); 50 | case AuthType.OAUTH_CLIENT_CREDENTIALS: 51 | return this.#authWithOAuthClientCredentials(); 52 | case AuthType.OAUTH_JWT_BEARER: 53 | return this.#authWithJwtBearer(); 54 | default: 55 | throw new Error( 56 | `Unsupported authType value: ${this.#config.authType}` 57 | ); 58 | } 59 | } 60 | 61 | /** 62 | * Authenticates with the username/password flow 63 | * @returns {ConnectionMetadata} 64 | */ 65 | async #authWithUsernamePassword() { 66 | const { loginUrl, username, password, userToken } = this.#config; 67 | 68 | const sfConnection = new jsforce.Connection({ 69 | loginUrl 70 | }); 71 | await sfConnection.login(username, `${password}${userToken}`); 72 | return { 73 | accessToken: sfConnection.accessToken, 74 | instanceUrl: sfConnection.instanceUrl, 75 | organizationId: sfConnection.userInfo.organizationId, 76 | username 77 | }; 78 | } 79 | 80 | /** 81 | * Authenticates with the OAuth 2.0 client credentials flow 82 | * @returns {ConnectionMetadata} 83 | */ 84 | async #authWithOAuthClientCredentials() { 85 | const { clientId, clientSecret } = this.#config; 86 | const params = new URLSearchParams(); 87 | params.append('grant_type', 'client_credentials'); 88 | params.append('client_id', clientId); 89 | params.append('client_secret', clientSecret); 90 | return this.#authWithOAuth(params.toString()); 91 | } 92 | 93 | /** 94 | * Authenticates with the OAuth 2.0 JWT bearer flow 95 | * @returns {ConnectionMetadata} 96 | */ 97 | async #authWithJwtBearer() { 98 | const { clientId, username, loginUrl, privateKey } = this.#config; 99 | // Prepare token 100 | const header = JSON.stringify({ alg: 'RS256' }); 101 | const claims = JSON.stringify({ 102 | iss: clientId, 103 | sub: username, 104 | aud: loginUrl, 105 | exp: Math.floor(Date.now() / 1000) + 60 * 5 106 | }); 107 | let token = `${base64url(header)}.${base64url(claims)}`; 108 | // Sign token 109 | const sign = crypto.createSign('RSA-SHA256'); 110 | sign.update(token); 111 | sign.end(); 112 | token += `.${base64url(sign.sign(privateKey))}`; 113 | // Log in 114 | const body = `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${token}`; 115 | return this.#authWithOAuth(body); 116 | } 117 | 118 | /** 119 | * Generic OAuth 2.0 connect method 120 | * @param {string} body URL encoded body 121 | * @returns {ConnectionMetadata} connection metadata 122 | */ 123 | async #authWithOAuth(body) { 124 | const { loginUrl } = this.#config; 125 | // Log in 126 | const loginResponse = await fetch(`${loginUrl}/services/oauth2/token`, { 127 | method: 'post', 128 | headers: { 129 | 'Content-Type': 'application/x-www-form-urlencoded' 130 | }, 131 | body 132 | }); 133 | if (loginResponse.status !== 200) { 134 | throw new Error( 135 | `Authentication error: HTTP ${ 136 | loginResponse.status 137 | } - ${await loginResponse.text()}` 138 | ); 139 | } 140 | const { access_token, instance_url } = await loginResponse.json(); 141 | // Get org and user info 142 | const userInfoResponse = await fetch( 143 | `${loginUrl}/services/oauth2/userinfo`, 144 | { 145 | headers: { authorization: `Bearer ${access_token}` } 146 | } 147 | ); 148 | if (userInfoResponse.status !== 200) { 149 | throw new Error( 150 | `Failed to retrieve user info: HTTP ${ 151 | userInfoResponse.status 152 | } - ${await userInfoResponse.text()}` 153 | ); 154 | } 155 | const { organization_id, preferred_username } = 156 | await userInfoResponse.json(); 157 | return { 158 | accessToken: access_token, 159 | instanceUrl: instance_url, 160 | organizationId: organization_id, 161 | username: preferred_username 162 | }; 163 | } 164 | } 165 | 166 | function base64url(input) { 167 | const buf = Buffer.from(input, 'utf8'); 168 | return buf.toString('base64url'); 169 | } 170 | -------------------------------------------------------------------------------- /dist/client.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Client for the Salesforce Pub/Sub API 3 | */ 4 | export default class PubSubApiClient { 5 | /** 6 | * Builds a new Pub/Sub API client 7 | * @param {Configuration} config the client configuration 8 | * @param {Logger} [logger] an optional custom logger. The client uses the console if no value is supplied. 9 | */ 10 | constructor(config: Configuration, logger?: Logger); 11 | /** 12 | * Authenticates with Salesforce (if not using user-supplied authentication mode) then, 13 | * connects to the Pub/Sub API. 14 | * @returns {Promise} Promise that resolves once the connection is established 15 | */ 16 | connect(): Promise; 17 | /** 18 | * Gets the gRPC connectivity state from the current channel. 19 | * @returns {Promise} Promise that holds channel's connectivity information {@link connectivityState} 20 | */ 21 | getConnectivityState(): Promise; 22 | /** 23 | * Subscribes to a topic and retrieves all past events in retention window. 24 | * @param {string} topicName name of the topic that we're subscribing to 25 | * @param {SubscribeCallback} subscribeCallback callback function for handling subscription events 26 | * @param {number | null} [numRequested] optional number of events requested. If not supplied or null, the client keeps the subscription alive forever. 27 | */ 28 | subscribeFromEarliestEvent(topicName: string, subscribeCallback: SubscribeCallback, numRequested?: number | null): void; 29 | /** 30 | * Subscribes to a topic and retrieves past events starting from a replay ID. 31 | * @param {string} topicName name of the topic that we're subscribing to 32 | * @param {SubscribeCallback} subscribeCallback callback function for handling subscription events 33 | * @param {number | null} numRequested number of events requested. If null, the client keeps the subscription alive forever. 34 | * @param {number} replayId replay ID 35 | */ 36 | subscribeFromReplayId(topicName: string, subscribeCallback: SubscribeCallback, numRequested: number | null, replayId: number): void; 37 | /** 38 | * Subscribes to a topic. 39 | * @param {string} topicName name of the topic that we're subscribing to 40 | * @param {SubscribeCallback} subscribeCallback callback function for handling subscription events 41 | * @param {number | null} [numRequested] optional number of events requested. If not supplied or null, the client keeps the subscription alive forever. 42 | */ 43 | subscribe(topicName: string, subscribeCallback: SubscribeCallback, numRequested?: number | null): void; 44 | /** 45 | * Subscribes to a topic thanks to a managed subscription. 46 | * @param {string} subscriptionIdOrName managed subscription ID or developer name 47 | * @param {SubscribeCallback} subscribeCallback callback function for handling subscription events 48 | * @param {number | null} [numRequested] optional number of events requested. If not supplied or null, the client keeps the subscription alive forever. 49 | * @throws Throws an error if the managed subscription does not exist or is not in the `RUN` state. 50 | */ 51 | subscribeWithManagedSubscription(subscriptionIdOrName: string, subscribeCallback: SubscribeCallback, numRequested?: number | null): Promise; 52 | /** 53 | * Request additional events on an existing subscription. 54 | * @param {string} topicName topic name 55 | * @param {number} numRequested number of events requested 56 | */ 57 | requestAdditionalEvents(topicName: string, numRequested: number): void; 58 | /** 59 | * Request additional events on an existing managed subscription. 60 | * @param {string} subscriptionId managed subscription ID 61 | * @param {number} numRequested number of events requested 62 | */ 63 | requestAdditionalManagedEvents(subscriptionId: string, numRequested: number): void; 64 | /** 65 | * Commits a replay ID on a managed subscription. 66 | * @param {string} subscriptionId managed subscription ID 67 | * @param {number} replayId event replay ID 68 | * @returns {string} commit request UUID 69 | */ 70 | commitReplayId(subscriptionId: string, replayId: number): string; 71 | /** 72 | * Publishes a payload to a topic using the gRPC client. This is a synchronous operation, use `publishBatch` when publishing event batches. 73 | * @param {string} topicName name of the topic that we're publishing on 74 | * @param {Object} payload payload of the event that is being published 75 | * @param {string} [correlationKey] optional correlation key. If you don't provide one, we'll generate a random UUID for you. 76 | * @returns {Promise} Promise holding a PublishResult object with replayId and correlationKey 77 | */ 78 | publish(topicName: string, payload: any, correlationKey?: string): Promise; 79 | /** 80 | * Publishes a batch of events using the gRPC client's publish stream. 81 | * @param {string} topicName name of the topic that we're publishing on 82 | * @param {ProducerEvent[]} events events to be published 83 | * @param {PublishCallback} publishCallback callback function for handling publish responses 84 | */ 85 | publishBatch(topicName: string, events: ProducerEvent[], publishCallback: PublishCallback): Promise; 86 | /** 87 | * Closes the gRPC connection. The client will no longer receive events for any topic. 88 | */ 89 | close(): void; 90 | #private; 91 | } 92 | export type Configuration = import("./utils/types.js").Configuration; 93 | export type Logger = import("./utils/types.js").Logger; 94 | export type ProducerEvent = import("./utils/types.js").ProducerEvent; 95 | export type PublishCallback = import("./utils/types.js").PublishCallback; 96 | export type PublishStream = import("./utils/types.js").PublishStream; 97 | export type PublishStreamInfo = import("./utils/types.js").PublishStreamInfo; 98 | export type PublishResult = import("./utils/types.js").PublishResult; 99 | export type Schema = import("./utils/types.js").Schema; 100 | export type SubscribeCallback = import("./utils/types.js").SubscribeCallback; 101 | export type SubscribeRequest = import("./utils/types.js").SubscribeRequest; 102 | export type Subscription = import("./utils/types.js").Subscription; 103 | export type SubscriptionInfo = import("./utils/types.js").SubscriptionInfo; 104 | import { connectivityState } from '@grpc/grpc-js'; 105 | //# sourceMappingURL=client.d.ts.map -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /src/utils/eventParser.js: -------------------------------------------------------------------------------- 1 | import avro from 'avro-js'; 2 | 3 | /** 4 | * Parses the Avro encoded data of an event agains a schema 5 | * @param {*} schema Avro schema 6 | * @param {*} event Avro encoded data of the event 7 | * @returns {*} parsed event data 8 | * @protected 9 | */ 10 | export function parseEvent(schema, event) { 11 | const allFields = schema.type.getFields(); 12 | const replayId = decodeReplayId(event.replayId); 13 | const payload = schema.type.fromBuffer(event.event.payload); // This schema is the same which we retreived earlier in the GetSchema rpc. 14 | // Parse CDC header if available 15 | if (payload.ChangeEventHeader) { 16 | try { 17 | payload.ChangeEventHeader.nulledFields = parseFieldBitmaps( 18 | allFields, 19 | payload.ChangeEventHeader.nulledFields 20 | ); 21 | } catch (error) { 22 | throw new Error('Failed to parse nulledFields', { cause: error }); 23 | } 24 | try { 25 | payload.ChangeEventHeader.diffFields = parseFieldBitmaps( 26 | allFields, 27 | payload.ChangeEventHeader.diffFields 28 | ); 29 | } catch (error) { 30 | throw new Error('Failed to parse diffFields', { cause: error }); 31 | } 32 | try { 33 | payload.ChangeEventHeader.changedFields = parseFieldBitmaps( 34 | allFields, 35 | payload.ChangeEventHeader.changedFields 36 | ); 37 | } catch (error) { 38 | throw new Error('Failed to parse changedFields', { cause: error }); 39 | } 40 | } 41 | // Eliminate intermediate types left by Avro in payload 42 | flattenSinglePropertyObjects(payload); 43 | // Return parsed data 44 | return { 45 | id: event.event.id, 46 | schemaId: event.event.schemaId, 47 | replayId, 48 | payload 49 | }; 50 | } 51 | 52 | /** 53 | * Flattens object properies that are themself objects with a single property. 54 | * This is used to eliminate intermediate 'types' left by Avro. 55 | * For example: { city : { string: 'SFO' } } becomes { city: 'SFO' } 56 | * @param {Object} theObject the object to fransform 57 | * @private 58 | */ 59 | function flattenSinglePropertyObjects(theObject) { 60 | Object.entries(theObject).forEach(([key, value]) => { 61 | if (key !== 'ChangeEventHeader' && value && typeof value === 'object') { 62 | const subKeys = Object.keys(value); 63 | if (subKeys.length === 1) { 64 | const subValue = value[subKeys[0]]; 65 | theObject[key] = subValue; 66 | if (subValue && typeof subValue === 'object') { 67 | flattenSinglePropertyObjects(theObject[key]); 68 | } 69 | } 70 | } 71 | }); 72 | } 73 | 74 | /** 75 | * Parses a bit map of modified fields 76 | * @param {Object[]} allFields 77 | * @param {string[]} fieldBitmapsAsHex 78 | * @returns {string[]} array of modified field names 79 | * @private 80 | */ 81 | function parseFieldBitmaps(allFields, fieldBitmapsAsHex) { 82 | if (fieldBitmapsAsHex.length === 0) { 83 | return []; 84 | } 85 | 86 | let fieldNames = []; 87 | // Replace top field level bitmap with list of fields 88 | if (fieldBitmapsAsHex[0].startsWith('0x')) { 89 | fieldNames = getFieldNamesFromBitmap(allFields, fieldBitmapsAsHex[0]); 90 | } 91 | // Process compound fields 92 | if ( 93 | fieldBitmapsAsHex.length > 1 && 94 | fieldBitmapsAsHex[fieldBitmapsAsHex.length - 1].indexOf('-') !== -1 95 | ) { 96 | fieldBitmapsAsHex.forEach((fieldBitmapAsHex) => { 97 | const bitmapMapStrings = fieldBitmapAsHex.split('-'); 98 | // Ignore top level field bitmap 99 | if (bitmapMapStrings.length >= 2) { 100 | const parentField = 101 | allFields[parseInt(bitmapMapStrings[0], 10)]; 102 | const childFields = getChildFields(parentField); 103 | const childFieldNames = getFieldNamesFromBitmap( 104 | childFields, 105 | bitmapMapStrings[1] 106 | ); 107 | fieldNames = fieldNames.concat( 108 | childFieldNames.map( 109 | (fieldName) => `${parentField._name}.${fieldName}` 110 | ) 111 | ); 112 | } 113 | }); 114 | } 115 | return fieldNames; 116 | } 117 | 118 | /** 119 | * Extracts the children of a parent field 120 | * @param {*} parentField 121 | * @returns {Object[]} child fields 122 | * @private 123 | */ 124 | function getChildFields(parentField) { 125 | const types = parentField._type.getTypes(); 126 | let fields = []; 127 | types.forEach((type) => { 128 | if (type instanceof avro.types.RecordType) { 129 | fields = fields.concat(type.getFields()); 130 | } 131 | }); 132 | return fields; 133 | } 134 | 135 | /** 136 | * Loads field names from a bitmap 137 | * @param {Field[]} fields list of Avro Field 138 | * @param {string} fieldBitmapAsHex 139 | * @returns {string[]} field names 140 | * @private 141 | */ 142 | function getFieldNamesFromBitmap(fields, fieldBitmapAsHex) { 143 | // Convert hex to binary and reverse bits 144 | let binValue = hexToBin(fieldBitmapAsHex); 145 | binValue = binValue.split('').reverse().join(''); 146 | // Use bitmap to figure out field names based on index 147 | const fieldNames = []; 148 | for (let i = 0; i < binValue.length && i < fields.length; i++) { 149 | if (binValue[i] === '1') { 150 | fieldNames.push(fields[i].getName()); 151 | } 152 | } 153 | return fieldNames; 154 | } 155 | 156 | /** 157 | * Decodes the value of a replay ID from a buffer 158 | * @param {Buffer} encodedReplayId 159 | * @returns {number} decoded replay ID 160 | * @protected 161 | */ 162 | export function decodeReplayId(encodedReplayId) { 163 | return Number(encodedReplayId.readBigUInt64BE()); 164 | } 165 | 166 | /** 167 | * Encodes the value of a replay ID 168 | * @param {number} replayId 169 | * @returns {Buffer} encoded replay ID 170 | * @protected 171 | */ 172 | export function encodeReplayId(replayId) { 173 | const buf = Buffer.allocUnsafe(8); 174 | buf.writeBigUInt64BE(BigInt(replayId), 0); 175 | return buf; 176 | } 177 | 178 | /** 179 | * Safely serializes an event into a JSON string 180 | * @param {any} event the event object 181 | * @returns {string} a string holding the JSON respresentation of the event 182 | * @protected 183 | */ 184 | export function toJsonString(event) { 185 | return JSON.stringify(event, (key, value) => 186 | /* Convert BigInt values into strings and keep other types unchanged */ 187 | typeof value === 'bigint' ? value.toString() : value 188 | ); 189 | } 190 | 191 | /** 192 | * Converts a hexadecimal string into a string binary representation 193 | * @param {string} hex 194 | * @returns {string} 195 | * @private 196 | */ 197 | function hexToBin(hex) { 198 | let bin = hex.substring(2); // Remove 0x prefix 199 | bin = bin.replaceAll('0', '0000'); 200 | bin = bin.replaceAll('1', '0001'); 201 | bin = bin.replaceAll('2', '0010'); 202 | bin = bin.replaceAll('3', '0011'); 203 | bin = bin.replaceAll('4', '0100'); 204 | bin = bin.replaceAll('5', '0101'); 205 | bin = bin.replaceAll('6', '0110'); 206 | bin = bin.replaceAll('7', '0111'); 207 | bin = bin.replaceAll('8', '1000'); 208 | bin = bin.replaceAll('9', '1001'); 209 | bin = bin.replaceAll('A', '1010'); 210 | bin = bin.replaceAll('B', '1011'); 211 | bin = bin.replaceAll('C', '1100'); 212 | bin = bin.replaceAll('D', '1101'); 213 | bin = bin.replaceAll('E', '1110'); 214 | bin = bin.replaceAll('F', '1111'); 215 | return bin; 216 | } 217 | -------------------------------------------------------------------------------- /spec/integration/client.spec.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import * as dotenv from 'dotenv'; 3 | import PubSubApiClient from '../../src/client.js'; 4 | import { AuthType } from '../../src/utils/types.js'; 5 | import { 6 | getSalesforceConnection, 7 | getSampleAccount, 8 | updateSampleAccount 9 | } from '../helper/sfUtilities.js'; 10 | import SimpleFileLogger from '../helper/simpleFileLogger.js'; 11 | import injectJasmineReporter from '../helper/reporter.js'; 12 | import { sleep, waitFor } from '../helper/asyncUtilities.js'; 13 | import { getConnectedPubSubApiClient } from '../helper/clientUtilities.js'; 14 | 15 | // Load config from .env file 16 | dotenv.config(); 17 | 18 | // Prepare logger 19 | let logger; 20 | if (process.env.TEST_LOGGER === 'simpleFileLogger') { 21 | logger = new SimpleFileLogger('test.log', 'debug'); 22 | logger.clear(); 23 | injectJasmineReporter(logger); 24 | } else { 25 | logger = console; 26 | } 27 | 28 | const EXTENDED_JASMINE_TIMEOUT = 20000; 29 | const PLATFORM_EVENT_TOPIC = '/event/Sample__e'; 30 | const CHANGE_EVENT_TOPIC = '/data/AccountChangeEvent'; 31 | 32 | describe('Client', function () { 33 | /** 34 | * Pub/Sub API client 35 | * @type {PubSubApiClient} 36 | */ 37 | var client; 38 | 39 | afterEach(async () => { 40 | if (client) { 41 | client.close(); 42 | await sleep(500); 43 | } 44 | }); 45 | 46 | it( 47 | 'supports user supplied auth with platform event', 48 | async function () { 49 | let receivedEvent, receivedSub; 50 | 51 | // Establish connection with jsforce 52 | const sfConnection = await getSalesforceConnection(); 53 | 54 | // Build PubSub client with existing connection 55 | client = new PubSubApiClient( 56 | { 57 | authType: AuthType.USER_SUPPLIED, 58 | accessToken: sfConnection.accessToken, 59 | instanceUrl: sfConnection.instanceUrl, 60 | organizationId: sfConnection.userInfo.organizationId 61 | }, 62 | logger 63 | ); 64 | await client.connect(); 65 | 66 | // Prepare callback & send subscribe request 67 | const callback = (subscription, callbackType, data) => { 68 | if (callbackType === 'event') { 69 | receivedEvent = data; 70 | receivedSub = subscription; 71 | } 72 | }; 73 | client.subscribe(PLATFORM_EVENT_TOPIC, callback, 1); 74 | 75 | // Wait for subscribe to be effective 76 | await sleep(1000); 77 | 78 | // Publish platform event 79 | const payload = { 80 | CreatedDate: new Date().getTime(), // Non-null value required but there's no validity check performed on this field 81 | CreatedById: '00558000000yFyDAAU', // Valid user ID 82 | Message__c: { string: 'Hello world' } // Field is nullable so we need to specify the 'string' type 83 | }; 84 | const publishResult = await client.publish( 85 | PLATFORM_EVENT_TOPIC, 86 | payload 87 | ); 88 | expect(publishResult.replayId).toBeDefined(); 89 | const publishedReplayId = publishResult.replayId; 90 | 91 | // Wait for event to be received 92 | await waitFor(5000, () => receivedEvent !== undefined); 93 | 94 | // Check received event and subcription info 95 | expect(receivedEvent?.replayId).toBe(publishedReplayId); 96 | expect(receivedSub?.topicName).toBe(PLATFORM_EVENT_TOPIC); 97 | expect(receivedSub?.receivedEventCount).toBe(1); 98 | expect(receivedSub?.requestedEventCount).toBe(1); 99 | }, 100 | EXTENDED_JASMINE_TIMEOUT 101 | ); 102 | 103 | it( 104 | 'supports user supplied auth with change event', 105 | async function () { 106 | let receivedEvent, receivedSub; 107 | 108 | // Establish connection with jsforce 109 | const sfConnection = await getSalesforceConnection(); 110 | 111 | // Build PubSub client with existing connection 112 | client = new PubSubApiClient( 113 | { 114 | authType: AuthType.USER_SUPPLIED, 115 | accessToken: sfConnection.accessToken, 116 | instanceUrl: sfConnection.instanceUrl, 117 | organizationId: sfConnection.userInfo.organizationId 118 | }, 119 | logger 120 | ); 121 | await client.connect(); 122 | 123 | // Prepare callback & send subscribe request 124 | const callback = (subscription, callbackType, data) => { 125 | if (callbackType === 'event') { 126 | receivedEvent = data; 127 | receivedSub = subscription; 128 | } 129 | }; 130 | client.subscribe(CHANGE_EVENT_TOPIC, callback, 1); 131 | 132 | // Wait for subscribe to be effective 133 | await sleep(1000); 134 | 135 | // Update sample record 136 | const account = await getSampleAccount(); 137 | account.BillingCity = 'SFO' + Math.random(); 138 | await updateSampleAccount(account); 139 | 140 | // Wait for event to be received 141 | await waitFor(10000, () => receivedEvent !== undefined); 142 | 143 | // Check received event and subcription info 144 | expect(receivedEvent?.replayId).toBeDefined(); 145 | expect(receivedSub?.topicName).toBe(CHANGE_EVENT_TOPIC); 146 | expect(receivedSub?.receivedEventCount).toBe(1); 147 | expect(receivedSub?.requestedEventCount).toBe(1); 148 | expect(receivedEvent.payload.ChangeEventHeader.entityName).toBe( 149 | 'Account' 150 | ); 151 | expect(receivedEvent.payload.ChangeEventHeader.recordIds[0]).toBe( 152 | account.Id 153 | ); 154 | expect( 155 | receivedEvent.payload.ChangeEventHeader.changedFields.includes( 156 | 'BillingAddress.City' 157 | ) 158 | ).toBeTrue(); 159 | expect(receivedEvent.payload.BillingAddress.City).toBe( 160 | account.BillingCity 161 | ); 162 | }, 163 | EXTENDED_JASMINE_TIMEOUT 164 | ); 165 | 166 | it( 167 | 'supports usermame/password auth with platform event', 168 | async function () { 169 | let receivedEvent, receivedSub; 170 | 171 | // Build PubSub client 172 | client = new PubSubApiClient( 173 | { 174 | authType: AuthType.USERNAME_PASSWORD, 175 | loginUrl: process.env.SALESFORCE_LOGIN_URL, 176 | username: process.env.SALESFORCE_USERNAME, 177 | password: process.env.SALESFORCE_PASSWORD, 178 | userToken: process.env.SALESFORCE_TOKEN 179 | }, 180 | logger 181 | ); 182 | await client.connect(); 183 | 184 | // Prepare callback & send subscribe request 185 | const callback = (subscription, callbackType, data) => { 186 | if (callbackType === 'event') { 187 | receivedEvent = data; 188 | receivedSub = subscription; 189 | } 190 | }; 191 | client.subscribe(PLATFORM_EVENT_TOPIC, callback, 1); 192 | 193 | // Wait for subscribe to be effective 194 | await sleep(1000); 195 | 196 | // Publish platform event 197 | const payload = { 198 | CreatedDate: new Date().getTime(), // Non-null value required but there's no validity check performed on this field 199 | CreatedById: '00558000000yFyDAAU', // Valid user ID 200 | Message__c: { string: 'Hello world' } // Field is nullable so we need to specify the 'string' type 201 | }; 202 | const publishResult = await client.publish( 203 | PLATFORM_EVENT_TOPIC, 204 | payload 205 | ); 206 | expect(publishResult.replayId).toBeDefined(); 207 | const publishedReplayId = publishResult.replayId; 208 | 209 | // Wait for event to be received 210 | await waitFor(10000, () => receivedEvent !== undefined); 211 | 212 | // Check received event and subcription info 213 | expect(receivedEvent?.replayId).toBe(publishedReplayId); 214 | expect(receivedSub?.topicName).toBe(PLATFORM_EVENT_TOPIC); 215 | expect(receivedSub?.receivedEventCount).toBe(1); 216 | expect(receivedSub?.requestedEventCount).toBe(1); 217 | }, 218 | EXTENDED_JASMINE_TIMEOUT 219 | ); 220 | 221 | it( 222 | 'supports client credentials OAuth flow with platform event', 223 | async function () { 224 | let receivedEvent, receivedSub; 225 | 226 | // Build PubSub client 227 | client = new PubSubApiClient( 228 | { 229 | authType: AuthType.OAUTH_CLIENT_CREDENTIALS, 230 | loginUrl: process.env.SALESFORCE_LOGIN_URL, 231 | clientId: process.env.SALESFORCE_CLIENT_ID, 232 | clientSecret: process.env.SALESFORCE_CLIENT_SECRET 233 | }, 234 | logger 235 | ); 236 | await client.connect(); 237 | 238 | // Prepare callback & send subscribe request 239 | const callback = (subscription, callbackType, data) => { 240 | if (callbackType === 'event') { 241 | receivedEvent = data; 242 | receivedSub = subscription; 243 | } 244 | }; 245 | client.subscribe(PLATFORM_EVENT_TOPIC, callback, 1); 246 | 247 | // Wait for subscribe to be effective 248 | await sleep(1000); 249 | 250 | // Publish platform event 251 | const payload = { 252 | CreatedDate: new Date().getTime(), // Non-null value required but there's no validity check performed on this field 253 | CreatedById: '00558000000yFyDAAU', // Valid user ID 254 | Message__c: { string: 'Hello world' } // Field is nullable so we need to specify the 'string' type 255 | }; 256 | const publishResult = await client.publish( 257 | PLATFORM_EVENT_TOPIC, 258 | payload 259 | ); 260 | expect(publishResult.replayId).toBeDefined(); 261 | const publishedReplayId = publishResult.replayId; 262 | 263 | // Wait for event to be received 264 | await waitFor(10000, () => receivedEvent !== undefined); 265 | 266 | // Check received event and subcription info 267 | expect(receivedEvent?.replayId).toBe(publishedReplayId); 268 | expect(receivedSub?.topicName).toBe(PLATFORM_EVENT_TOPIC); 269 | expect(receivedSub?.receivedEventCount).toBe(1); 270 | expect(receivedSub?.requestedEventCount).toBe(1); 271 | }, 272 | EXTENDED_JASMINE_TIMEOUT 273 | ); 274 | 275 | it( 276 | 'supports JWT OAuth flow with platform event', 277 | async function () { 278 | let receivedEvent, receivedSub; 279 | 280 | // Read private key and remove potential invalid characters from key 281 | const privateKey = fs.readFileSync( 282 | process.env.SALESFORCE_PRIVATE_KEY_PATH 283 | ); 284 | 285 | // Build PubSub client 286 | client = new PubSubApiClient( 287 | { 288 | authType: AuthType.OAUTH_JWT_BEARER, 289 | loginUrl: process.env.SALESFORCE_JWT_LOGIN_URL, 290 | clientId: process.env.SALESFORCE_JWT_CLIENT_ID, 291 | username: process.env.SALESFORCE_USERNAME, 292 | privateKey 293 | }, 294 | logger 295 | ); 296 | await client.connect(); 297 | 298 | // Prepare callback & send subscribe request 299 | const callback = (subscription, callbackType, data) => { 300 | if (callbackType === 'event') { 301 | receivedEvent = data; 302 | receivedSub = subscription; 303 | } 304 | }; 305 | client.subscribe(PLATFORM_EVENT_TOPIC, callback, 1); 306 | 307 | // Wait for subscribe to be effective 308 | await sleep(1000); 309 | 310 | // Publish platform event 311 | const payload = { 312 | CreatedDate: new Date().getTime(), // Non-null value required but there's no validity check performed on this field 313 | CreatedById: '00558000000yFyDAAU', // Valid user ID 314 | Message__c: { string: 'Hello world' } // Field is nullable so we need to specify the 'string' type 315 | }; 316 | const publishResult = await client.publish( 317 | PLATFORM_EVENT_TOPIC, 318 | payload 319 | ); 320 | expect(publishResult.replayId).toBeDefined(); 321 | const publishedReplayId = publishResult.replayId; 322 | 323 | // Wait for event to be received 324 | await waitFor(10000, () => receivedEvent !== undefined); 325 | 326 | // Check received event and subcription info 327 | expect(receivedEvent?.replayId).toBe(publishedReplayId); 328 | expect(receivedSub?.topicName).toBe(PLATFORM_EVENT_TOPIC); 329 | expect(receivedSub?.receivedEventCount).toBe(1); 330 | expect(receivedSub?.requestedEventCount).toBe(1); 331 | }, 332 | EXTENDED_JASMINE_TIMEOUT 333 | ); 334 | 335 | it( 336 | 'supports querying for additional events', 337 | async function () { 338 | let receivedEvents = []; 339 | let hasRequestedAdditionalEvents = false; 340 | 341 | // Build PubSub client 342 | client = await getConnectedPubSubApiClient(logger); 343 | 344 | // Prepare callback & send subscribe request 345 | const callback = async (subscription, callbackType, data) => { 346 | if (callbackType === 'event') { 347 | receivedEvents.push(data); 348 | } else if (callbackType === 'lastEvent') { 349 | // Request another event, the first time we receive the "last event" callback 350 | if (!hasRequestedAdditionalEvents) { 351 | client.requestAdditionalEvents( 352 | subscription.topicName, 353 | 1 354 | ); 355 | hasRequestedAdditionalEvents = true; 356 | // Wait for request to be effective 357 | await sleep(1000); 358 | } 359 | } 360 | }; 361 | client.subscribe(PLATFORM_EVENT_TOPIC, callback, 1); 362 | 363 | // Wait for subscribe to be effective 364 | await sleep(1000); 365 | 366 | // Prepare platform event payload 367 | const payload = { 368 | CreatedDate: new Date().getTime(), // Non-null value required but there's no validity check performed on this field 369 | CreatedById: '00558000000yFyDAAU', // Valid user ID 370 | Message__c: { string: 'Event 1/2' } // Field is nullable so we need to specify the 'string' type 371 | }; 372 | 373 | // Publish 1st platform event 374 | let publishResult = await client.publish( 375 | PLATFORM_EVENT_TOPIC, 376 | payload 377 | ); 378 | expect(publishResult.replayId).toBeDefined(); 379 | const publishedReplayId1 = publishResult.replayId; 380 | // Publish 2nd platform event 381 | payload.Message__c.string = 'Event 2/2'; 382 | publishResult = await client.publish(PLATFORM_EVENT_TOPIC, payload); 383 | expect(publishResult.replayId).toBeDefined(); 384 | const publishedReplayId2 = publishResult.replayId; 385 | 386 | // Wait for event to be received 387 | await waitFor(10000, () => receivedEvents.length === 2); 388 | 389 | // Check received events 390 | expect(hasRequestedAdditionalEvents) 391 | .withContext('Did not request additional events') 392 | .toBeTrue(); 393 | expect( 394 | receivedEvents.some((e) => e.replayId === publishedReplayId1) 395 | ).toBeTrue(); 396 | expect( 397 | receivedEvents.some((e) => e.replayId === publishedReplayId2) 398 | ).toBeTrue(); 399 | }, 400 | EXTENDED_JASMINE_TIMEOUT 401 | ); 402 | }); 403 | -------------------------------------------------------------------------------- /pubsub_api.proto: -------------------------------------------------------------------------------- 1 | /* 2 | * Salesforce Pub/Sub API Version 1. 3 | */ 4 | 5 | syntax = "proto3"; 6 | package eventbus.v1; 7 | 8 | option java_multiple_files = true; 9 | option java_package = "com.salesforce.eventbus.protobuf"; 10 | option java_outer_classname = "PubSubProto"; 11 | 12 | option go_package = "github.com/developerforce/pub-sub-api/go/proto"; 13 | 14 | /* 15 | * Contains information about a topic and uniquely identifies it. TopicInfo is returned by the GetTopic RPC method. 16 | */ 17 | message TopicInfo { 18 | // Topic name 19 | string topic_name = 1; 20 | // Tenant/org GUID 21 | string tenant_guid = 2; 22 | // Is publishing allowed? 23 | bool can_publish = 3; 24 | // Is subscription allowed? 25 | bool can_subscribe = 4; 26 | /* ID of the current topic schema, which can be used for 27 | * publishing of generically serialized events. 28 | */ 29 | string schema_id = 5; 30 | // RPC ID used to trace errors. 31 | string rpc_id = 6; 32 | } 33 | 34 | /* 35 | * A request message for GetTopic. Note that the tenant/org is not directly referenced 36 | * in the request, but is implicitly identified by the authentication headers. 37 | */ 38 | message TopicRequest { 39 | // The name of the topic to retrieve. 40 | string topic_name = 1; 41 | } 42 | 43 | /* 44 | * Reserved for future use. 45 | * Header that contains information for distributed tracing, filtering, routing, etc. 46 | * For example, X-B3-* headers assigned by a publisher are stored with the event and 47 | * can provide a full distributed trace of the event across its entire lifecycle. 48 | */ 49 | message EventHeader { 50 | string key = 1; 51 | bytes value = 2; 52 | } 53 | 54 | /* 55 | * Represents an event that an event publishing app creates. 56 | */ 57 | message ProducerEvent { 58 | // Either a user-provided ID or a system generated guid 59 | string id = 1; 60 | // Schema fingerprint for this event which is hash of the schema 61 | string schema_id = 2; 62 | // The message data field 63 | bytes payload = 3; 64 | // Reserved for future use. Key-value pairs of headers. 65 | repeated EventHeader headers = 4; 66 | } 67 | 68 | /* 69 | * Represents an event that is consumed in a subscriber client. 70 | * In addition to the fields in ProducerEvent, ConsumerEvent has the replay_id field. 71 | */ 72 | message ConsumerEvent { 73 | // The event with fields identical to ProducerEvent 74 | ProducerEvent event = 1; 75 | /* The replay ID of the event. 76 | * A subscriber app can store the replay ID. When the app restarts, it can resume subscription 77 | * starting from events in the event bus after the event with that replay ID. 78 | */ 79 | bytes replay_id = 2; 80 | } 81 | 82 | /* 83 | * Event publish result that the Publish RPC method returns. The result contains replay_id or a publish error. 84 | */ 85 | message PublishResult { 86 | // Replay ID of the event 87 | bytes replay_id = 1; 88 | // Publish error if any 89 | Error error = 2; 90 | // Correlation key of the ProducerEvent 91 | string correlation_key = 3; 92 | } 93 | 94 | // Contains error information for an error that an RPC method returns. 95 | message Error { 96 | // Error code 97 | ErrorCode code = 1; 98 | // Error message 99 | string msg = 2; 100 | } 101 | 102 | // Supported error codes 103 | enum ErrorCode { 104 | UNKNOWN = 0; 105 | PUBLISH = 1; 106 | // ErrorCode for unrecoverable commit errors. 107 | COMMIT = 2; 108 | } 109 | 110 | /* 111 | * Supported subscription replay start values. 112 | * By default, the subscription will start at the tip of the stream if ReplayPreset is not specified. 113 | */ 114 | enum ReplayPreset { 115 | // Start the subscription at the tip of the stream. 116 | LATEST = 0; 117 | // Start the subscription at the earliest point in the stream. 118 | EARLIEST = 1; 119 | // Start the subscription after a custom point in the stream. This must be set with a valid replay_id in the FetchRequest. 120 | CUSTOM = 2; 121 | } 122 | 123 | /* 124 | * Request for the Subscribe streaming RPC method. This request is used to: 125 | * 1. Establish the initial subscribe stream. 126 | * 2. Request more events from the subscription stream. 127 | * Flow Control is handled by the subscriber via num_requested. 128 | * A client can specify a starting point for the subscription with replay_preset and replay_id combinations. 129 | * If no replay_preset is specified, the subscription starts at LATEST (tip of the stream). 130 | * replay_preset and replay_id values are only consumed as part of the first FetchRequest. If 131 | * a client needs to start at another point in the stream, it must start a new subscription. 132 | */ 133 | message FetchRequest { 134 | /* 135 | * Identifies a topic for subscription in the very first FetchRequest of the stream. The topic cannot change 136 | * in subsequent FetchRequests within the same subscribe stream, but can be omitted for efficiency. 137 | */ 138 | string topic_name = 1; 139 | 140 | /* 141 | * Subscription starting point. This is consumed only as part of the first FetchRequest 142 | * when the subscription is set up. 143 | */ 144 | ReplayPreset replay_preset = 2; 145 | /* 146 | * If replay_preset of CUSTOM is selected, specify the subscription point to start after. 147 | * This is consumed only as part of the first FetchRequest when the subscription is set up. 148 | */ 149 | bytes replay_id = 3; 150 | /* 151 | * Number of events a client is ready to accept. Each subsequent FetchRequest informs the server 152 | * of additional processing capacity available on the client side. There is no guarantee of equal number of 153 | * FetchResponse messages to be sent back. There is not necessarily a correspondence between 154 | * number of requested events in FetchRequest and the number of events returned in subsequent 155 | * FetchResponses. 156 | */ 157 | int32 num_requested = 4; 158 | // For internal Salesforce use only. 159 | string auth_refresh = 5; 160 | } 161 | 162 | /* 163 | * Response for the Subscribe streaming RPC method. This returns ConsumerEvent(s). 164 | * If there are no events to deliver, the server sends an empty batch fetch response with the latest replay ID. The 165 | * empty fetch response is sent within 270 seconds. An empty fetch response provides a periodic keepalive from the 166 | * server and the latest replay ID. 167 | */ 168 | message FetchResponse { 169 | // Received events for subscription for client consumption 170 | repeated ConsumerEvent events = 1; 171 | // Latest replay ID of a subscription. Enables clients with an updated replay value so that they can keep track 172 | // of their last consumed replay. Clients will not have to start a subscription at a very old replay in the case where a resubscribe is necessary. 173 | bytes latest_replay_id = 2; 174 | // RPC ID used to trace errors. 175 | string rpc_id = 3; 176 | // Number of remaining events to be delivered to the client for a Subscribe RPC call. 177 | int32 pending_num_requested = 4; 178 | } 179 | 180 | /* 181 | * Request for the GetSchema RPC method. The schema request is based on the event schema ID. 182 | */ 183 | message SchemaRequest { 184 | // Schema fingerprint for this event, which is a hash of the schema. 185 | string schema_id = 1; 186 | } 187 | 188 | /* 189 | * Response for the GetSchema RPC method. This returns the schema ID and schema of an event. 190 | */ 191 | message SchemaInfo { 192 | // Avro schema in JSON format 193 | string schema_json = 1; 194 | // Schema fingerprint 195 | string schema_id = 2; 196 | // RPC ID used to trace errors. 197 | string rpc_id = 3; 198 | } 199 | 200 | // Request for the Publish and PublishStream RPC method. 201 | message PublishRequest { 202 | // Topic to publish on 203 | string topic_name = 1; 204 | // Batch of ProducerEvent(s) to send 205 | repeated ProducerEvent events = 2; 206 | // For internal Salesforce use only. 207 | string auth_refresh = 3; 208 | } 209 | 210 | /* 211 | * Response for the Publish and PublishStream RPC methods. This returns 212 | * a list of PublishResults for each event that the client attempted to 213 | * publish. PublishResult indicates if publish succeeded or not 214 | * for each event. It also returns the schema ID that was used to create 215 | * the ProducerEvents in the PublishRequest. 216 | */ 217 | message PublishResponse { 218 | // Publish results 219 | repeated PublishResult results = 1; 220 | // Schema fingerprint for this event, which is a hash of the schema 221 | string schema_id = 2; 222 | // RPC ID used to trace errors. 223 | string rpc_id = 3; 224 | } 225 | 226 | /* 227 | * This feature is part of an open beta release and is subject to the applicable 228 | * Beta Services Terms provided at Agreements and Terms 229 | * (https://www.salesforce.com/company/legal/agreements/). 230 | * 231 | * Request for the ManagedSubscribe streaming RPC method. This request is used to: 232 | * 1. Establish the initial managed subscribe stream. 233 | * 2. Request more events from the subscription stream. 234 | * 3. Commit a Replay ID using CommitReplayRequest. 235 | */ 236 | message ManagedFetchRequest { 237 | /* 238 | * Managed subscription ID or developer name. This value corresponds to the 239 | * ID or developer name of the ManagedEventSubscription Tooling API record. 240 | * This value is consumed as part of the first ManagedFetchRequest only. 241 | * The subscription_id cannot change in subsequent ManagedFetchRequests 242 | * within the same subscribe stream, but can be omitted for efficiency. 243 | */ 244 | string subscription_id = 1; 245 | string developer_name = 2; 246 | /* 247 | * Number of events a client is ready to accept. Each subsequent FetchRequest informs the server 248 | * of additional processing capacity available on the client side. There is no guarantee of equal number of 249 | * FetchResponse messages to be sent back. There is not necessarily a correspondence between 250 | * number of requested events in FetchRequest and the number of events returned in subsequent 251 | * FetchResponses. 252 | */ 253 | int32 num_requested = 3; 254 | // For internal Salesforce use only. 255 | string auth_refresh = 4; 256 | CommitReplayRequest commit_replay_id_request = 5; 257 | 258 | } 259 | 260 | /* 261 | * This feature is part of an open beta release and is subject to the applicable 262 | * Beta Services Terms provided at Agreements and Terms 263 | * (https://www.salesforce.com/company/legal/agreements/). 264 | * 265 | * Response for the ManagedSubscribe streaming RPC method. This can return 266 | * ConsumerEvent(s) or CommitReplayResponse along with other metadata. 267 | */ 268 | message ManagedFetchResponse { 269 | // Received events for subscription for client consumption 270 | repeated ConsumerEvent events = 1; 271 | // Latest replay ID of a subscription. 272 | bytes latest_replay_id = 2; 273 | // RPC ID used to trace errors. 274 | string rpc_id = 3; 275 | // Number of remaining events to be delivered to the client for a Subscribe RPC call. 276 | int32 pending_num_requested = 4; 277 | // commit response 278 | CommitReplayResponse commit_response = 5; 279 | } 280 | 281 | /* 282 | * This feature is part of an open beta release and is subject to the applicable 283 | * Beta Services Terms provided at Agreements and Terms 284 | * (https://www.salesforce.com/company/legal/agreements/). 285 | * 286 | * Request to commit a Replay ID for the last processed event or for the latest 287 | * replay ID received in an empty batch of events. 288 | */ 289 | message CommitReplayRequest { 290 | // commit_request_id to identify commit responses 291 | string commit_request_id = 1; 292 | // replayId to commit 293 | bytes replay_id = 2; 294 | } 295 | 296 | /* 297 | * This feature is part of an open beta release and is subject to the applicable 298 | * Beta Services Terms provided at Agreements and Terms 299 | * (https://www.salesforce.com/company/legal/agreements/). 300 | * 301 | * There is no guaranteed 1:1 CommitReplayRequest to CommitReplayResponse. 302 | * N CommitReplayRequest(s) can get compressed in a batch resulting in a single 303 | * CommitReplayResponse which reflects the latest values of last 304 | * CommitReplayRequest in that batch. 305 | */ 306 | message CommitReplayResponse { 307 | // commit_request_id to identify commit responses. 308 | string commit_request_id = 1; 309 | // replayId that may have been committed 310 | bytes replay_id = 2; 311 | // for failed commits 312 | Error error = 3; 313 | // time when server received request in epoch ms 314 | int64 process_time = 4; 315 | } 316 | 317 | /* 318 | * The Pub/Sub API provides a single interface for publishing and subscribing to platform events, including real-time 319 | * event monitoring events, and change data capture events. The Pub/Sub API is a gRPC API that is based on HTTP/2. 320 | * 321 | * A session token is needed to authenticate. Any of the Salesforce supported 322 | * OAuth flows can be used to obtain a session token: 323 | * https://help.salesforce.com/articleView?id=sf.remoteaccess_oauth_flows.htm&type=5 324 | * 325 | * For each RPC, a client needs to pass authentication information 326 | * as metadata headers (https://www.grpc.io/docs/guides/concepts/#metadata) with their method call. 327 | * 328 | * For Salesforce session token authentication, use: 329 | * accesstoken : access token 330 | * instanceurl : Salesforce instance URL 331 | * tenantid : tenant/org id of the client 332 | * 333 | * StatusException is thrown in case of response failure for any request. 334 | */ 335 | service PubSub { 336 | /* 337 | * Bidirectional streaming RPC to subscribe to a Topic. The subscription is pull-based. A client can request 338 | * for more events as it consumes events. This enables a client to handle flow control based on the client's processing speed. 339 | * 340 | * Typical flow: 341 | * 1. Client requests for X number of events via FetchRequest. 342 | * 2. Server receives request and delivers events until X events are delivered to the client via one or more FetchResponse messages. 343 | * 3. Client consumes the FetchResponse messages as they come. 344 | * 4. Client issues new FetchRequest for Y more number of events. This request can 345 | * come before the server has delivered the earlier requested X number of events 346 | * so the client gets a continuous stream of events if any. 347 | * 348 | * If a client requests more events before the server finishes the last 349 | * requested amount, the server appends the new amount to the current amount of 350 | * events it still needs to fetch and deliver. 351 | * 352 | * A client can subscribe at any point in the stream by providing a replay option in the first FetchRequest. 353 | * The replay option is honored for the first FetchRequest received from a client. Any subsequent FetchRequests with a 354 | * new replay option are ignored. A client needs to call the Subscribe RPC again to restart the subscription 355 | * at a new point in the stream. 356 | * 357 | * The first FetchRequest of the stream identifies the topic to subscribe to. 358 | * If any subsequent FetchRequest provides topic_name, it must match what 359 | * was provided in the first FetchRequest; otherwise, the RPC returns an error 360 | * with INVALID_ARGUMENT status. 361 | */ 362 | rpc Subscribe (stream FetchRequest) returns (stream FetchResponse); 363 | 364 | // Get the event schema for a topic based on a schema ID. 365 | rpc GetSchema (SchemaRequest) returns (SchemaInfo); 366 | 367 | /* 368 | * Get the topic Information related to the specified topic. 369 | */ 370 | rpc GetTopic (TopicRequest) returns (TopicInfo); 371 | 372 | /* 373 | * Send a publish request to synchronously publish events to a topic. 374 | */ 375 | rpc Publish (PublishRequest) returns (PublishResponse); 376 | 377 | /* 378 | * Bidirectional Streaming RPC to publish events to the event bus. 379 | * PublishRequest contains the batch of events to publish. 380 | * 381 | * The first PublishRequest of the stream identifies the topic to publish on. 382 | * If any subsequent PublishRequest provides topic_name, it must match what 383 | * was provided in the first PublishRequest; otherwise, the RPC returns an error 384 | * with INVALID_ARGUMENT status. 385 | * 386 | * The server returns a PublishResponse for each PublishRequest when publish is 387 | * complete for the batch. A client does not have to wait for a PublishResponse 388 | * before sending a new PublishRequest, i.e. multiple publish batches can be queued 389 | * up, which allows for higher publish rate as a client can asynchronously 390 | * publish more events while publishes are still in flight on the server side. 391 | * 392 | * PublishResponse holds a PublishResult for each event published that indicates success 393 | * or failure of the publish. A client can then retry the publish as needed before sending 394 | * more PublishRequests for new events to publish. 395 | * 396 | * A client must send a valid publish request with one or more events every 70 seconds to hold on to the stream. 397 | * Otherwise, the server closes the stream and notifies the client. Once the client is notified of the stream closure, 398 | * it must make a new PublishStream call to resume publishing. 399 | */ 400 | rpc PublishStream (stream PublishRequest) returns (stream PublishResponse); 401 | 402 | /* 403 | * This feature is part of an open beta release and is subject to the applicable 404 | * Beta Services Terms provided at Agreements and Terms 405 | * (https://www.salesforce.com/company/legal/agreements/). 406 | * 407 | * Same as Subscribe, but for Managed Subscription clients. 408 | * This feature is part of an open beta release. 409 | */ 410 | rpc ManagedSubscribe (stream ManagedFetchRequest) returns (stream ManagedFetchResponse); 411 | } 412 | 413 | // Style guide: https://developers.google.com/protocol-buffers/docs/style -------------------------------------------------------------------------------- /dist/pubsub_api-07e1f84a.proto: -------------------------------------------------------------------------------- 1 | /* 2 | * Salesforce Pub/Sub API Version 1. 3 | */ 4 | 5 | syntax = "proto3"; 6 | package eventbus.v1; 7 | 8 | option java_multiple_files = true; 9 | option java_package = "com.salesforce.eventbus.protobuf"; 10 | option java_outer_classname = "PubSubProto"; 11 | 12 | option go_package = "github.com/developerforce/pub-sub-api/go/proto"; 13 | 14 | /* 15 | * Contains information about a topic and uniquely identifies it. TopicInfo is returned by the GetTopic RPC method. 16 | */ 17 | message TopicInfo { 18 | // Topic name 19 | string topic_name = 1; 20 | // Tenant/org GUID 21 | string tenant_guid = 2; 22 | // Is publishing allowed? 23 | bool can_publish = 3; 24 | // Is subscription allowed? 25 | bool can_subscribe = 4; 26 | /* ID of the current topic schema, which can be used for 27 | * publishing of generically serialized events. 28 | */ 29 | string schema_id = 5; 30 | // RPC ID used to trace errors. 31 | string rpc_id = 6; 32 | } 33 | 34 | /* 35 | * A request message for GetTopic. Note that the tenant/org is not directly referenced 36 | * in the request, but is implicitly identified by the authentication headers. 37 | */ 38 | message TopicRequest { 39 | // The name of the topic to retrieve. 40 | string topic_name = 1; 41 | } 42 | 43 | /* 44 | * Reserved for future use. 45 | * Header that contains information for distributed tracing, filtering, routing, etc. 46 | * For example, X-B3-* headers assigned by a publisher are stored with the event and 47 | * can provide a full distributed trace of the event across its entire lifecycle. 48 | */ 49 | message EventHeader { 50 | string key = 1; 51 | bytes value = 2; 52 | } 53 | 54 | /* 55 | * Represents an event that an event publishing app creates. 56 | */ 57 | message ProducerEvent { 58 | // Either a user-provided ID or a system generated guid 59 | string id = 1; 60 | // Schema fingerprint for this event which is hash of the schema 61 | string schema_id = 2; 62 | // The message data field 63 | bytes payload = 3; 64 | // Reserved for future use. Key-value pairs of headers. 65 | repeated EventHeader headers = 4; 66 | } 67 | 68 | /* 69 | * Represents an event that is consumed in a subscriber client. 70 | * In addition to the fields in ProducerEvent, ConsumerEvent has the replay_id field. 71 | */ 72 | message ConsumerEvent { 73 | // The event with fields identical to ProducerEvent 74 | ProducerEvent event = 1; 75 | /* The replay ID of the event. 76 | * A subscriber app can store the replay ID. When the app restarts, it can resume subscription 77 | * starting from events in the event bus after the event with that replay ID. 78 | */ 79 | bytes replay_id = 2; 80 | } 81 | 82 | /* 83 | * Event publish result that the Publish RPC method returns. The result contains replay_id or a publish error. 84 | */ 85 | message PublishResult { 86 | // Replay ID of the event 87 | bytes replay_id = 1; 88 | // Publish error if any 89 | Error error = 2; 90 | // Correlation key of the ProducerEvent 91 | string correlation_key = 3; 92 | } 93 | 94 | // Contains error information for an error that an RPC method returns. 95 | message Error { 96 | // Error code 97 | ErrorCode code = 1; 98 | // Error message 99 | string msg = 2; 100 | } 101 | 102 | // Supported error codes 103 | enum ErrorCode { 104 | UNKNOWN = 0; 105 | PUBLISH = 1; 106 | // ErrorCode for unrecoverable commit errors. 107 | COMMIT = 2; 108 | } 109 | 110 | /* 111 | * Supported subscription replay start values. 112 | * By default, the subscription will start at the tip of the stream if ReplayPreset is not specified. 113 | */ 114 | enum ReplayPreset { 115 | // Start the subscription at the tip of the stream. 116 | LATEST = 0; 117 | // Start the subscription at the earliest point in the stream. 118 | EARLIEST = 1; 119 | // Start the subscription after a custom point in the stream. This must be set with a valid replay_id in the FetchRequest. 120 | CUSTOM = 2; 121 | } 122 | 123 | /* 124 | * Request for the Subscribe streaming RPC method. This request is used to: 125 | * 1. Establish the initial subscribe stream. 126 | * 2. Request more events from the subscription stream. 127 | * Flow Control is handled by the subscriber via num_requested. 128 | * A client can specify a starting point for the subscription with replay_preset and replay_id combinations. 129 | * If no replay_preset is specified, the subscription starts at LATEST (tip of the stream). 130 | * replay_preset and replay_id values are only consumed as part of the first FetchRequest. If 131 | * a client needs to start at another point in the stream, it must start a new subscription. 132 | */ 133 | message FetchRequest { 134 | /* 135 | * Identifies a topic for subscription in the very first FetchRequest of the stream. The topic cannot change 136 | * in subsequent FetchRequests within the same subscribe stream, but can be omitted for efficiency. 137 | */ 138 | string topic_name = 1; 139 | 140 | /* 141 | * Subscription starting point. This is consumed only as part of the first FetchRequest 142 | * when the subscription is set up. 143 | */ 144 | ReplayPreset replay_preset = 2; 145 | /* 146 | * If replay_preset of CUSTOM is selected, specify the subscription point to start after. 147 | * This is consumed only as part of the first FetchRequest when the subscription is set up. 148 | */ 149 | bytes replay_id = 3; 150 | /* 151 | * Number of events a client is ready to accept. Each subsequent FetchRequest informs the server 152 | * of additional processing capacity available on the client side. There is no guarantee of equal number of 153 | * FetchResponse messages to be sent back. There is not necessarily a correspondence between 154 | * number of requested events in FetchRequest and the number of events returned in subsequent 155 | * FetchResponses. 156 | */ 157 | int32 num_requested = 4; 158 | // For internal Salesforce use only. 159 | string auth_refresh = 5; 160 | } 161 | 162 | /* 163 | * Response for the Subscribe streaming RPC method. This returns ConsumerEvent(s). 164 | * If there are no events to deliver, the server sends an empty batch fetch response with the latest replay ID. The 165 | * empty fetch response is sent within 270 seconds. An empty fetch response provides a periodic keepalive from the 166 | * server and the latest replay ID. 167 | */ 168 | message FetchResponse { 169 | // Received events for subscription for client consumption 170 | repeated ConsumerEvent events = 1; 171 | // Latest replay ID of a subscription. Enables clients with an updated replay value so that they can keep track 172 | // of their last consumed replay. Clients will not have to start a subscription at a very old replay in the case where a resubscribe is necessary. 173 | bytes latest_replay_id = 2; 174 | // RPC ID used to trace errors. 175 | string rpc_id = 3; 176 | // Number of remaining events to be delivered to the client for a Subscribe RPC call. 177 | int32 pending_num_requested = 4; 178 | } 179 | 180 | /* 181 | * Request for the GetSchema RPC method. The schema request is based on the event schema ID. 182 | */ 183 | message SchemaRequest { 184 | // Schema fingerprint for this event, which is a hash of the schema. 185 | string schema_id = 1; 186 | } 187 | 188 | /* 189 | * Response for the GetSchema RPC method. This returns the schema ID and schema of an event. 190 | */ 191 | message SchemaInfo { 192 | // Avro schema in JSON format 193 | string schema_json = 1; 194 | // Schema fingerprint 195 | string schema_id = 2; 196 | // RPC ID used to trace errors. 197 | string rpc_id = 3; 198 | } 199 | 200 | // Request for the Publish and PublishStream RPC method. 201 | message PublishRequest { 202 | // Topic to publish on 203 | string topic_name = 1; 204 | // Batch of ProducerEvent(s) to send 205 | repeated ProducerEvent events = 2; 206 | // For internal Salesforce use only. 207 | string auth_refresh = 3; 208 | } 209 | 210 | /* 211 | * Response for the Publish and PublishStream RPC methods. This returns 212 | * a list of PublishResults for each event that the client attempted to 213 | * publish. PublishResult indicates if publish succeeded or not 214 | * for each event. It also returns the schema ID that was used to create 215 | * the ProducerEvents in the PublishRequest. 216 | */ 217 | message PublishResponse { 218 | // Publish results 219 | repeated PublishResult results = 1; 220 | // Schema fingerprint for this event, which is a hash of the schema 221 | string schema_id = 2; 222 | // RPC ID used to trace errors. 223 | string rpc_id = 3; 224 | } 225 | 226 | /* 227 | * This feature is part of an open beta release and is subject to the applicable 228 | * Beta Services Terms provided at Agreements and Terms 229 | * (https://www.salesforce.com/company/legal/agreements/). 230 | * 231 | * Request for the ManagedSubscribe streaming RPC method. This request is used to: 232 | * 1. Establish the initial managed subscribe stream. 233 | * 2. Request more events from the subscription stream. 234 | * 3. Commit a Replay ID using CommitReplayRequest. 235 | */ 236 | message ManagedFetchRequest { 237 | /* 238 | * Managed subscription ID or developer name. This value corresponds to the 239 | * ID or developer name of the ManagedEventSubscription Tooling API record. 240 | * This value is consumed as part of the first ManagedFetchRequest only. 241 | * The subscription_id cannot change in subsequent ManagedFetchRequests 242 | * within the same subscribe stream, but can be omitted for efficiency. 243 | */ 244 | string subscription_id = 1; 245 | string developer_name = 2; 246 | /* 247 | * Number of events a client is ready to accept. Each subsequent FetchRequest informs the server 248 | * of additional processing capacity available on the client side. There is no guarantee of equal number of 249 | * FetchResponse messages to be sent back. There is not necessarily a correspondence between 250 | * number of requested events in FetchRequest and the number of events returned in subsequent 251 | * FetchResponses. 252 | */ 253 | int32 num_requested = 3; 254 | // For internal Salesforce use only. 255 | string auth_refresh = 4; 256 | CommitReplayRequest commit_replay_id_request = 5; 257 | 258 | } 259 | 260 | /* 261 | * This feature is part of an open beta release and is subject to the applicable 262 | * Beta Services Terms provided at Agreements and Terms 263 | * (https://www.salesforce.com/company/legal/agreements/). 264 | * 265 | * Response for the ManagedSubscribe streaming RPC method. This can return 266 | * ConsumerEvent(s) or CommitReplayResponse along with other metadata. 267 | */ 268 | message ManagedFetchResponse { 269 | // Received events for subscription for client consumption 270 | repeated ConsumerEvent events = 1; 271 | // Latest replay ID of a subscription. 272 | bytes latest_replay_id = 2; 273 | // RPC ID used to trace errors. 274 | string rpc_id = 3; 275 | // Number of remaining events to be delivered to the client for a Subscribe RPC call. 276 | int32 pending_num_requested = 4; 277 | // commit response 278 | CommitReplayResponse commit_response = 5; 279 | } 280 | 281 | /* 282 | * This feature is part of an open beta release and is subject to the applicable 283 | * Beta Services Terms provided at Agreements and Terms 284 | * (https://www.salesforce.com/company/legal/agreements/). 285 | * 286 | * Request to commit a Replay ID for the last processed event or for the latest 287 | * replay ID received in an empty batch of events. 288 | */ 289 | message CommitReplayRequest { 290 | // commit_request_id to identify commit responses 291 | string commit_request_id = 1; 292 | // replayId to commit 293 | bytes replay_id = 2; 294 | } 295 | 296 | /* 297 | * This feature is part of an open beta release and is subject to the applicable 298 | * Beta Services Terms provided at Agreements and Terms 299 | * (https://www.salesforce.com/company/legal/agreements/). 300 | * 301 | * There is no guaranteed 1:1 CommitReplayRequest to CommitReplayResponse. 302 | * N CommitReplayRequest(s) can get compressed in a batch resulting in a single 303 | * CommitReplayResponse which reflects the latest values of last 304 | * CommitReplayRequest in that batch. 305 | */ 306 | message CommitReplayResponse { 307 | // commit_request_id to identify commit responses. 308 | string commit_request_id = 1; 309 | // replayId that may have been committed 310 | bytes replay_id = 2; 311 | // for failed commits 312 | Error error = 3; 313 | // time when server received request in epoch ms 314 | int64 process_time = 4; 315 | } 316 | 317 | /* 318 | * The Pub/Sub API provides a single interface for publishing and subscribing to platform events, including real-time 319 | * event monitoring events, and change data capture events. The Pub/Sub API is a gRPC API that is based on HTTP/2. 320 | * 321 | * A session token is needed to authenticate. Any of the Salesforce supported 322 | * OAuth flows can be used to obtain a session token: 323 | * https://help.salesforce.com/articleView?id=sf.remoteaccess_oauth_flows.htm&type=5 324 | * 325 | * For each RPC, a client needs to pass authentication information 326 | * as metadata headers (https://www.grpc.io/docs/guides/concepts/#metadata) with their method call. 327 | * 328 | * For Salesforce session token authentication, use: 329 | * accesstoken : access token 330 | * instanceurl : Salesforce instance URL 331 | * tenantid : tenant/org id of the client 332 | * 333 | * StatusException is thrown in case of response failure for any request. 334 | */ 335 | service PubSub { 336 | /* 337 | * Bidirectional streaming RPC to subscribe to a Topic. The subscription is pull-based. A client can request 338 | * for more events as it consumes events. This enables a client to handle flow control based on the client's processing speed. 339 | * 340 | * Typical flow: 341 | * 1. Client requests for X number of events via FetchRequest. 342 | * 2. Server receives request and delivers events until X events are delivered to the client via one or more FetchResponse messages. 343 | * 3. Client consumes the FetchResponse messages as they come. 344 | * 4. Client issues new FetchRequest for Y more number of events. This request can 345 | * come before the server has delivered the earlier requested X number of events 346 | * so the client gets a continuous stream of events if any. 347 | * 348 | * If a client requests more events before the server finishes the last 349 | * requested amount, the server appends the new amount to the current amount of 350 | * events it still needs to fetch and deliver. 351 | * 352 | * A client can subscribe at any point in the stream by providing a replay option in the first FetchRequest. 353 | * The replay option is honored for the first FetchRequest received from a client. Any subsequent FetchRequests with a 354 | * new replay option are ignored. A client needs to call the Subscribe RPC again to restart the subscription 355 | * at a new point in the stream. 356 | * 357 | * The first FetchRequest of the stream identifies the topic to subscribe to. 358 | * If any subsequent FetchRequest provides topic_name, it must match what 359 | * was provided in the first FetchRequest; otherwise, the RPC returns an error 360 | * with INVALID_ARGUMENT status. 361 | */ 362 | rpc Subscribe (stream FetchRequest) returns (stream FetchResponse); 363 | 364 | // Get the event schema for a topic based on a schema ID. 365 | rpc GetSchema (SchemaRequest) returns (SchemaInfo); 366 | 367 | /* 368 | * Get the topic Information related to the specified topic. 369 | */ 370 | rpc GetTopic (TopicRequest) returns (TopicInfo); 371 | 372 | /* 373 | * Send a publish request to synchronously publish events to a topic. 374 | */ 375 | rpc Publish (PublishRequest) returns (PublishResponse); 376 | 377 | /* 378 | * Bidirectional Streaming RPC to publish events to the event bus. 379 | * PublishRequest contains the batch of events to publish. 380 | * 381 | * The first PublishRequest of the stream identifies the topic to publish on. 382 | * If any subsequent PublishRequest provides topic_name, it must match what 383 | * was provided in the first PublishRequest; otherwise, the RPC returns an error 384 | * with INVALID_ARGUMENT status. 385 | * 386 | * The server returns a PublishResponse for each PublishRequest when publish is 387 | * complete for the batch. A client does not have to wait for a PublishResponse 388 | * before sending a new PublishRequest, i.e. multiple publish batches can be queued 389 | * up, which allows for higher publish rate as a client can asynchronously 390 | * publish more events while publishes are still in flight on the server side. 391 | * 392 | * PublishResponse holds a PublishResult for each event published that indicates success 393 | * or failure of the publish. A client can then retry the publish as needed before sending 394 | * more PublishRequests for new events to publish. 395 | * 396 | * A client must send a valid publish request with one or more events every 70 seconds to hold on to the stream. 397 | * Otherwise, the server closes the stream and notifies the client. Once the client is notified of the stream closure, 398 | * it must make a new PublishStream call to resume publishing. 399 | */ 400 | rpc PublishStream (stream PublishRequest) returns (stream PublishResponse); 401 | 402 | /* 403 | * This feature is part of an open beta release and is subject to the applicable 404 | * Beta Services Terms provided at Agreements and Terms 405 | * (https://www.salesforce.com/company/legal/agreements/). 406 | * 407 | * Same as Subscribe, but for Managed Subscription clients. 408 | * This feature is part of an open beta release. 409 | */ 410 | rpc ManagedSubscribe (stream ManagedFetchRequest) returns (stream ManagedFetchResponse); 411 | } 412 | 413 | // Style guide: https://developers.google.com/protocol-buffers/docs/style -------------------------------------------------------------------------------- /v4-documentation.md: -------------------------------------------------------------------------------- 1 | # Pub/Sub API Node Client - v4 Documentation 2 | 3 | > [!INFO] 4 | > This documentation is kept to support a legacy version. Please consider upgrading to the latest version. 5 | 6 | - [Installation and Configuration](#installation-and-configuration) 7 | - [User supplied authentication](#user-supplied-authentication) 8 | - [Username/password flow](#usernamepassword-flow) 9 | - [OAuth 2.0 client credentials flow (client_credentials)](#oauth-20-client-credentials-flow-client_credentials) 10 | - [OAuth 2.0 JWT bearer flow](#oauth-20-jwt-bearer-flow) 11 | - [Basic Example](#basic-example) 12 | - [Other Examples](#other-examples) 13 | - [Publish a platform event](#publish-a-platform-event) 14 | - [Subscribe with a replay ID](#subscribe-with-a-replay-id) 15 | - [Subscribe to past events in retention window](#subscribe-to-past-events-in-retention-window) 16 | - [Work with flow control for high volumes of events](#work-with-flow-control-for-high-volumes-of-events) 17 | - [Handle gRPC stream lifecycle events](#handle-grpc-stream-lifecycle-events) 18 | - [Use a custom logger](#use-a-custom-logger) 19 | - [Common Issues](#common-issues) 20 | - [Reference](#reference) 21 | - [PubSubApiClient](#pubsubapiclient) 22 | - [PubSubEventEmitter](#pubsubeventemitter) 23 | - [EventParseError](#eventparseerror) 24 | 25 | ## Installation and Configuration 26 | 27 | Install the client library with `npm install salesforce-pubsub-api-client`. 28 | 29 | Create a `.env` file at the root of the project for configuration. 30 | 31 | Pick one of these authentication flows and fill the relevant configuration: 32 | 33 | - User supplied authentication 34 | - Username/password authentication (recommended for tests) 35 | - OAuth 2.0 client credentials 36 | - OAuth 2.0 JWT Bearer (recommended for production) 37 | 38 | > [!TIP] 39 | > The default client logger is fine for a test environment but you'll want to switch to a [custom logger](#use-a-custom-logger) with asynchronous logging for increased performance. 40 | 41 | ### User supplied authentication 42 | 43 | If you already have a Salesforce client in your app, you can reuse its authentication information. You only need this minimal configuration: 44 | 45 | ```properties 46 | SALESFORCE_AUTH_TYPE=user-supplied 47 | 48 | PUB_SUB_ENDPOINT=api.pubsub.salesforce.com:7443 49 | ``` 50 | 51 | When connecting to the Pub/Sub API, use the following method instead of the standard `connect()` method to specify authentication information: 52 | 53 | ```js 54 | await client.connectWithAuth(accessToken, instanceUrl, organizationId); 55 | ``` 56 | 57 | ### Username/password flow 58 | 59 | > [!WARNING] 60 | > Relying on a username/password authentication flow for production is not recommended. Consider switching to JWT auth for extra security. 61 | 62 | ```properties 63 | SALESFORCE_AUTH_TYPE=username-password 64 | SALESFORCE_LOGIN_URL=https://login.salesforce.com 65 | SALESFORCE_USERNAME=YOUR_SALESFORCE_USERNAME 66 | SALESFORCE_PASSWORD=YOUR_SALESFORCE_PASSWORD 67 | SALESFORCE_TOKEN=YOUR_SALESFORCE_USER_SECURITY_TOKEN 68 | 69 | PUB_SUB_ENDPOINT=api.pubsub.salesforce.com:7443 70 | ``` 71 | 72 | ### OAuth 2.0 client credentials flow (client_credentials) 73 | 74 | ```properties 75 | SALESFORCE_AUTH_TYPE=oauth-client-credentials 76 | SALESFORCE_LOGIN_URL=YOUR_DOMAIN_URL 77 | SALESFORCE_CLIENT_ID=YOUR_CONNECTED_APP_CLIENT_ID 78 | SALESFORCE_CLIENT_SECRET=YOUR_CONNECTED_APP_CLIENT_SECRET 79 | 80 | PUB_SUB_ENDPOINT=api.pubsub.salesforce.com:7443 81 | ``` 82 | 83 | ### OAuth 2.0 JWT bearer flow 84 | 85 | This is the most secure authentication option. Recommended for production use. 86 | 87 | ```properties 88 | SALESFORCE_AUTH_TYPE=oauth-jwt-bearer 89 | SALESFORCE_LOGIN_URL=https://login.salesforce.com 90 | SALESFORCE_CLIENT_ID=YOUR_CONNECTED_APP_CLIENT_ID 91 | SALESFORCE_USERNAME=YOUR_SALESFORCE_USERNAME 92 | SALESFORCE_PRIVATE_KEY_FILE=PATH_TO_YOUR_KEY_FILE 93 | 94 | PUB_SUB_ENDPOINT=api.pubsub.salesforce.com:7443 95 | ``` 96 | 97 | ## Basic Example 98 | 99 | Here's an example that will get you started quickly. It listens to a single account change event. 100 | 101 | 1. Activate Account change events in **Salesforce Setup > Change Data Capture**. 102 | 103 | 1. Create a `sample.js` file with this content: 104 | 105 | ```js 106 | import PubSubApiClient from 'salesforce-pubsub-api-client'; 107 | 108 | async function run() { 109 | try { 110 | const client = new PubSubApiClient(); 111 | await client.connect(); 112 | 113 | // Subscribe to account change events 114 | const eventEmitter = await client.subscribe( 115 | '/data/AccountChangeEvent' 116 | ); 117 | 118 | // Handle incoming events 119 | eventEmitter.on('data', (event) => { 120 | console.log( 121 | `Handling ${event.payload.ChangeEventHeader.entityName} change event ` + 122 | `with ID ${event.replayId} ` + 123 | `on channel ${eventEmitter.getTopicName()} ` + 124 | `(${eventEmitter.getReceivedEventCount()}/${eventEmitter.getRequestedEventCount()} ` + 125 | `events received so far)` 126 | ); 127 | // Safely log event as a JSON string 128 | console.log( 129 | JSON.stringify( 130 | event, 131 | (key, value) => 132 | /* Convert BigInt values into strings and keep other types unchanged */ 133 | typeof value === 'bigint' 134 | ? value.toString() 135 | : value, 136 | 2 137 | ) 138 | ); 139 | }); 140 | } catch (error) { 141 | console.error(error); 142 | } 143 | } 144 | 145 | run(); 146 | ``` 147 | 148 | 1. Run the project with `node sample.js` 149 | 150 | If everything goes well, you'll see output like this: 151 | 152 | ``` 153 | Connected to Salesforce org https://pozil-dev-ed.my.salesforce.com as grpc@pozil.com 154 | Connected to Pub/Sub API endpoint api.pubsub.salesforce.com:7443 155 | Topic schema loaded: /data/AccountChangeEvent 156 | Subscribe request sent for 100 events from /data/AccountChangeEvent... 157 | ``` 158 | 159 | At this point the script will be on hold and will wait for events. 160 | 161 | 1. Modify an account record in Salesforce. This fires an account change event. 162 | 163 | Once the client receives an event, it will display it like this: 164 | 165 | ``` 166 | Received 1 events, latest replay ID: 18098167 167 | Handling Account change event with ID 18098167 on channel /data/AccountChangeEvent (1/100 events received so far) 168 | { 169 | "replayId": 18098167, 170 | "payload": { 171 | "ChangeEventHeader": { 172 | "entityName": "Account", 173 | "recordIds": [ 174 | "0014H00002LbR7QQAV" 175 | ], 176 | "changeType": "UPDATE", 177 | "changeOrigin": "com/salesforce/api/soap/58.0;client=SfdcInternalAPI/", 178 | "transactionKey": "000046c7-a642-11e2-c29b-229c6786473e", 179 | "sequenceNumber": 1, 180 | "commitTimestamp": 1696444513000, 181 | "commitNumber": 11657372702432, 182 | "commitUser": "00558000000yFyDAAU", 183 | "nulledFields": [], 184 | "diffFields": [], 185 | "changedFields": [ 186 | "LastModifiedDate", 187 | "BillingAddress.City", 188 | "BillingAddress.State" 189 | ] 190 | }, 191 | "Name": null, 192 | "Type": null, 193 | "ParentId": null, 194 | "BillingAddress": { 195 | "Street": null, 196 | "City": "San Francisco", 197 | "State": "CA", 198 | "PostalCode": null, 199 | "Country": null, 200 | "StateCode": null, 201 | "CountryCode": null, 202 | "Latitude": null, 203 | "Longitude": null, 204 | "Xyz": null, 205 | "GeocodeAccuracy": null 206 | }, 207 | "ShippingAddress": null, 208 | "Phone": null, 209 | "Fax": null, 210 | "AccountNumber": null, 211 | "Website": null, 212 | "Sic": null, 213 | "Industry": null, 214 | "AnnualRevenue": null, 215 | "NumberOfEmployees": null, 216 | "Ownership": null, 217 | "TickerSymbol": null, 218 | "Description": null, 219 | "Rating": null, 220 | "Site": null, 221 | "OwnerId": null, 222 | "CreatedDate": null, 223 | "CreatedById": null, 224 | "LastModifiedDate": 1696444513000, 225 | "LastModifiedById": null, 226 | "Jigsaw": null, 227 | "JigsawCompanyId": null, 228 | "CleanStatus": null, 229 | "AccountSource": null, 230 | "DunsNumber": null, 231 | "Tradestyle": null, 232 | "NaicsCode": null, 233 | "NaicsDesc": null, 234 | "YearStarted": null, 235 | "SicDesc": null, 236 | "DandbCompanyId": null 237 | } 238 | } 239 | ``` 240 | 241 | Note that the change event payloads include all object fields but fields that haven't changed are null. In the above example, the only changes are the Billing State, Billing City and Last Modified Date. 242 | 243 | Use the values from `ChangeEventHeader.nulledFields`, `ChangeEventHeader.diffFields` and `ChangeEventHeader.changedFields` to identify actual value changes. 244 | 245 | ## Other Examples 246 | 247 | ### Publish a platform event 248 | 249 | Publish a `Sample__e` Platform Event with a `Message__c` field: 250 | 251 | ```js 252 | const payload = { 253 | CreatedDate: new Date().getTime(), // Non-null value required but there's no validity check performed on this field 254 | CreatedById: '005_________', // Valid user ID 255 | Message__c: { string: 'Hello world' } // Field is nullable so we need to specify the 'string' type 256 | }; 257 | const publishResult = await client.publish('/event/Sample__e', payload); 258 | console.log('Published event: ', JSON.stringify(publishResult)); 259 | ``` 260 | 261 | ### Subscribe with a replay ID 262 | 263 | Subscribe to 5 account change events starting from a replay ID: 264 | 265 | ```js 266 | const eventEmitter = await client.subscribeFromReplayId( 267 | '/data/AccountChangeEvent', 268 | 5, 269 | 17092989 270 | ); 271 | ``` 272 | 273 | ### Subscribe to past events in retention window 274 | 275 | Subscribe to the 3 earliest past account change events in retention window: 276 | 277 | ```js 278 | const eventEmitter = await client.subscribeFromEarliestEvent( 279 | '/data/AccountChangeEvent', 280 | 3 281 | ); 282 | ``` 283 | 284 | ### Work with flow control for high volumes of events 285 | 286 | When working with high volumes of events you can control the incoming flow of events by requesting a limited batch of events. This event flow control ensures that the client doesn’t get overwhelmed by accepting more events that it can handle if there is a spike in event publishing. 287 | 288 | This is the overall process: 289 | 290 | 1. Pass a number of requested events in your subscribe call. 291 | 1. Handle the `lastevent` event from `PubSubEventEmitter` to detect the end of the event batch. 292 | 1. Subscribe to an additional batch of events with `client.requestAdditionalEvents(...)`. If you don't request additional events at this point, the gRPC subscription will close automatically (default Pub/Sub API behavior). 293 | 294 | The code below illustrate how you can achieve event flow control: 295 | 296 | ```js 297 | try { 298 | // Connect with the Pub/Sub API 299 | const client = new PubSubApiClient(); 300 | await client.connect(); 301 | 302 | // Subscribe to a batch of 10 account change event 303 | const eventEmitter = await client.subscribe('/data/AccountChangeEvent', 10); 304 | 305 | // Handle incoming events 306 | eventEmitter.on('data', (event) => { 307 | // Logic for handling a single event. 308 | // Unless you request additional events later, this should get called up to 10 times 309 | // given the initial subscription boundary. 310 | }); 311 | 312 | // Handle last requested event 313 | eventEmitter.on('lastevent', () => { 314 | console.log( 315 | `Reached last requested event on channel ${eventEmitter.getTopicName()}.` 316 | ); 317 | // Request 10 additional events 318 | client.requestAdditionalEvents(eventEmitter, 10); 319 | }); 320 | } catch (error) { 321 | console.error(error); 322 | } 323 | ``` 324 | 325 | ### Handle gRPC stream lifecycle events 326 | 327 | Use the `EventEmmitter` returned by subscribe methods to handle gRPC stream lifecycle events: 328 | 329 | ```js 330 | // Stream end 331 | eventEmitter.on('end', () => { 332 | console.log('gRPC stream ended'); 333 | }); 334 | 335 | // Stream error 336 | eventEmitter.on('error', (error) => { 337 | console.error('gRPC stream error: ', JSON.stringify(error)); 338 | }); 339 | 340 | // Stream status update 341 | eventEmitter.on('status', (status) => { 342 | console.log('gRPC stream status: ', status); 343 | }); 344 | ``` 345 | 346 | ### Use a custom logger 347 | 348 | The client logs output to the console by default but you can provide your favorite logger in the client constructor. 349 | 350 | When in production, asynchronous logging is preferable for performance reasons. 351 | 352 | For example: 353 | 354 | ```js 355 | import pino from 'pino'; 356 | 357 | const logger = pino(); 358 | const client = new PubSubApiClient(logger); 359 | ``` 360 | 361 | ## Common Issues 362 | 363 | ### TypeError: Do not know how to serialize a BigInt 364 | 365 | If you attempt to call `JSON.stringify` on an event you will likely see the following error: 366 | 367 | > TypeError: Do not know how to serialize a BigInt 368 | 369 | This happens when an integer value stored in an event field exceeds the range of the `Number` JS type (this typically happens with `commitNumber` values). In this case, we use a `BigInt` type to safely store the integer value. However, the `BigInt` type is not yet supported in standard JSON representation (see step 10 in the [BigInt TC39 spec](https://tc39.es/proposal-bigint/#sec-serializejsonproperty)) so this triggers a `TypeError`. 370 | 371 | To avoid this error, use a replacer function to safely escape BigInt values so that they can be serialized as a string (or any other format of your choice) in JSON: 372 | 373 | ```js 374 | // Safely log event as a JSON string 375 | console.log( 376 | JSON.stringify( 377 | event, 378 | (key, value) => 379 | /* Convert BigInt values into strings and keep other types unchanged */ 380 | typeof value === 'bigint' ? value.toString() : value, 381 | 2 382 | ) 383 | ); 384 | ``` 385 | 386 | ## Reference 387 | 388 | ### PubSubApiClient 389 | 390 | Client for the Salesforce Pub/Sub API 391 | 392 | #### `PubSubApiClient([logger])` 393 | 394 | Builds a new Pub/Sub API client. 395 | 396 | | Name | Type | Description | 397 | | -------- | ------ | ------------------------------------------------------------------------------- | 398 | | `logger` | Logger | an optional custom logger. The client uses the console if no value is supplied. | 399 | 400 | #### `close()` 401 | 402 | Closes the gRPC connection. The client will no longer receive events for any topic. 403 | 404 | #### `async connect() → {Promise.}` 405 | 406 | Authenticates with Salesforce then, connects to the Pub/Sub API. 407 | 408 | Returns: Promise that resolves once the connection is established. 409 | 410 | #### `async connectWithAuth(accessToken, instanceUrl, organizationIdopt) → {Promise.}` 411 | 412 | Connects to the Pub/Sub API with user-supplied authentication. 413 | 414 | Returns: Promise that resolves once the connection is established. 415 | 416 | | Name | Type | Description | 417 | | ---------------- | ------ | --------------------------------------------------------------------------------------------------- | 418 | | `accessToken` | string | Salesforce access token | 419 | | `instanceUrl` | string | Salesforce instance URL | 420 | | `organizationId` | string | optional organization ID. If you don't provide one, we'll attempt to parse it from the accessToken. | 421 | 422 | #### `async getConnectivityState() → Promise}` 423 | 424 | Get connectivity state from current channel. 425 | 426 | Returns: Promise that holds channel's [connectivity state](https://grpc.github.io/grpc/node/grpc.html#.connectivityState). 427 | 428 | #### `async publish(topicName, payload, correlationKeyopt) → {Promise.}` 429 | 430 | Publishes a payload to a topic using the gRPC client. 431 | 432 | Returns: Promise holding a `PublishResult` object with `replayId` and `correlationKey`. 433 | 434 | | Name | Type | Description | 435 | | ---------------- | ------ | ----------------------------------------------------------------------------------------- | 436 | | `topicName` | string | name of the topic that we're subscribing to | 437 | | `payload` | Object | | 438 | | `correlationKey` | string | optional correlation key. If you don't provide one, we'll generate a random UUID for you. | 439 | 440 | #### `async subscribe(topicName, [numRequested]) → {Promise.}` 441 | 442 | Subscribes to a topic. 443 | 444 | Returns: Promise that holds an `PubSubEventEmitter` that allows you to listen to received events and stream lifecycle events. 445 | 446 | | Name | Type | Description | 447 | | -------------- | ------ | -------------------------------------------------------------------------------------------------------------- | 448 | | `topicName` | string | name of the topic that we're subscribing to | 449 | | `numRequested` | number | optional number of events requested. If not supplied or null, the client keeps the subscription alive forever. | 450 | 451 | #### `async subscribeFromEarliestEvent(topicName, [numRequested]) → {Promise.}` 452 | 453 | Subscribes to a topic and retrieves all past events in retention window. 454 | 455 | Returns: Promise that holds an `PubSubEventEmitter` that allows you to listen to received events and stream lifecycle events. 456 | 457 | | Name | Type | Description | 458 | | -------------- | ------ | -------------------------------------------------------------------------------------------------------------- | 459 | | `topicName` | string | name of the topic that we're subscribing to | 460 | | `numRequested` | number | optional number of events requested. If not supplied or null, the client keeps the subscription alive forever. | 461 | 462 | #### `async subscribeFromReplayId(topicName, numRequested, replayId) → {Promise.}` 463 | 464 | Subscribes to a topic and retrieves past events starting from a replay ID. 465 | 466 | Returns: Promise that holds an `PubSubEventEmitter` that allows you to listen to received events and stream lifecycle events. 467 | 468 | | Name | Type | Description | 469 | | -------------- | ------ | --------------------------------------------------------------------------------------- | 470 | | `topicName` | string | name of the topic that we're subscribing to | 471 | | `numRequested` | number | number of events requested. If `null`, the client keeps the subscription alive forever. | 472 | | `replayId` | number | replay ID | 473 | 474 | #### `requestAdditionalEvents(eventEmitter, numRequested)` 475 | 476 | Request additional events on an existing subscription. 477 | 478 | | Name | Type | Description | 479 | | -------------- | ------------------ | ----------------------------------------------------------- | 480 | | `eventEmitter` | PubSubEventEmitter | event emitter that was obtained in the first subscribe call | 481 | | `numRequested` | number | number of events requested. | 482 | 483 | ### PubSubEventEmitter 484 | 485 | EventEmitter wrapper for processing incoming Pub/Sub API events while keeping track of the topic name and the volume of events requested/received. 486 | 487 | The emitter sends the following events: 488 | 489 | | Event Name | Event Data | Description | 490 | | ----------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | 491 | | `data` | Object | Client received a new event. The attached data is the parsed event data. | 492 | | `error` | `EventParseError \| Object` | Signals an event parsing error or a gRPC stream error. | 493 | | `lastevent` | void | Signals that we received the last event that the client requested. The stream will end shortly. | 494 | | `keepalive` | `{ latestReplayId: number, pendingNumRequested: number }` | Server publishes this keep alive message every 270 seconds (or less) if there are no events. | 495 | | `end` | void | Signals the end of the gRPC stream. | 496 | | `status` | Object | Misc gRPC stream status information. | 497 | 498 | The emitter also exposes these methods: 499 | 500 | | Method | Description | 501 | | -------------------------- | ------------------------------------------------------------------------------------------ | 502 | | `getRequestedEventCount()` | Returns the number of events that were requested when subscribing. | 503 | | `getReceivedEventCount()` | Returns the number of events that were received since subscribing. | 504 | | `getTopicName()` | Returns the topic name for this subscription. | 505 | | `getLatestReplayId()` | Returns the replay ID of the last processed event or `null` if no event was processed yet. | 506 | 507 | ### EventParseError 508 | 509 | Holds the information related to an event parsing error. This class attempts to extract the event replay ID from the event that caused the error. 510 | 511 | | Name | Type | Description | 512 | | ---------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------ | 513 | | `message` | string | The error message. | 514 | | `cause` | Error | The cause of the error. | 515 | | `replayId` | number | The replay ID of the event at the origin of the error. Could be undefined if we're not able to extract it from the event data. | 516 | | `event` | Object | The un-parsed event data at the origin of the error. | 517 | | `latestReplayId` | number | The latest replay ID that was received before the error. | 518 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/v/salesforce-pubsub-api-client)](https://www.npmjs.com/package/salesforce-pubsub-api-client) 2 | 3 | # Node client for the Salesforce Pub/Sub API 4 | 5 | See the [official Pub/Sub API repo](https://github.com/developerforce/pub-sub-api) and the [documentation](https://developer.salesforce.com/docs/platform/pub-sub-api/guide/intro.html) for more information on the Salesforce gRPC-based Pub/Sub API. 6 | 7 | - [v4 to v5 Migration](#v4-to-v5-migration) 8 | - [v4 Documentation](v4-documentation.md) 9 | - [Installation and Configuration](#installation-and-configuration) 10 | - [Authentication](#authentication) 11 | - [User supplied authentication](#user-supplied-authentication) 12 | - [Username/password flow](#usernamepassword-flow) 13 | - [OAuth 2.0 client credentials flow (client_credentials)](#oauth-20-client-credentials-flow-client_credentials) 14 | - [OAuth 2.0 JWT bearer flow](#oauth-20-jwt-bearer-flow) 15 | - [Logging](#logging) 16 | - [Quick Start Example](#quick-start-example) 17 | - [Other Examples](#other-examples) 18 | - [Publish a single platform event](#publish-a-single-platform-event) 19 | - [Publish a batch of platform events](#publish-a-batch-of-platform-events) 20 | - [Subscribe with a replay ID](#subscribe-with-a-replay-id) 21 | - [Subscribe to past events in retention window](#subscribe-to-past-events-in-retention-window) 22 | - [Subscribe using a managed subscription](#subscribe-using-a-managed-subscription) 23 | - [Work with flow control for high volumes of events](#work-with-flow-control-for-high-volumes-of-events) 24 | - [Handle gRPC stream lifecycle events](#handle-grpc-stream-lifecycle-events) 25 | - [Common Issues](#common-issues) 26 | - [Reference](#reference) 27 | - [PubSubApiClient](#pubsubapiclient) 28 | - [PublishCallback](#publishcallback) 29 | - [SubscribeCallback](#subscribecallback) 30 | - [SubscriptionInfo](#subscriptioninfo) 31 | - [EventParseError](#eventparseerror) 32 | - [Configuration](#configuration) 33 | 34 | This project bundles and uses CA root certificates from the [python-ceritfi](https://github.com/certifi/python-certifi/blob/master/certifi/cacert.pem) project. 35 | 36 | ## v4 to v5 Migration 37 | 38 | > [!WARNING] 39 | > Version 5 of the Pub/Sub API client introduces a couple of breaking changes which require a small migration effort. Read this section for an overview of the changes. 40 | 41 | ### Configuration and Connection 42 | 43 | In v4 and earlier versions of this client: 44 | 45 | - you specify the configuration in a `.env` file with specific property names. 46 | - you connect with either the `connect()` or `connectWithAuth()` method depending on the authentication flow. 47 | 48 | In v5: 49 | 50 | - you pass your configuration with an object in the client constructor. The `.env` file is no longer a requirement, you are free to store your configuration where you want. 51 | - you connect with a unique [`connect()`](#async-connect--promisevoid) method. 52 | 53 | ### Event handling 54 | 55 | In v4 and earlier versions of this client you use an asynchronous `EventEmitter` to receive updates such as incoming messages or lifecycle events: 56 | 57 | ```js 58 | // Subscribe to account change events 59 | const eventEmitter = await client.subscribe( 60 | '/data/AccountChangeEvent' 61 | ); 62 | 63 | // Handle incoming events 64 | eventEmitter.on('data', (event) => { 65 | // Event handling logic goes here 66 | }): 67 | ``` 68 | 69 | In v5 you use a synchronous callback function to receive the same information. This helps to ensure that events are received in the right order. 70 | 71 | ```js 72 | const subscribeCallback = (subscription, callbackType, data) => { 73 | // Event handling logic goes here 74 | }; 75 | 76 | // Subscribe to account change events 77 | await client.subscribe('/data/AccountChangeEvent', subscribeCallback); 78 | ``` 79 | 80 | ## Installation and Configuration 81 | 82 | Install the client library with `npm install salesforce-pubsub-api-client`. 83 | 84 | ### Authentication 85 | 86 | Pick one of these authentication flows and pass the relevant configuration to the `PubSubApiClient` constructor: 87 | 88 | - [User supplied authentication](#user-supplied-authentication) 89 | - [Username/password flow](#usernamepassword-flow) (recommended for tests) 90 | - [OAuth 2.0 client flow](#oauth-20-client-credentials-flow-client_credentials) 91 | - [OAuth 2.0 JWT Bearer flow](#oauth-20-jwt-bearer-flow) (recommended for production) 92 | 93 | #### User supplied authentication 94 | 95 | If you already have a Salesforce client in your app, you can reuse its authentication information. 96 | In the example below, we assume that `sfConnection` is a connection obtained with [jsforce](https://jsforce.github.io/) 97 | 98 | ```js 99 | const client = new PubSubApiClient({ 100 | authType: 'user-supplied', 101 | accessToken: sfConnection.accessToken, 102 | instanceUrl: sfConnection.instanceUrl, 103 | organizationId: sfConnection.userInfo.organizationId 104 | }); 105 | ``` 106 | 107 | #### Username/password flow 108 | 109 | > [!WARNING] 110 | > Relying on a username/password authentication flow for production is not recommended. Consider switching to JWT auth for extra security. 111 | 112 | ```js 113 | const client = new PubSubApiClient({ 114 | authType: 'username-password', 115 | loginUrl: process.env.SALESFORCE_LOGIN_URL, 116 | username: process.env.SALESFORCE_USERNAME, 117 | password: process.env.SALESFORCE_PASSWORD, 118 | userToken: process.env.SALESFORCE_TOKEN 119 | }); 120 | ``` 121 | 122 | #### OAuth 2.0 client credentials flow (client_credentials) 123 | 124 | ```js 125 | const client = new PubSubApiClient({ 126 | authType: 'oauth-client-credentials', 127 | loginUrl: process.env.SALESFORCE_LOGIN_URL, 128 | clientId: process.env.SALESFORCE_CLIENT_ID, 129 | clientSecret: process.env.SALESFORCE_CLIENT_SECRET 130 | }); 131 | ``` 132 | 133 | #### OAuth 2.0 JWT bearer flow 134 | 135 | This is the most secure authentication option. Recommended for production use. 136 | 137 | ```js 138 | // Read private key file 139 | const privateKey = fs.readFileSync(process.env.SALESFORCE_PRIVATE_KEY_FILE); 140 | 141 | // Build PubSub client 142 | const client = new PubSubApiClient({ 143 | authType: 'oauth-jwt-bearer', 144 | loginUrl: process.env.SALESFORCE_JWT_LOGIN_URL, 145 | clientId: process.env.SALESFORCE_JWT_CLIENT_ID, 146 | username: process.env.SALESFORCE_USERNAME, 147 | privateKey 148 | }); 149 | ``` 150 | 151 | ### Logging 152 | 153 | The client uses debug level messages so you can lower the default logging level if you need more information. 154 | 155 | The documentation examples use the default client logger (the console). The console is fine for a test environment but you'll want to switch to a custom logger with asynchronous logging for increased performance. 156 | 157 | You can pass a logger like pino in the client constructor: 158 | 159 | ```js 160 | import pino from 'pino'; 161 | 162 | const config = { 163 | /* your config goes here */ 164 | }; 165 | const logger = pino(); 166 | const client = new PubSubApiClient(config, logger); 167 | ``` 168 | 169 | ## Quick Start Example 170 | 171 | Here's an example that will get you started quickly. It listens to up to 3 account change events. Once the third event is reached, the client closes gracefully. 172 | 173 | 1. Activate Account change events in **Salesforce Setup > Change Data Capture**. 174 | 175 | 1. Install the client and `dotenv` in your project: 176 | 177 | ```sh 178 | npm install salesforce-pubsub-api-client dotenv 179 | ``` 180 | 181 | 1. Create a `.env` file at the root of the project and replace the values: 182 | 183 | ```properties 184 | SALESFORCE_LOGIN_URL=... 185 | SALESFORCE_USERNAME=... 186 | SALESFORCE_PASSWORD=... 187 | SALESFORCE_TOKEN=... 188 | ``` 189 | 190 | 1. Create a `sample.js` file with the following content: 191 | 192 | ```js 193 | import * as dotenv from 'dotenv'; 194 | import PubSubApiClient from 'salesforce-pubsub-api-client'; 195 | 196 | async function run() { 197 | try { 198 | // Load config from .env file 199 | dotenv.config(); 200 | 201 | // Build and connect Pub/Sub API client 202 | const client = new PubSubApiClient({ 203 | authType: 'username-password', 204 | loginUrl: process.env.SALESFORCE_LOGIN_URL, 205 | username: process.env.SALESFORCE_USERNAME, 206 | password: process.env.SALESFORCE_PASSWORD, 207 | userToken: process.env.SALESFORCE_TOKEN 208 | }); 209 | await client.connect(); 210 | 211 | // Prepare event callback 212 | const subscribeCallback = (subscription, callbackType, data) => { 213 | switch (callbackType) { 214 | case 'event': 215 | // Event received 216 | console.log( 217 | `${subscription.topicName} - Handling ${data.payload.ChangeEventHeader.entityName} change event ` + 218 | `with ID ${data.replayId} ` + 219 | `(${subscription.receivedEventCount}/${subscription.requestedEventCount} ` + 220 | `events received so far)` 221 | ); 222 | // Safely log event payload as a JSON string 223 | console.log( 224 | JSON.stringify( 225 | data, 226 | (key, value) => 227 | /* Convert BigInt values into strings and keep other types unchanged */ 228 | typeof value === 'bigint' 229 | ? value.toString() 230 | : value, 231 | 2 232 | ) 233 | ); 234 | break; 235 | case 'lastEvent': 236 | // Last event received 237 | console.log( 238 | `${subscription.topicName} - Reached last of ${subscription.requestedEventCount} requested event on channel. Closing connection.` 239 | ); 240 | break; 241 | case 'end': 242 | // Client closed the connection 243 | console.log('Client shut down gracefully.'); 244 | break; 245 | } 246 | }; 247 | 248 | // Subscribe to 3 account change event 249 | client.subscribe('/data/AccountChangeEvent', subscribeCallback, 3); 250 | } catch (error) { 251 | console.error(error); 252 | } 253 | } 254 | 255 | run(); 256 | ``` 257 | 258 | 1. Run the project with `node sample.js` 259 | 260 | If everything goes well, you'll see output like this: 261 | 262 | ``` 263 | Connected to Salesforce org https://pozil-dev-ed.my.salesforce.com (00D58000000arpqEAA) as grpc@pozil.com 264 | Connected to Pub/Sub API endpoint api.pubsub.salesforce.com:7443 265 | /data/AccountChangeEvent - Subscribe request sent for 3 events 266 | ``` 267 | 268 | At this point, the script is on hold and waits for events. 269 | 270 | 1. Modify an account record in Salesforce. This fires an account change event. 271 | 272 | Once the client receives an event, it displays it like this: 273 | 274 | ``` 275 | /data/AccountChangeEvent - Received 1 events, latest replay ID: 18098167 276 | /data/AccountChangeEvent - Handling Account change event with ID 18098167 (1/3 events received so far) 277 | { 278 | "id": "9b77cea1-a923-4766-ad50-a1797d9b39fd", 279 | "schemaId": "a01VpgrsZNJdn-7KStJcxQ", 280 | "replayId": 18098167, 281 | "payload": { 282 | "ChangeEventHeader": { 283 | "entityName": "Account", 284 | "recordIds": [ 285 | "0014H00002LbR7QQAV" 286 | ], 287 | "changeType": "UPDATE", 288 | "changeOrigin": "com/salesforce/api/soap/58.0;client=SfdcInternalAPI/", 289 | "transactionKey": "000046c7-a642-11e2-c29b-229c6786473e", 290 | "sequenceNumber": 1, 291 | "commitTimestamp": 1696444513000, 292 | "commitNumber": 11657372702432, 293 | "commitUser": "00558000000yFyDAAU", 294 | "nulledFields": [], 295 | "diffFields": [], 296 | "changedFields": [ 297 | "LastModifiedDate", 298 | "BillingAddress.City", 299 | "BillingAddress.State" 300 | ] 301 | }, 302 | "Name": null, 303 | "Type": null, 304 | "ParentId": null, 305 | "BillingAddress": { 306 | "Street": null, 307 | "City": "San Francisco", 308 | "State": "CA", 309 | "PostalCode": null, 310 | "Country": null, 311 | "StateCode": null, 312 | "CountryCode": null, 313 | "Latitude": null, 314 | "Longitude": null, 315 | "Xyz": null, 316 | "GeocodeAccuracy": null 317 | }, 318 | "ShippingAddress": null, 319 | "Phone": null, 320 | "Fax": null, 321 | "AccountNumber": null, 322 | "Website": null, 323 | "Sic": null, 324 | "Industry": null, 325 | "AnnualRevenue": null, 326 | "NumberOfEmployees": null, 327 | "Ownership": null, 328 | "TickerSymbol": null, 329 | "Description": null, 330 | "Rating": null, 331 | "Site": null, 332 | "OwnerId": null, 333 | "CreatedDate": null, 334 | "CreatedById": null, 335 | "LastModifiedDate": 1696444513000, 336 | "LastModifiedById": null, 337 | "Jigsaw": null, 338 | "JigsawCompanyId": null, 339 | "CleanStatus": null, 340 | "AccountSource": null, 341 | "DunsNumber": null, 342 | "Tradestyle": null, 343 | "NaicsCode": null, 344 | "NaicsDesc": null, 345 | "YearStarted": null, 346 | "SicDesc": null, 347 | "DandbCompanyId": null 348 | } 349 | } 350 | ``` 351 | 352 | Note that the change event payloads include all object fields but fields that haven't changed are null. In the above example, the only changes are the Billing State, Billing City and Last Modified Date. 353 | 354 | Use the values from `ChangeEventHeader.nulledFields`, `ChangeEventHeader.diffFields` and `ChangeEventHeader.changedFields` to identify actual value changes. 355 | 356 | ## Other Examples 357 | 358 | ### Publish a single platform event 359 | 360 | > [!NOTE] 361 | > For best performances, use `publishBatch` when publishing event batches. 362 | 363 | Publish a single `Sample__e` platform events with a `Message__c` field using [publish](#async-publishtopicname-payload-correlationkey--promisepublishresult): 364 | 365 | ```js 366 | const payload = { 367 | CreatedDate: new Date().getTime(), // Non-null value required but there's no validity check performed on this field 368 | CreatedById: '005_________', // Valid user ID 369 | Message__c: { string: 'Hello world' } // Field is nullable so we need to specify the 'string' type 370 | }; 371 | const publishResult = await client.publish('/event/Sample__e', payload); 372 | console.log('Published event: ', JSON.stringify(publishResult)); 373 | ``` 374 | 375 | ### Publish a batch of platform events 376 | 377 | Publish a batch of `Sample__e` platform events using [publishBatch](#async-publishbatchtopicname-events-publishcallback): 378 | 379 | ```js 380 | // Prepare publish callback 381 | const publishCallback = (info, callbackType, data) => { 382 | switch (callbackType) { 383 | case 'publishResponse': 384 | console.log(JSON.stringify(data)); 385 | break; 386 | } 387 | }; 388 | 389 | // Prepare events 390 | const events = [ 391 | { 392 | payload: { 393 | CreatedDate: new Date().getTime(), // Non-null value required but there's no validity check performed on this field 394 | CreatedById: '005_________', // Valid user ID 395 | Message__c: { string: 'Hello world' } // Field is nullable so we need to specify the 'string' type 396 | } 397 | } 398 | ]; 399 | 400 | // Publish event batch 401 | client.publishBatch('/event/Sample__e', events, publishCallback); 402 | ``` 403 | 404 | ### Subscribe with a replay ID 405 | 406 | Subscribe to 5 account change events starting from a replay ID: 407 | 408 | ```js 409 | await client.subscribeFromReplayId( 410 | '/data/AccountChangeEvent', 411 | subscribeCallback, 412 | 5, 413 | 17092989 414 | ); 415 | ``` 416 | 417 | ### Subscribe to past events in retention window 418 | 419 | Subscribe to the 3 earliest past account change events in the retention window: 420 | 421 | ```js 422 | await client.subscribeFromEarliestEvent( 423 | '/data/AccountChangeEvent', 424 | subscribeCallback, 425 | 3 426 | ); 427 | ``` 428 | 429 | ### Subscribe using a managed subscription 430 | 431 | You can turn your Pub/Sub client application stateless by delegating the tracking of replay IDs to the server thanks to [managed event subscriptions](https://developer.salesforce.com/docs/platform/pub-sub-api/guide/managed-sub.html). 432 | 433 | 1. [Create a managed event subscription](https://developer.salesforce.com/docs/platform/pub-sub-api/guide/managed-sub.html#configuring-a-managed-event-subscription) using the tooling API. You can use API request templates from the [Salesforce Platform APIs](https://www.postman.com/salesforce-developers/salesforce-developers/folder/00bu8y3/managed-event-subscriptions) Postman collection to do so. 434 | 1. Subscribe to 3 events from a managed event subscription (`Managed_Sample_PE` in this expample): 435 | ```js 436 | await client.subscribeWithManagedSubscription( 437 | 'Managed_Sample_PE', 438 | subscribeCallback, 439 | 3 440 | ); 441 | ``` 442 | 1. Using the subscription information sent in the subscribe callback, frequently commit the last replay ID that you receveive: 443 | ```js 444 | client.commitReplayId( 445 | subscription.subscriptionId, 446 | subscription.lastReplayId 447 | ); 448 | ``` 449 | 1. Optionnaly, request additional events to be sent (3 more in this example): 450 | ```js 451 | client.requestAdditionalManagedEvents(subscription.subscriptionId, 3); 452 | ``` 453 | 454 | ### Work with flow control for high volumes of events 455 | 456 | When working with high volumes of events you can control the incoming flow of events by requesting a limited batch of events. This event flow control ensures that the client doesn’t get overwhelmed by accepting more events that it can handle if there is a spike in event publishing. 457 | 458 | This is the overall process: 459 | 460 | 1. Pass a number of requested events in your subscribe call. 461 | 1. Handle the `lastEvent` [callback type](#subscribecallback) from subscribe callback to detect the end of the event batch. 462 | 1. Subscribe to an additional batch of events with `client.requestAdditionalEvents(...)`. If you don't request additional events at this point, the gRPC subscription will close automatically (default Pub/Sub API behavior). 463 | 464 | The code below illustrate how you can achieve event flow control: 465 | 466 | ```js 467 | try { 468 | // Connect with the Pub/Sub API 469 | const client = new PubSubApiClient(/* config goes here */); 470 | await client.connect(); 471 | 472 | // Prepare event callback 473 | const subscribeCallback = (subscription, callbackType, data) => { 474 | switch (callbackType) { 475 | case 'event': 476 | // Logic for handling a single event. 477 | // Unless you request additional events later, this should get called up to 10 times 478 | // given the initial subscription boundary. 479 | break; 480 | case 'lastEvent': 481 | // Last event received 482 | console.log( 483 | `${eventEmitter.getTopicName()} - Reached last requested event on channel.` 484 | ); 485 | // Request 10 additional events 486 | client.requestAdditionalEvents(eventEmitter, 10); 487 | break; 488 | case 'end': 489 | // Client closed the connection 490 | console.log('Client shut down gracefully.'); 491 | break; 492 | } 493 | }; 494 | 495 | // Subscribe to a batch of 10 account change event 496 | await client.subscribe('/data/AccountChangeEvent', subscribeCallback 10); 497 | } catch (error) { 498 | console.error(error); 499 | } 500 | ``` 501 | 502 | ### Handle gRPC stream lifecycle events 503 | 504 | Use callback types from subscribe callback to handle gRPC stream lifecycle events: 505 | 506 | ```js 507 | const subscribeCallback = (subscription, callbackType, data) => { 508 | if (callbackType === 'grpcStatus') { 509 | // Stream status update 510 | console.log('gRPC stream status: ', status); 511 | } else if (callbackType === 'error') { 512 | // Stream error 513 | console.error('gRPC stream error: ', JSON.stringify(error)); 514 | } else if (callbackType === 'end') { 515 | // Stream end 516 | console.log('gRPC stream ended'); 517 | } 518 | }; 519 | ``` 520 | 521 | ## Common Issues 522 | 523 | ### TypeError: Do not know how to serialize a BigInt 524 | 525 | If you attempt to call `JSON.stringify` on an event you will likely see the following error: 526 | 527 | > TypeError: Do not know how to serialize a BigInt 528 | 529 | This happens when an integer value stored in an event field exceeds the range of the `Number` JS type (this typically happens with `commitNumber` values). In this case, we use a `BigInt` type to safely store the integer value. However, the `BigInt` type is not yet supported in standard JSON representation (see step 10 in the [BigInt TC39 spec](https://tc39.es/proposal-bigint/#sec-serializejsonproperty)) so this triggers a `TypeError`. 530 | 531 | To avoid this error, use a replacer function to safely escape BigInt values so that they can be serialized as a string (or any other format of your choice) in JSON: 532 | 533 | ```js 534 | // Safely log event as a JSON string 535 | console.log( 536 | JSON.stringify( 537 | event, 538 | (key, value) => 539 | /* Convert BigInt values into strings and keep other types unchanged */ 540 | typeof value === 'bigint' ? value.toString() : value, 541 | 2 542 | ) 543 | ); 544 | ``` 545 | 546 | ## Reference 547 | 548 | ### PubSubApiClient 549 | 550 | Client for the Salesforce Pub/Sub API 551 | 552 | #### `PubSubApiClient(configuration, [logger])` 553 | 554 | Builds a new Pub/Sub API client. 555 | 556 | | Name | Type | Description | 557 | | --------------- | ------------------------------- | ------------------------------------------------------------------------------------------- | 558 | | `configuration` | [Configuration](#configuration) | The client configuration (authentication...). | 559 | | `logger` | Logger | An optional [custom logger](#logging). The client uses the console if no value is supplied. | 560 | 561 | #### `close()` 562 | 563 | Closes the gRPC connection. The client will no longer receive events for any topic. 564 | 565 | #### `commitReplayId(subscriptionId, replayId) → string` 566 | 567 | Commits a replay ID on a managed subscription. 568 | 569 | Returns: commit request UUID. 570 | 571 | | Name | Type | Description | 572 | | ---------------- | ------ | ----------------------- | 573 | | `subscriptionId` | string | managed subscription ID | 574 | | `replayId` | number | event replay ID | 575 | 576 | #### `async connect() → {Promise.}` 577 | 578 | Authenticates with Salesforce then connects to the Pub/Sub API. 579 | 580 | Returns: Promise that resolves once the connection is established. 581 | 582 | #### `async getConnectivityState() → {Promise}` 583 | 584 | Gets the gRPC connectivity state from the current channel. 585 | 586 | Returns: Promise that holds the channel's [connectivity state](https://grpc.github.io/grpc/node/grpc.html#.connectivityState). 587 | 588 | #### `async publish(topicName, payload, [correlationKey]) → {Promise.}` 589 | 590 | Publishes an payload to a topic using the gRPC client. This is a synchronous operation, use `publishBatch` when publishing event batches. 591 | 592 | Returns: Promise holding a `PublishResult` object with `replayId` and `correlationKey`. 593 | 594 | | Name | Type | Description | 595 | | ---------------- | ------ | ----------------------------------------------------------------------------------------- | 596 | | `topicName` | string | name of the topic that we're publishing on | 597 | | `payload` | Object | payload of the event that is being published | 598 | | `correlationKey` | string | optional correlation key. If you don't provide one, we'll generate a random UUID for you. | 599 | 600 | #### `async publishBatch(topicName, events, publishCallback)` 601 | 602 | Publishes a batch of events using the gRPC client's publish stream. 603 | 604 | | Name | Type | Description | 605 | | ----------------- | ----------------------------------- | ------------------------------------------------- | 606 | | `topicName` | string | name of the topic that we're publishing on | 607 | | `events` | [PublisherEvent](#publisherEvent)[] | events to be published | 608 | | `publishCallback` | [PublishCallback](#publishCallback) | callback function for handling publish responses. | 609 | 610 | #### `async subscribe(topicName, subscribeCallback, [numRequested])` 611 | 612 | Subscribes to a topic. 613 | 614 | | Name | Type | Description | 615 | | ------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------- | 616 | | `topicName` | string | name of the topic that we're subscribing to | 617 | | `subscribeCallback` | [SubscribeCallback](#subscribecallback) | subscribe callback function | 618 | | `numRequested` | number | optional number of events requested. If not supplied or null, the client keeps the subscription alive forever. | 619 | 620 | #### `async subscribeFromEarliestEvent(topicName, subscribeCallback, [numRequested])` 621 | 622 | Subscribes to a topic and retrieves all past events in retention window. 623 | 624 | | Name | Type | Description | 625 | | ------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------- | 626 | | `topicName` | string | name of the topic that we're subscribing to | 627 | | `subscribeCallback` | [SubscribeCallback](#subscribecallback) | subscribe callback function | 628 | | `numRequested` | number | optional number of events requested. If not supplied or null, the client keeps the subscription alive forever. | 629 | 630 | #### `async subscribeFromReplayId(topicName, subscribeCallback, numRequested, replayId)` 631 | 632 | Subscribes to a topic and retrieves past events starting from a replay ID. 633 | 634 | | Name | Type | Description | 635 | | ------------------- | --------------------------------------- | --------------------------------------------------------------------------------------- | 636 | | `topicName` | string | name of the topic that we're subscribing to | 637 | | `subscribeCallback` | [SubscribeCallback](#subscribecallback) | subscribe callback function | 638 | | `numRequested` | number | number of events requested. If `null`, the client keeps the subscription alive forever. | 639 | | `replayId` | number | replay ID | 640 | 641 | #### `async subscribeWithManagedSubscription(subscriptionIdOrName, subscribeCallback, [numRequested])` 642 | 643 | Subscribes to a topic thanks to a managed subscription. 644 | 645 | Throws an error if the managed subscription does not exist or is not in the `RUN` state. 646 | 647 | | Name | Type | Description | 648 | | ---------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------- | 649 | | `subscriptionIdOrName` | string | managed subscription ID or developer name | 650 | | `subscribeCallback` | [SubscribeCallback](#subscribecallback) | subscribe callback function | 651 | | `numRequested` | number | optional number of events requested. If not supplied or null, the client keeps the subscription alive forever. | 652 | 653 | #### `requestAdditionalEvents(topicName, numRequested)` 654 | 655 | Request additional events on an existing subscription. 656 | 657 | | Name | Type | Description | 658 | | -------------- | ------ | --------------------------- | 659 | | `topicName` | string | name of the topic. | 660 | | `numRequested` | number | number of events requested. | 661 | 662 | #### `requestAdditionalManagedEvents(subscriptionId, numRequested)` 663 | 664 | Request additional events on an existing managed subscription. 665 | 666 | | Name | Type | Description | 667 | | ---------------- | ------ | --------------------------- | 668 | | `subscriptionId` | string | managed subscription ID. | 669 | | `numRequested` | number | number of events requested. | 670 | 671 | ### PublishCallback 672 | 673 | Callback function that lets you process batch publish responses. 674 | 675 | The function takes three parameters: 676 | 677 | | Name | Type | Description | 678 | | -------------- | ----------------------- | --------------------------------------------------------------------- | 679 | | `info` | `{ topicName: string }` | callback information | 680 | | `callbackType` | string | name of the callback type (see table below). | 681 | | `data` | [Object] | data that is passed with the callback (depends on the callback type). | 682 | 683 | Callback types: 684 | 685 | | Name | Callback Data | Description | 686 | | ----------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------- | 687 | | `publishResponse` | [PublishResponse](#publishresponse) | Client received a publish response. The attached data is the publish confirmation for a batch of events. | 688 | | `error` | Object | Signals an event publishing error or a gRPC stream error. | 689 | | `grpcKeepalive` | `{ schemaId: string, rpcId: string }` | Server publishes this gRPC keep alive message every 270 seconds (or less) if there are no events. | 690 | | `grpcStatus` | Object | Misc gRPC stream status information. | 691 | 692 | #### PublishResponse 693 | 694 | | Name | Type | Description | 695 | | ---------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------- | 696 | | `schemaId` | string | topic schema ID | 697 | | `rpcId` | string | RPC ID | 698 | | `results` | `{ replayId: string, correlationKey: string }[]` | Event publish confirmations. Each confirmation contains the replay ID and a correlation key. | 699 | 700 | ### SubscribeCallback 701 | 702 | Callback function that lets you process incoming Pub/Sub API events while keeping track of the topic name and the volume of events requested/received. 703 | 704 | The function takes three parameters: 705 | 706 | | Name | Type | Description | 707 | | -------------- | ------------------------------------- | --------------------------------------------------------------------- | 708 | | `subscription` | [SubscriptionInfo](#subscriptioninfo) | subscription information | 709 | | `callbackType` | string | name of the callback type (see table below). | 710 | | `data` | [Object] | data that is passed with the callback (depends on the callback type). | 711 | 712 | Callback types: 713 | 714 | | Name | Callback Data | Description | 715 | | --------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | 716 | | `event` | Object | Client received a new event. The attached data is the parsed event data. | 717 | | `error` | [EventParseError](#eventparseerror) or Object | Signals an event parsing error or a gRPC stream error. | 718 | | `lastEvent` | void | Signals that we received the last event that the client requested. The stream will end shortly. | 719 | | `end` | void | Signals the end of the gRPC stream. | 720 | | `grpcKeepalive` | `{ latestReplayId: number, pendingNumRequested: number }` | Server publishes this gRPC keep alive message every 270 seconds (or less) if there are no events. | 721 | | `grpcStatus` | Object | Misc gRPC stream status information. | 722 | 723 | ### SubscriptionInfo 724 | 725 | Holds the information related to a subscription. 726 | 727 | | Name | Type | Description | 728 | | --------------------- | ------- | ------------------------------------------------------------------------------ | 729 | | `isManaged` | boolean | whether this is a managed event subscription or not. | 730 | | `topicName` | string | topic name for this subscription. | 731 | | `subscriptionId` | string | managed subscription ID. Undefined for regular subscriptions. | 732 | | `subscriptionName` | string | managed subscription name. Undefined for regular subscriptions. | 733 | | `requestedEventCount` | number | number of events that were requested when subscribing. | 734 | | `receivedEventCount` | number | the number of events that were received since subscribing. | 735 | | `lastReplayId` | number | replay ID of the last processed event or `null` if no event was processed yet. | 736 | 737 | ### EventParseError 738 | 739 | Holds the information related to an event parsing error. This class attempts to extract the event replay ID from the event that caused the error. 740 | 741 | | Name | Type | Description | 742 | | ---------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------ | 743 | | `message` | string | The error message. | 744 | | `cause` | Error | The cause of the error. | 745 | | `replayId` | number | The replay ID of the event at the origin of the error. Could be undefined if we're not able to extract it from the event data. | 746 | | `event` | Object | The un-parsed event data at the origin of the error. | 747 | | `latestReplayId` | number | The latest replay ID that was received before the error. | 748 | 749 | ### Configuration 750 | 751 | Check out the [authentication](#authentication) section for more information on how to provide the right values. 752 | 753 | | Name | Type | Description | 754 | | ----------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 755 | | `authType` | string | Authentication type. One of `user-supplied`, `username-password`, `oauth-client-credentials` or `oauth-jwt-bearer`. | 756 | | `pubSubEndpoint` | string | A custom Pub/Sub API endpoint. The default endpoint `api.pubsub.salesforce.com:7443` is used if none is supplied. | 757 | | `accessToken` | string | Salesforce access token. | 758 | | `instanceUrl` | string | Salesforce instance URL. | 759 | | `organizationId` | string | Optional organization ID. If you don't provide one, we'll attempt to parse it from the accessToken. | 760 | | `loginUrl` | string | Salesforce login host. One of `https://login.salesforce.com`, `https://test.salesforce.com` or your domain specific host. | 761 | | `clientId` | string | Connected app client ID. | 762 | | `clientSecret` | string | Connected app client secret. | 763 | | `privateKey` | string | Private key content. | 764 | | `username` | string | Salesforce username. | 765 | | `password` | string | Salesforce user password. | 766 | | `userToken` | string | Salesforce user security token. | 767 | | `rejectUnauthorizedSsl` | boolean | Optional flag used to accept self-signed SSL certificates for testing purposes when set to `false`. Default is `true` (client rejects self-signed SSL certificates). | 768 | --------------------------------------------------------------------------------