pubnub
Version:
Publish & Subscribe Real-time Messaging with PubNub
276 lines (231 loc) • 9 kB
text/typescript
/**
* Common PubNub Network Provider middleware module.
*
* @internal
*/
import { CancellationController, TransportMethod, TransportRequest } from '../core/types/transport-request';
import { PrivateClientConfiguration } from '../core/interfaces/configuration';
import { TransportResponse } from '../core/types/transport-response';
import { LoggerManager } from '../core/components/logger-manager';
import { TokenManager } from '../core/components/token_manager';
import { PubNubAPIError } from '../errors/pubnub-api-error';
import StatusCategory from '../core/constants/categories';
import { Transport } from '../core/interfaces/transport';
import { encodeString } from '../core/utils';
import { Query } from '../core/types/api';
/**
* Transport middleware configuration options.
*
* @internal
*/
type PubNubMiddlewareConfiguration = {
/**
* Private client configuration.
*/
clientConfiguration: PrivateClientConfiguration;
/**
* REST API endpoints access tokens manager.
*/
tokenManager?: TokenManager;
/**
* HMAC-SHA256 hash generator from provided `data`.
*/
shaHMAC?: (data: string) => string;
/**
* Platform-specific transport for requests processing.
*/
transport: Transport;
};
/**
* Request signature generator.
*
* @internal
*/
class RequestSignature {
private static textDecoder = new TextDecoder('utf-8');
constructor(
private publishKey: string,
private secretKey: string,
private hasher: (input: string, secret: string) => string,
private logger: LoggerManager,
) {}
/**
* Compute request signature.
*
* @param req - Request which will be used to compute signature.
* @returns {string} `v2` request signature.
*/
public signature(req: TransportRequest): string {
const method = req.path.startsWith('/publish') ? TransportMethod.GET : req.method;
let signatureInput = `${method}\n${this.publishKey}\n${req.path}\n${this.queryParameters(req.queryParameters!)}\n`;
if (method === TransportMethod.POST || method === TransportMethod.PATCH) {
const body = req.body;
let payload: string | undefined;
if (body && body instanceof ArrayBuffer) {
payload = RequestSignature.textDecoder.decode(body);
} else if (body && typeof body !== 'object') {
payload = body;
}
if (payload) signatureInput += payload;
}
this.logger.trace('RequestSignature', () => ({
messageType: 'text',
message: `Request signature input:\n${signatureInput}`,
}));
return `v2.${this.hasher(signatureInput, this.secretKey)}`
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
/**
* Prepare request query parameters for signature.
*
* @param query - Key / value pair of the request query parameters.
* @private
*/
private queryParameters(query: Query) {
return Object.keys(query)
.sort()
.map((key) => {
const queryValue = query[key];
if (!Array.isArray(queryValue)) return `${key}=${encodeString(queryValue)}`;
return queryValue
.sort()
.map((value) => `${key}=${encodeString(value)}`)
.join('&');
})
.join('&');
}
}
/**
* Common PubNub Network Provider middleware.
*
* @internal
*/
export class PubNubMiddleware implements Transport {
/**
* Request signature generator.
*/
signatureGenerator?: RequestSignature;
constructor(private configuration: PubNubMiddlewareConfiguration) {
const {
clientConfiguration: { keySet },
shaHMAC,
} = configuration;
if (process.env.CRYPTO_MODULE !== 'disabled') {
if (keySet.secretKey && shaHMAC)
this.signatureGenerator = new RequestSignature(keySet.publishKey!, keySet.secretKey, shaHMAC, this.logger);
}
}
/**
* Retrieve registered loggers' manager.
*
* @returns Registered loggers' manager.
*/
private get logger(): LoggerManager {
return this.configuration.clientConfiguration.logger();
}
makeSendable(req: TransportRequest): [Promise<TransportResponse>, CancellationController | undefined] {
const retryPolicy = this.configuration.clientConfiguration.retryConfiguration;
const transport = this.configuration.transport;
// Make requests retryable.
if (retryPolicy !== undefined) {
let retryTimeout: ReturnType<typeof setTimeout> | undefined;
let activeCancellation: CancellationController | undefined;
let canceled = false;
let attempt = 0;
const cancellation: CancellationController = {
abort: (reason) => {
canceled = true;
if (retryTimeout) clearTimeout(retryTimeout);
if (activeCancellation) activeCancellation.abort(reason);
},
};
const retryableRequest = new Promise<TransportResponse>((resolve, reject) => {
const trySendRequest = () => {
// Check whether the request already has been canceled and there is no retry should proceed.
if (canceled) return;
const [attemptPromise, attemptCancellation] = transport.makeSendable(this.request(req));
activeCancellation = attemptCancellation;
const responseHandler = (res?: TransportResponse, error?: PubNubAPIError) => {
const retriableError = error ? error.category !== StatusCategory.PNCancelledCategory : true;
const retriableStatusCode = !res || res.status >= 400;
let delay = -1;
if (
retriableError &&
retriableStatusCode &&
retryPolicy.shouldRetry(req, res, error?.category, attempt + 1)
)
delay = retryPolicy.getDelay(attempt, res);
if (delay > 0) {
attempt++;
this.logger.warn('PubNubMiddleware', `HTTP request retry #${attempt} in ${delay}ms.`);
retryTimeout = setTimeout(() => trySendRequest(), delay);
} else {
if (res) resolve(res);
else if (error) reject(error);
}
};
attemptPromise
.then((res) => responseHandler(res))
.catch((err: PubNubAPIError) => responseHandler(undefined, err));
};
trySendRequest();
});
return [retryableRequest, activeCancellation ? cancellation : undefined];
}
return transport.makeSendable(this.request(req));
}
request(req: TransportRequest): TransportRequest {
const { clientConfiguration } = this.configuration;
// Get request patched by transport provider.
req = this.configuration.transport.request(req);
if (!req.queryParameters) req.queryParameters = {};
// Modify the request with required information.
if (clientConfiguration.useInstanceId) req.queryParameters['instanceid'] = clientConfiguration.getInstanceId()!;
if (!req.queryParameters['uuid']) req.queryParameters['uuid'] = clientConfiguration.userId!;
if (clientConfiguration.useRequestId) req.queryParameters['requestid'] = req.identifier;
req.queryParameters['pnsdk'] = this.generatePNSDK();
req.origin ??= clientConfiguration.origin as string;
// Authenticate request if required.
this.authenticateRequest(req);
// Sign request if it is required.
this.signRequest(req);
return req;
}
private authenticateRequest(req: TransportRequest) {
// Access management endpoints don't need authentication (signature required instead).
if (req.path.startsWith('/v2/auth/') || req.path.startsWith('/v3/pam/') || req.path.startsWith('/time')) return;
const { clientConfiguration, tokenManager } = this.configuration;
const accessKey = (tokenManager && tokenManager.getToken()) ?? clientConfiguration.authKey;
if (accessKey) req.queryParameters!['auth'] = accessKey;
}
/**
* Compute and append request signature.
*
* @param req - Transport request with information which should be used to generate signature.
*/
private signRequest(req: TransportRequest) {
if (!this.signatureGenerator || req.path.startsWith('/time')) return;
req.queryParameters!['timestamp'] = String(Math.floor(new Date().getTime() / 1000));
req.queryParameters!['signature'] = this.signatureGenerator.signature(req);
}
/**
* Compose `pnsdk` query parameter.
*
* SDK provides ability to set custom name or append vendor information to the `pnsdk` query
* parameter.
*
* @returns Finalized `pnsdk` query parameter value.
*/
private generatePNSDK() {
const { clientConfiguration } = this.configuration;
if (clientConfiguration.sdkName) return clientConfiguration.sdkName;
let base = `PubNub-JS-${clientConfiguration.sdkFamily}`;
if (clientConfiguration.partnerId) base += `-${clientConfiguration.partnerId}`;
base += `/${clientConfiguration.getVersion()}`;
const pnsdkSuffix = clientConfiguration._getPnsdkSuffix(' ');
if (pnsdkSuffix.length > 0) base += pnsdkSuffix;
return base;
}
}