diff --git a/packages/salesforce/src/connection/httpTransport.ts b/packages/salesforce/src/connection/httpTransport.ts index 68b5133f..d48ba076 100644 --- a/packages/salesforce/src/connection/httpTransport.ts +++ b/packages/salesforce/src/connection/httpTransport.ts @@ -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; @@ -42,7 +42,38 @@ interface HttpContentType { parameters: Record; } -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(); /** @@ -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, + 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(); - 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; } @@ -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)); } @@ -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)); } @@ -177,7 +204,7 @@ export class HttpTransport { } private sendRequestBody(request: http.ClientRequest, body: string) : Promise { - 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 @@ -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); } diff --git a/packages/salesforce/src/connection/jsFroceConnectionProvider.ts b/packages/salesforce/src/connection/jsFroceConnectionProvider.ts index 69fad17b..c37dc28c 100644 --- a/packages/salesforce/src/connection/jsFroceConnectionProvider.ts +++ b/packages/salesforce/src/connection/jsFroceConnectionProvider.ts @@ -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 })); } @@ -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; } diff --git a/packages/salesforce/src/connection/salesforceConnection.ts b/packages/salesforce/src/connection/salesforceConnection.ts index 09fc26ca..831fef45 100644 --- a/packages/salesforce/src/connection/salesforceConnection.ts +++ b/packages/salesforce/src/connection/salesforceConnection.ts @@ -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 @@ -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 */ @@ -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); @@ -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 { + 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)); } /** @@ -129,7 +145,12 @@ export class SalesforceConnection extends Connection { throw err; } - return super.request(info, { ...options }).catch(errorHandler); + const requestPromise = super.request(info, { ...options }).catch(errorHandler); + if (callback) { + // @ts-ignore + return requestPromise.then(v => callback(undefined, v), err => callback(err, undefined)); + } + return requestPromise; } /** @@ -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) { + 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; + } +} diff --git a/packages/salesforce/src/connection/sfdxConnectionProvider.ts b/packages/salesforce/src/connection/sfdxConnectionProvider.ts index 61400c48..050a8922 100644 --- a/packages/salesforce/src/connection/sfdxConnectionProvider.ts +++ b/packages/salesforce/src/connection/sfdxConnectionProvider.ts @@ -2,6 +2,7 @@ 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 }; @@ -9,6 +10,7 @@ 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(); @@ -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() { diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts index 252a3a17..aa44977d 100644 --- a/packages/util/src/index.ts +++ b/packages/util/src/index.ts @@ -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';