@salesforce/core
Version:
Core libraries to interact with SFDX projects, orgs, and APIs.
446 lines • 20.4 kB
JavaScript
"use strict";
/*
* Copyright (c) 2020, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.StreamingClient = exports.CometClient = void 0;
/* eslint-disable @typescript-eslint/ban-ts-comment */
const node_url_1 = require("node:url");
const lib_1 = require("@salesforce/kit/lib");
const lib_2 = require("@salesforce/ts-types/lib");
const faye_1 = __importDefault(require("faye"));
const logger_1 = require("../logger/logger");
const sfError_1 = require("../sfError");
const messages_1 = require("../messages");
const types_1 = require("./types");
Object.defineProperty(exports, "CometClient", { enumerable: true, get: function () { return types_1.CometClient; } });
;
const messages = new messages_1.Messages('@salesforce/core', 'streaming', new Map([["genericTimeout", "Socket timeout occurred while listening for results."], ["genericHandshakeTimeout", "The streaming request failed to handshake at %s."], ["handshakeApiVersionError", "Invalid API version specified for streaming connection handshake: %s"], ["handshakeApiVersionError.actions", ["Set the API version to match the org as an environment variable (e.g., SFDX_API_VERSION=XX.0)."]], ["waitParamValidValueError", "Invalid value was specified for wait. Please provide a wait value greater than %s minutes."], ["invalidApiVersion", "Invalid api version is being reported by config (apiVersion=%s)."]]));
/**
* Validation helper
*
* @param newTime New Duration to validate.
* @param existingTime Existing time to validate.
*/
function validateTimeout(newTime, existingTime) {
if (newTime.milliseconds >= existingTime.milliseconds) {
return newTime;
}
throw messages.createError('waitParamValidValueError', [existingTime.minutes]);
}
/**
* Api wrapper to support Salesforce streaming. The client contains an internal implementation of a cometd specification.
*
* Salesforce client and timeout information
*
* Streaming API imposes two timeouts, as supported in the Bayeux protocol.
*
* Socket timeout: 110 seconds
* A client receives events (JSON-formatted HTTP responses) while it waits on a connection. If no events are generated
* and the client is still waiting, the connection times out after 110 seconds and the server closes the connection.
* Clients should reconnect before two minutes to avoid the connection timeout.
*
* Reconnect timeout: 40 seconds
* After receiving the events, a client needs to reconnect to receive the next set of events. If the reconnection
* doesn't happen within 40 seconds, the server expires the subscription and the connection is closed. If this happens,
* the client must start again and handshake, subscribe, and connect. Each Streaming API client logs into an instance
* and maintains a session. When the client handshakes, connects, or subscribes, the session timeout is restarted. A
* client session times out if the client doesn’t reconnect to the server within 40 seconds after receiving a response
* (an event, subscribe result, and so on).
*
* Note that these timeouts apply to the Streaming API client session and not the Salesforce authentication session. If
* the client session times out, the authentication session remains active until the organization-specific timeout
* policy goes into effect.
*
* ```
* const streamProcessor = (message: JsonMap): StatusResult => {
* const payload = ensureJsonMap(message.payload);
* const id = ensureString(payload.id);
*
* if (payload.status !== 'Active') {
* return { completed: false };
* }
*
* return {
* completed: true,
* payload: id
* };
* };
*
* const org = await Org.create();
* const options = new StreamingClient.DefaultOptions(org, 'MyPushTopics', streamProcessor);
*
* const asyncStatusClient = await StreamingClient.create(options);
*
* await asyncStatusClient.handshake();
*
* const info: RequestInfo = {
* method: 'POST',
* url: `${org.getField(OrgFields.INSTANCE_URL)}/SomeService`,
* headers: { HEADER: 'HEADER_VALUE'},
* body: 'My content'
* };
*
* await asyncStatusClient.subscribe(async () => {
* const connection = await org.getConnection();
* // Now that we are subscribed, we can initiate the request that will cause the events to start streaming.
* const requestResponse: JsonCollection = await connection.request(info);
* const id = ensureJsonMap(requestResponse).id;
* console.log(`this.id: ${JSON.stringify(ensureString(id), null, 4)}`);
* });
* ```
*/
class StreamingClient extends lib_1.AsyncOptionalCreatable {
targetUrl;
options;
logger;
cometClient;
/**
* Constructor
*
* @param options Streaming client options
* {@link AsyncCreatable.create}
*/
constructor(options) {
super(options);
this.options = (0, lib_2.ensure)(options);
const instanceUrl = (0, lib_2.ensure)(this.options.org.getConnection().getAuthInfoFields().instanceUrl);
/**
* The salesforce network infrastructure issues a cookie called sfdx-stream if it sees /cometd in the url.
* Without this cookie request response streams will experience intermittent client session failures.
*
* The following cookies should be sent on a /meta/handshake
*
* "set-cookie": [
* "BrowserId=<ID>;Path=/;Domain=.salesforce.com;Expires=Sun, 13-Jan-2019 20:16:19 GMT;Max-Age=5184000",
* "t=<ID>;Path=/cometd/;HttpOnly",
* "BAYEUX_BROWSER=<ID>;Path=/cometd/;Secure",
* "sfdc-stream=<ID>; expires=Wed, 14-Nov-2018 23:16:19 GMT; path=/"
* ],
*
* Enable SFDX_ENABLE_FAYE_REQUEST_RESPONSE_LOGGING to debug potential session problems and to verify cookie
* exchanges.
*/
this.targetUrl = (0, node_url_1.resolve)(instanceUrl, `cometd/${this.options.apiVersion}`);
this.cometClient = this.options.streamingImpl.getCometClient(this.targetUrl);
this.options.streamingImpl.setLogger(this.log.bind(this));
this.cometClient.on('transport:up', () => this.log('Transport up event received'));
this.cometClient.on('transport:down', () => this.log('Transport down event received'));
this.cometClient.addExtension({
incoming: this.incoming.bind(this),
});
this.cometClient.disable('websocket');
}
/**
* Asynchronous initializer.
*/
async init() {
// get the apiVersion from the connection if not already an option
const conn = this.options.org.getConnection();
this.options.apiVersion = this.options.apiVersion || conn.getApiVersion();
this.logger = await logger_1.Logger.child(this.constructor.name);
await this.options.org.refreshAuth();
const accessToken = conn.getConnectionOptions().accessToken;
if (accessToken && accessToken.length > 5) {
this.logger.debug(`accessToken: XXXXXX${accessToken.substring(accessToken.length - 5, accessToken.length - 1)}`);
this.cometClient.setHeader('Authorization', `OAuth ${accessToken}`);
}
else {
throw new sfError_1.SfError('Missing or invalid access token', 'MissingOrInvalidAccessToken');
}
this.log(`Streaming client target url: ${this.targetUrl}`);
this.log(`options.subscribeTimeout (ms): ${this.options.subscribeTimeout.milliseconds}`);
this.log(`options.handshakeTimeout (ms): ${this.options.handshakeTimeout.milliseconds}`);
}
/**
* Allows replaying of of Streaming events starting with replayId.
*
* @param replayId The starting message id to replay from.
*/
replay(replayId) {
this.cometClient.addExtension({
outgoing: (message, callback) => {
if (message.channel === '/meta/subscribe') {
if (!message.ext) {
// eslint-disable-next-line no-param-reassign
message.ext = {};
}
const replayFromMap = {};
replayFromMap[this.options.channel] = replayId;
// add "ext : { "replay" : { CHANNEL : REPLAY_VALUE }}" to subscribe message
(0, lib_1.set)(message, 'ext.replay', replayFromMap);
}
callback(message);
},
});
}
/**
* Provides a convenient way to handshake with the server endpoint before trying to subscribe.
*/
handshake() {
let timeout;
return new Promise((resolve, reject) => {
timeout = setTimeout(() => {
const timeoutError = messages.createError('genericHandshakeTimeout', [this.targetUrl]);
this.doTimeout(timeout, timeoutError);
reject(timeoutError);
}, this.options.handshakeTimeout.milliseconds);
this.cometClient.handshake(() => {
this.log('handshake completed');
clearTimeout(timeout);
this.log('cleared handshake timeout');
resolve(StreamingClient.ConnectionState.CONNECTED);
});
});
}
/**
* Subscribe to streaming events. When the streaming processor that's set in the options completes execution it
* returns a payload in the StatusResult object. The payload is just echoed here for convenience.
*
* **Throws** *{@link SfError}{ name: '{@link StreamingClient.TimeoutErrorType.SUBSCRIBE}'}* When the subscribe timeout occurs.
*
* @param streamInit This function should call the platform apis that result in streaming updates on push topics.
* {@link StatusResult}
*/
subscribe(streamInit) {
let timeout;
// This outer promise is to hold the streaming promise chain open until the streaming processor
// says it's complete.
// eslint-disable-next-line @typescript-eslint/no-misused-promises
return new Promise((subscribeResolve, subscribeReject) =>
// This is the inner promise chain that's satisfied when the client impl (Faye/Mock) says it's subscribed.
new Promise((subscriptionResolve, subscriptionReject) => {
timeout = setTimeout(() => {
const timeoutError = messages.createError('genericTimeout');
this.doTimeout(timeout, timeoutError);
subscribeReject(timeoutError);
}, this.options.subscribeTimeout.milliseconds);
// Initialize the subscription.
const subscription = this.cometClient.subscribe(this.options.channel, (message) => {
try {
// The result of the stream processor determines the state of the outer promise.
const result = this.options.streamProcessor(message);
// The stream processor says it's complete. Clean up and resolve the outer promise.
if (result?.completed) {
clearTimeout(timeout);
this.disconnectClient();
subscribeResolve(result.payload);
} // This 'if' is intended to be evaluated until it's completed or until the timeout fires.
}
catch (e) {
// it's completely valid for the stream processor to throw an error. If it does we will
// reject the outer promise. Keep in mind if we are here the subscription was resolved.
clearTimeout(timeout);
this.disconnectClient();
subscribeReject(e);
}
});
subscription.callback(() => {
subscriptionResolve();
});
subscription.errback((error) => {
subscriptionReject(error);
});
})
.then(() =>
// Now that we successfully have a subscription started up we are safe to initialize the function that
// will affect the streaming events. I.E. create an org or run apex tests.
streamInit?.())
.catch((error) => {
this.disconnect();
// Need to catch the subscription rejection or it will result in an unhandled rejection error.
clearTimeout(timeout);
// No subscription so we can reject the out promise as well.
subscribeReject(error);
}));
}
/**
* Handler for incoming streaming messages.
*
* @param message The message to process.
* @param cb The callback. Failure to call this can cause the internal comet client to hang.
*/
incoming(message, cb) {
this.log(message);
// Look for a specific error message during the handshake. If found, throw an error
// with actions for the user.
if (message &&
message.channel === '/meta/handshake' &&
message.error &&
(0, lib_2.ensureString)(message.error).includes('400::API version in the URI is mandatory')) {
throw messages.createError('handshakeApiVersionError', [this.options.apiVersion]);
}
cb(message);
}
doTimeout(timeout, error) {
this.disconnect();
clearTimeout(timeout);
this.log(JSON.stringify(error));
return error;
}
disconnectClient() {
if (this.cometClient) {
this.cometClient.disconnect();
}
}
disconnect() {
this.log('Disconnecting the comet client');
// This is a patch for faye. If Faye encounters errors while attempting to handshake it will keep trying
// and will prevent the timeout from disconnecting. Here for example we will detect there is no client id but
// unauthenticated connections are being made to salesforce. Let's close the dispatcher if it exists and
// has no clientId.
// @ts-ignore
// eslint-disable-next-line no-underscore-dangle
if (this.cometClient._dispatcher) {
this.log('Closing the faye dispatcher');
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, no-underscore-dangle
const dispatcher = this.cometClient._dispatcher;
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access
this.log(`dispatcher.clientId: ${dispatcher.clientId}`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (!dispatcher.clientId) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
dispatcher.close();
}
else {
this.disconnectClient();
}
}
}
/**
* Simple inner log wrapper
*
* @param message The message to log
*/
log(message) {
if (this.logger) {
this.logger.debug(message);
}
}
}
exports.StreamingClient = StreamingClient;
(function (StreamingClient) {
/**
* Default Streaming Options. Uses Faye as the cometd impl.
*/
class DefaultOptions {
static SFDX_ENABLE_FAYE_COOKIES_ALLOW_ALL_PATHS = 'SFDX_ENABLE_FAYE_REQUEST_RESPONSE_LOGGING';
static SFDX_ENABLE_FAYE_REQUEST_RESPONSE_LOGGING = 'SFDX_ENABLE_FAYE_REQUEST_RESPONSE_LOGGING';
static DEFAULT_SUBSCRIBE_TIMEOUT = lib_1.Duration.minutes(3);
static DEFAULT_HANDSHAKE_TIMEOUT = lib_1.Duration.seconds(30);
apiVersion;
org;
streamProcessor;
subscribeTimeout;
handshakeTimeout;
channel;
streamingImpl;
/**
* Constructor for DefaultStreamingOptions
*
* @param org The streaming target org
* @param channel The streaming channel or topic. If the topic is a system topic then api 36.0 is used.
* System topics are deprecated.
* @param streamProcessor The function called that can process streaming messages.
* @param envDep
* @see {@link StatusResult}
*/
constructor(org, channel, streamProcessor, envDep = lib_1.env) {
if (envDep) {
const logger = logger_1.Logger.childFromRoot('StreamingClient');
logger.warn('envDep is deprecated');
}
if (!streamProcessor) {
throw new sfError_1.SfError('Missing stream processor', 'MissingArg');
}
if (!org) {
throw new sfError_1.SfError('Missing org', 'MissingArg');
}
if (!channel) {
throw new sfError_1.SfError('Missing streaming channel', 'MissingArg');
}
this.org = org;
this.apiVersion = org.getConnection().getApiVersion();
if (channel.startsWith('/system')) {
this.apiVersion = '36.0';
}
if (!(parseFloat(this.apiVersion) > 0)) {
throw messages.createError('invalidApiVersion', [this.apiVersion]);
}
this.streamProcessor = streamProcessor;
this.channel = channel;
this.subscribeTimeout = StreamingClient.DefaultOptions.DEFAULT_SUBSCRIBE_TIMEOUT;
this.handshakeTimeout = StreamingClient.DefaultOptions.DEFAULT_HANDSHAKE_TIMEOUT;
this.streamingImpl = {
getCometClient: (url) =>
// @ts-ignore
new faye_1.default.Client(url),
setLogger: (logLine) => {
// @ts-ignore
faye_1.default.logger = {};
['info', 'error', 'fatal', 'warn', 'debug'].forEach((element) => {
// @ts-ignore
(0, lib_1.set)(faye_1.default.logger, element, logLine);
});
},
};
}
/**
* Setter for the subscribe timeout.
*
* **Throws** An error if the newTime is less than the default time.
*
* @param newTime The new subscribe timeout.
* {@link DefaultOptions.DEFAULT_SUBSCRIBE_TIMEOUT}
*/
setSubscribeTimeout(newTime) {
this.subscribeTimeout = validateTimeout(newTime, DefaultOptions.DEFAULT_SUBSCRIBE_TIMEOUT);
}
/**
* Setter for the handshake timeout.
*
* **Throws** An error if the newTime is less than the default time.
*
* @param newTime The new handshake timeout
* {@link DefaultOptions.DEFAULT_HANDSHAKE_TIMEOUT}
*/
setHandshakeTimeout(newTime) {
this.handshakeTimeout = validateTimeout(newTime, DefaultOptions.DEFAULT_HANDSHAKE_TIMEOUT);
}
}
StreamingClient.DefaultOptions = DefaultOptions;
/**
* Connection state
*
* @see {@link StreamingClient.handshake}
*/
let ConnectionState;
(function (ConnectionState) {
/**
* Used to indicated that the streaming client is connected.
*/
ConnectionState[ConnectionState["CONNECTED"] = 0] = "CONNECTED";
})(ConnectionState = StreamingClient.ConnectionState || (StreamingClient.ConnectionState = {}));
/**
* Indicators to test error names for StreamingTimeouts
*/
let TimeoutErrorType;
(function (TimeoutErrorType) {
/**
* To indicate the error occurred on handshake
*/
TimeoutErrorType["HANDSHAKE"] = "GenericHandshakeTimeoutError";
/**
* To indicate the error occurred on subscribe
*/
TimeoutErrorType["SUBSCRIBE"] = "GenericTimeoutError";
})(TimeoutErrorType = StreamingClient.TimeoutErrorType || (StreamingClient.TimeoutErrorType = {}));
})(StreamingClient || (exports.StreamingClient = StreamingClient = {}));
//# sourceMappingURL=streamingClient.js.map