Skip to content

Commit

Permalink
feat: improve custom transport so it can be used with OAuth flow and …
Browse files Browse the repository at this point in the history
…stores refreshed tokens in SFDX to avoid refreshing tokens every time vlocode connects to a SFDX org with an outdated access token
  • Loading branch information
Codeneos committed Jan 25, 2023
1 parent b31a419 commit 7bd75e5
Show file tree
Hide file tree
Showing 5 changed files with 165 additions and 62 deletions.
97 changes: 62 additions & 35 deletions packages/salesforce/src/connection/httpTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import * as csv from 'csv-parse/sync';
import { URL } from 'url';
import { CookieJar } from 'tough-cookie';
import { HttpApiOptions } from 'jsforce/http-api';
import { DeferredPromise, XML } from '@vlocode/util';
import { DeferredPromise, withDefaults, XML } from '@vlocode/util';
import { SalesforceConnection } from './salesforceConnection';
import { ILogger } from '@vlocode/core';
import { ILogger, Logger, LogManager } from '@vlocode/core';

export interface HttpResponse {
statusCode?: number;
Expand Down Expand Up @@ -42,7 +42,38 @@ interface HttpContentType {
parameters: Record<string, string | undefined>;
}

export class HttpTransport {
interface HttpTransportOptions {
/**
* Threshold value when to apply Gzip encoding when posting data to Salesforce.
* @default 128
*/
gzipThreshold: number;

/**
* When true and the length of the body exceeds {@link gzipThreshold} the request will be encoded using gzip compression. This also
* sets the accept-encoding: gzip header on the request to tell Salesforce it can send back responses with gzip compression.
*
* When disabled neither requests or responses will be encoded with gzip.
* @default true
*/
useGzipEncoding: boolean;

/**
* Include a keep-alive header in all requests to re-use the HTTP connection and socket.
* @default true
*/
shouldKeepAlive: boolean;

/**
* Parse set-cookies header and store cookies to be included in the request header on subsequent requests.
*
* Note: handling of cookies is not required but avoids Salesforce from sending the full set-cookie header on each request
* @default true
*/
handleCookies: boolean;
}

export class HttpTransport{
private cookies = new CookieJar();

/**
Expand All @@ -63,41 +94,33 @@ export class HttpTransport {
public bodyEncoding: BufferEncoding = 'utf8';

/**
* Threshold value when to apply Gzip encoding when posting data to Salesforce.
*/
public static gzipThreshold = 128;

/**
* When true and the length of the body exceeds {@link gzipThreshold} the request will be encoded using gzip compression. This also
* sets the accept-encoding: gzip header on the request to tell Salesforce it can send back responses with gzip compression.
*
* When disabled neither requests or responses will be encoded with gzip.
*/
public static useGzipEncoding = 128;

/**
* Include a keep-alive header in all requests to re-use the HTTP connection and socket.
* Options applied to to this HTTP transport
*/
public static shouldKeepAlive = true;

public options: HttpTransportOptions & { baseUrl?: string, instanceUrl?: string };
/**
* Parse set-cookies header and store cookies to be included in the request header on subsequent requests.
*
* Note: handling of cookies is not required but avoids Salesforce from sending the full set-cookie header on each request
* Default configuration for the transport options. When no specific value is set for an individual transport the
* defaults are used instead.
*/
public static handleCookies = true;
static options: HttpTransportOptions = {
gzipThreshold: 128,
useGzipEncoding: true,
shouldKeepAlive: true,
handleCookies: true,
};

constructor(
private connection: SalesforceConnection,
private logger: ILogger) {
options: Partial<HttpTransportOptions & { baseUrl?: string, instanceUrl?: string }>,
private logger: ILogger = Logger.null) {
this.options = withDefaults(options, HttpTransport.options);
this.logger.info(`Enabled features ${this.getFeatureList().map(v => v.toUpperCase()).join(' ')}`);
}

public getFeatureList() {
const features = new Array<string>();
HttpTransport.useGzipEncoding && features.push('gzip');
HttpTransport.handleCookies && features.push('cookies');
HttpTransport.shouldKeepAlive && features.push('keepAlive');
this.options.useGzipEncoding && features.push('gzip');
this.options.handleCookies && features.push('cookies');
this.options.shouldKeepAlive && features.push('keepAlive');
return features;
}

Expand All @@ -120,15 +143,19 @@ export class HttpTransport {
method: info.method
});

if (HttpTransport.shouldKeepAlive) {
if (this.options.shouldKeepAlive) {
request.shouldKeepAlive = true;
}

if (HttpTransport.useGzipEncoding) {
if (this.httpAgent.options.keepAlive !== this.options.shouldKeepAlive) {
this.httpAgent.options.keepAlive = this.options.shouldKeepAlive;
}

if (this.options.useGzipEncoding) {
request.setHeader('accept-encoding', 'gzip, deflate');
}

if (HttpTransport.handleCookies) {
if (this.options.handleCookies) {
request.setHeader('cookie', this.cookies.getCookieStringSync(url.href));
}

Expand All @@ -138,7 +165,7 @@ export class HttpTransport {
this.logger.debug(`${url.pathname}, status=${response.statusCode} (${Date.now() - startTime}ms)`);

const setCookiesHeader = response.headers['set-cookie'];
if (HttpTransport.handleCookies && setCookiesHeader?.length) {
if (this.options.handleCookies && setCookiesHeader?.length) {
setCookiesHeader.forEach(cookie => this.cookies.setCookieSync(cookie, url.href));
}

Expand Down Expand Up @@ -177,7 +204,7 @@ export class HttpTransport {
}

private sendRequestBody(request: http.ClientRequest, body: string) : Promise<http.ClientRequest> {
if (body.length > HttpTransport.gzipThreshold && HttpTransport.useGzipEncoding) {
if (body.length > this.options.gzipThreshold && this.options.useGzipEncoding) {
return new Promise((resolve, reject) => {
zlib.gzip(body, (err, value) => {
err ? reject(err) : resolve(request
Expand Down Expand Up @@ -277,9 +304,9 @@ export class HttpTransport {
private parseUrl(url: string) {
if (url.startsWith('/')) {
if (url.startsWith('/services/')) {
return new URL(this.connection.instanceUrl + url);
return new URL(this.options.instanceUrl + url);
}
return new URL(this.connection._baseUrl() + url);
return new URL(this.options.baseUrl + url);
}
return new URL(url);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,6 @@ export class JsForceConnectionProvider extends SalesforceConnectionProvider {
private newConnection(options: SalesforceOAuthDetails) {
return this.initConnection(new Connection({
version: options.version ?? this.#version,
oauth2: {
clientId: options.clientId,
clientSecret: options.clientSecret
},
...options
}));
}
Expand All @@ -93,7 +89,6 @@ export class JsForceConnectionProvider extends SalesforceConnectionProvider {

private initConnection(connection: Connection) {
const sfConnection: SalesforceConnection = SalesforceConnection.create(connection);
sfConnection.setLogger(LogManager.get('JsForce'));
sfConnection.version = this.#version;
return sfConnection;
}
Expand Down
113 changes: 92 additions & 21 deletions packages/salesforce/src/connection/salesforceConnection.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Connection, ConnectionOptions, RequestInfo } from 'jsforce';
import { Connection, ConnectionOptions, RequestInfo, OAuth2, OAuth2Options } from 'jsforce';
import { HttpApiOptions } from 'jsforce/http-api';

import { Logger, LogLevel, LogManager } from '@vlocode/core';
import { lazy, wait } from '@vlocode/util';
import { HttpTransport } from './httpTransport';
import { CustomError, decorate, lazy, wait } from '@vlocode/util';
import { HttpResponse, HttpTransport } from './httpTransport';

/**
* Salesforce connection decorator that extends the base JSForce connection
Expand All @@ -16,11 +16,6 @@ export class SalesforceConnection extends Connection {
*/
public disableFeedTracking!: boolean;

/**
* Internal HTTP transport used by this connection.
*/
private _transport: HttpTransport;

/**
* The client ID used in the request headers
*/
Expand All @@ -38,11 +33,9 @@ export class SalesforceConnection extends Connection {
public static retryInterval = 1000;

/**
* Get the logger
* Internal logger for the connection, is adapted into a JSforce logger as well
*/
public get logger(): JsForceLogAdapter {
return this['_logger'];
}
private logger!: Logger;

constructor(params: ConnectionOptions) {
super(params);
Expand Down Expand Up @@ -71,16 +64,39 @@ export class SalesforceConnection extends Connection {
*/
private initializeLocalVariables() {
this.disableFeedTracking = true;
this._transport = new HttpTransport(this, LogManager.get('HttpTransport'));

// Setup transport
this['_transport'] = new HttpTransport({ instanceUrl: this.instanceUrl, baseUrl: this._baseUrl() }, LogManager.get('HttpTransport'));
if (this.oauth2) {
this.oauth2 = new SalesforceOAuth2(this.oauth2, this);
}

// Configure logger
this.logger = LogManager.get(SalesforceConnection)
this['_logger'] = new JsForceLogAdapter(this.logger);
this.tooling['_logger'] = new JsForceLogAdapter(this.logger);

// Overwrite refresh function on refresh delegate
this['_refreshDelegate']['_refreshFn'] = SalesforceConnection.refreshAccessToken;
}

/**
* Change the default logger of the connection.
* @param logger Logger
*/
public setLogger(logger: Logger) {
this['_logger'] = new JsForceLogAdapter(logger);
this.tooling['_logger'] = new JsForceLogAdapter(logger);
private static refreshAccessToken(_this: SalesforceConnection, callback: (err: any, accessToken?: string, response?: any) => void) : Promise<string> {
if (!_this.refreshToken) {
throw new Error('Unable to refresh token due to missing access token');
}

return _this.oauth2.refreshToken(_this.refreshToken)
.then((res: any) => {
_this.accessToken = res.access_token;
_this.instanceUrl = res.instance_url;

const [ userId, organizationId ] = res['id'].split("/");
_this.userInfo = { id: userId, organizationId, url: res.id };

callback?.(undefined, res.access_token, res);
return res.access_token;
})
.catch(err => callback?.(err));
}

/**
Expand Down Expand Up @@ -129,7 +145,12 @@ export class SalesforceConnection extends Connection {
throw err;
}

return super.request<T>(info, { ...options }).catch(errorHandler);
const requestPromise = super.request<T>(info, { ...options }).catch(errorHandler);
if (callback) {
// @ts-ignore
return requestPromise.then(v => callback(undefined, v), err => callback(err, undefined));
}
return requestPromise;
}

/**
Expand Down Expand Up @@ -176,3 +197,53 @@ class JsForceLogAdapter {
public error(...args: any[]): void { this.logger.write(LogLevel.error, ...args); }
public debug(...args: any[]): void { this.logger.write(LogLevel.debug, ...args); }
}

class SalesforceOAuth2 extends decorate(OAuth2) {

private transport: HttpTransport;

constructor(oauth: OAuth2, connection: SalesforceConnection) {
super(oauth);

this.transport = new HttpTransport({
handleCookies: false,
// OAuth endpoints do not support gzip encoding
useGzipEncoding: false,
shouldKeepAlive: false,
instanceUrl: connection.instanceUrl,
baseUrl: connection._baseUrl()
});
}

/**
* Post a request to token service
* @param params Params as object send as URL encoded data
* @returns Response body as JSON object
*/
private async _postParams(params: Record<string, string>) {
const response = await this.transport.httpRequest({
method: 'POST',
url: this.tokenServiceUrl,
headers: {
'content-type': 'application/x-www-form-urlencoded'
},
body: Object.entries(params).map(([v,k]) => `${v}=${encodeURIComponent(k)}`).join('&'),
});

if (response.statusCode && response.statusCode >= 400) {
if (typeof response.body === 'object') {
throw new CustomError(response.body['error_description'], { name: response.body['error'] });
}

throw new CustomError(response.body ?? '(SalesforceOAuth2) No response from server', {
name: `ERROR_HTTP_${response.statusCode}`
});
}

if (typeof response.body !== 'object') {
throw new Error('(SalesforceOAuth2) No response from server');
}

return response.body;
}
}
11 changes: 10 additions & 1 deletion packages/salesforce/src/connection/sfdxConnectionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { Connection } from 'jsforce';
import { cache, sfdx } from '@vlocode/util';
import { SalesforceConnectionProvider } from './salesforceConnectionProvider';
import { JsForceConnectionProvider } from './jsFroceConnectionProvider';
import { LogManager } from '@vlocode/core';

export { Connection };

export class SfdxConnectionProvider extends SalesforceConnectionProvider {

private jsforceProvider: JsForceConnectionProvider;
private version?: string;
private logger = LogManager.get(SfdxConnectionProvider);

constructor(private readonly usernameOrAlias: string, version: string | undefined) {
super();
Expand All @@ -19,7 +21,14 @@ export class SfdxConnectionProvider extends SalesforceConnectionProvider {
if (!this.jsforceProvider) {
await this.initConnectionProvider();
}
return this.jsforceProvider.getJsForceConnection();

const conn = await this.jsforceProvider.getJsForceConnection();
conn.on('refresh', (accessToken) => {
sfdx.updateAccessToken(this.usernameOrAlias, accessToken)
.then(() => this.logger.verbose(`Updated SFDX access token for user ${this.usernameOrAlias}`))
.catch((err) => this.logger.warn(`Unable store updated SFDX access token ${this.usernameOrAlias}`, err));
});
return conn;
}

private async initConnectionProvider() {
Expand Down
1 change: 1 addition & 0 deletions packages/util/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from './decorator';
export * from './defaults';
export * from './deferred';
export * from './events';
export * from './errors';
export * from './fs';
export * from './guards';
export * from './hookManager';
Expand Down

0 comments on commit 7bd75e5

Please sign in to comment.