UNPKG

@opentap/runner-client

Version:

This is the web client for the OpenTAP Runner.

621 lines (620 loc) 27.8 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; import { AckPolicy, Empty, ErrorCode, JSONCodec, StringCodec, connect, headers, } from 'nats.ws'; import { ComponentSettingsBase, ComponentSettingsIdentifier, ComponentSettingsListItem, DataGridControl, ErrorResponse, FileDescriptor, ListItemType, NoResponderError, ProfileGroup, } from './DTOs'; import { EventEmitter } from 'events'; import { v4 as uuidv4 } from 'uuid'; const DEFAULT_TIMEOUT = 40000; // default timeout of 40 seconds var Events; (function (Events) { Events["ERROR"] = "error_event"; })(Events || (Events = {})); export class BaseClient { /** Get request access token */ get accessToken() { return this._accessToken; } /** Set request access token */ set accessToken(value) { this._accessToken = value; } /** Get request headers */ get headers() { return this._headers; } /** Set request headers */ set headers(value) { this._headers = value; } /** Get timeout */ get timeout() { return this._timeout || DEFAULT_TIMEOUT; } /** Set timeout in milliseconds. Default is 40000 milliseconds */ set timeout(value) { this._timeout = value; } constructor(baseSubject, options) { this.domainAccess = new Map(); this._headers = {}; this.baseSubject = baseSubject; this.connectionOptions = Object.assign({}, options); this.connectionOptions.timeout = (options === null || options === void 0 ? void 0 : options.timeout) || DEFAULT_TIMEOUT; this.eventEmitter = new EventEmitter(); } withTimeout(promise, timeout) { return Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error(ErrorCode.Timeout)), timeout))]); } /** * Send a request to the nats server. * @param subject The subject to request * @param payload (optional) * @param options (optional) * @returns Promise of an object */ request(subject, payload, options) { return __awaiter(this, void 0, void 0, function* () { var _a, _b, _c, _d; // Prepend the base subject if the given subject does not start with that if (!(options === null || options === void 0 ? void 0 : options.fullSubject)) { subject = `${this.baseSubject}.Request.${subject}`; } if (!this.connection) return Promise.reject(`${subject}: Connection is down! Please try again!`); if (this.connection.isClosed()) return Promise.reject(`${subject}: Connection has been closed! Please reconnect!`); const data = this.encode(payload); const headers = this.buildHeaders(); const timeout = (options === null || options === void 0 ? void 0 : options.timeout) || this.timeout; // Generate a unique reply subject that we can subscribe to const replySubject = (_b = (_a = options === null || options === void 0 ? void 0 : options.publishOptions) === null || _a === void 0 ? void 0 : _a.reply) !== null && _b !== void 0 ? _b : `${subject}.Reply.${uuidv4()}`; // Larger payloads are always faster because the request time is bound // by the number of chunks roundtrips we need to make. // 99% of requests will fit in a single chunk which is ideal, // because it only requires a single roundtrip, same as a regular Request call. // If serverMaxPayload is not available for some reason, we just use a default chunk size of 512000. // We subtract 50000 from max_payload in order to leave room for headers, which // can be quite large since they can contain jwt tokens, which may contain all sorts of information. const serverMaxPayload = (_d = (_c = this.connection) === null || _c === void 0 ? void 0 : _c.info) === null || _d === void 0 ? void 0 : _d.max_payload; const chunkSize = serverMaxPayload ? serverMaxPayload - 50000 : 512000; // The Session and the Client need to agree on the chunk size. Put it in a header. headers.append('ChunkSize', chunkSize.toString()); // Generate a unique request ID which the Session needs to associate chunks const requestId = uuidv4(); headers.append('RequestId', requestId); const opts = Object.assign({ headers, reply: replySubject }, options === null || options === void 0 ? void 0 : options.publishOptions); const fileDescriptor = new FileDescriptor(data.length, chunkSize); // Put the chunk sequence number in a header so the session knows if a chunk is missing let chunkNumber = 1; headers.set('ChunkNumber', chunkNumber.toString()); const getChunk = (chunk) => { const offset = chunk * fileDescriptor.chunkSize; return data.slice(offset, offset + fileDescriptor.chunkSize); }; // Set up the subscription before we start publishing chunks const subscription = this.connection.subscribe(replySubject); // The response from the session will arrive in this promise after we have finished // publishing chunks const responsePromise = new Promise((resolve, reject) => { const messages = []; subscription.callback = (error, message) => { var _a, _b, _c, _d; if (error) { return reject(error); } if (((_a = message.headers) === null || _a === void 0 ? void 0 : _a.code) === 503) { return reject(Error('No responders.')); } // If the message isn't chunked, we can assume the message is finished after a single message has been received. const chunkNumber = (_b = message.headers) === null || _b === void 0 ? void 0 : _b.get('ChunkNumber'); // ChunkNumber starts from 1, so this check should be correct if (!chunkNumber) { subscription.unsubscribe(); return resolve({ byteArray: message.data, isErrorResponse: ((_c = message.headers) === null || _c === void 0 ? void 0 : _c.get('OpenTapNatsError')) === 'true' }); } // Put all the response chunks in an array in the order they are received messages.push(message); // If the chunk has a size equal to the chunkSize, we should expect another message if (message.data.length === chunkSize) { return; } // If the chunk has a length smaller than the chunkSize, the message is complete // If the final message was received, we can safely unsubscribe subscription.unsubscribe(); // Check if the number of the final message is equal to the number of // messages we received. If this is not the case, we dropped a chunk, // likely due to a network error. In this case, the entire message is invalid. if (parseInt(chunkNumber) !== messages.length) { return reject(Error(`Expected {finalMessageNumber} chunks, but received ${messages.length}. ` + `The connection may have been interrupted.`)); } // Concatenate the payloads // When there are many chunks, doing a single allocation // is significantly faster than concatenating arrays in sequence const flattenedSize = messages.reduce((sum, array) => sum + array.data.length, 0); const flattenedArray = new Uint8Array(flattenedSize); let k = 0; messages.forEach(m => { for (let i = 0; i < m.data.length; i++) { flattenedArray[k++] = m.data[i]; } }); return resolve({ byteArray: flattenedArray, isErrorResponse: ((_d = message.headers) === null || _d === void 0 ? void 0 : _d.get('OpenTapNatsError')) === 'true' }); }; }).then(({ byteArray, isErrorResponse }) => { if (byteArray.length === 0) { return Promise.resolve(undefined); } const jsonCodec = JSONCodec(); // If a raw response is requested, we should avoid decoding the bytes. const response = (options === null || options === void 0 ? void 0 : options.rawResponse) ? byteArray : jsonCodec.decode(byteArray); return isErrorResponse ? Promise.reject(ErrorResponse.fromJS(response)) : Promise.resolve(response); }); // 'Manually' publish the first chunk. This is to ensure that **at least** one chunk is published. // This is a special case for empty payloads (which are common) let chunk = getChunk(0); this.connection.publish(subject, chunk, opts); chunkNumber += 1; for (let i = 1; i < fileDescriptor.numberOfChunks; i++) { headers.set('ChunkNumber', chunkNumber.toString()); chunk = getChunk(i); this.connection.publish(subject, chunk, opts); chunkNumber += 1; } // In the special case where the last published chunk was full, we need to publish // an empty message if (data.length > 0 && data.length % fileDescriptor.chunkSize === 0) { headers.set('ChunkNumber', chunkNumber.toString()); this.connection.publish(subject, Empty, opts); } // Now that we have sent the terminating chunk, the result should arrive on our promise. return this.withTimeout(responsePromise, timeout).catch(err => { subscription.unsubscribe(); return Promise.reject(this.natsErrorHandler(err, subject)); }); }); } /** * Handle the error * @param error * @param subject * @returns */ natsErrorHandler(error, subject) { let errorResponse = new ErrorResponse(error); switch (error === null || error === void 0 ? void 0 : error.message) { case 'No responders.': errorResponse = new NoResponderError(); break; default: errorResponse.message = error.message; } errorResponse.subject = subject; errorResponse.stackTrace = error === null || error === void 0 ? void 0 : error.stack; this.eventEmitter.emit(Events.ERROR, errorResponse); return errorResponse; } /** * Build the headers' object. * @returns {MsgHdrs} Header object */ buildHeaders() { var _a; const _headers = headers(); if (this._accessToken) _headers.set('Authorization', this._accessToken); (_a = this.domainAccess) === null || _a === void 0 ? void 0 : _a.forEach((value, key) => _headers.append('DomainAuthorization', `${key}|${value}`)); Object.entries(this._headers).forEach(([key, value]) => _headers.append(key, value)); return _headers; } /** * Subscribes to given subject. * @param subject The subject to subscribe * @param options Subscription options * @returns Subscription object */ subscribe(subject, options = {}) { if (!subject) throw Error('Subject is not defined!'); if (!this.connection) throw Error('Connection is not up yet! Please try again later!'); if (this.connection.isClosed()) throw Error('Connection has been closed! Please reconnect!'); const natsSubject = options.fullSubject ? subject : `${this.baseSubject}.${subject}`; return this.connection.subscribe(natsSubject, options); } /** * Subscribes to given subject. * @param subject The subject to subscribe * @param jetStreamOptions Subscription options * @returns Subscription object */ createJetStreamConsumer(stream, subject, jetStreamOptions = {}, consumerOptions = {}) { if (!this.connection) { throw Error('Connection is not established'); } return this.connection.jetstreamManager(Object.assign({}, jetStreamOptions)).then(jetStreamManager => jetStreamManager.consumers .add(stream, Object.assign(Object.assign({}, consumerOptions), { filter_subject: subject, ack_policy: AckPolicy.None })) .then(consumerInfo => this.connection.jetstream(jetStreamOptions).consumers.get(consumerInfo.stream_name, consumerInfo.name))); } encode(payload) { if (!payload) { return Empty; } if (payload instanceof Uint8Array) { return payload; } return StringCodec().encode(JSON.stringify(payload)); } /** * Create a connection to the nats server. * @param {ConnectionOptions} options */ connect() { return __awaiter(this, void 0, void 0, function* () { if (!this.baseSubject) return Promise.reject('Subject must be given'); if (this.connection) return Promise.resolve(); try { return yield connect(this.connectionOptions) .then((connection) => { this.connection = connection; return Promise.resolve(); }) .catch((error) => Promise.reject(`Failed to connect to ${this.connectionOptions.servers} with ${error}`)); } catch (error) { return Promise.reject(`Failed to connect to ${this.connectionOptions.servers} with ${error}`); } }); } /** * Close the connection. */ close() { return __awaiter(this, void 0, void 0, function* () { var _a; if (this.connection) { yield ((_a = this.connection) === null || _a === void 0 ? void 0 : _a.close().then(() => { this.connection = null; }).catch((error) => { throw new Error(`failed to close connection: ${error}`); })); } }); } /** * Add a domain specific access token to the dictionary. * @param {string} domain * @param {string} accessToken */ addDomainAccessToken(domain, accessToken) { if (!domain || !accessToken) return; this.domainAccess.set(domain, accessToken); } /** * Generic error callback function. * @returns */ error() { return error => { throw error; }; } /** * Generic success callback function. * @returns */ success() { return response => response; } /** * Add an error-event listener. * @param listener * @returns {EventEmitter} */ addErrorEventListener(listener) { var _a; (_a = this.eventEmitter) === null || _a === void 0 ? void 0 : _a.addListener(Events.ERROR, listener); } /** * Remove an error-event listener. * @param listener */ removeErrorEventListener(listener) { var _a; (_a = this.eventEmitter) === null || _a === void 0 ? void 0 : _a.removeListener(Events.ERROR, listener); } /** * Retrieve component settings overview * @returns {{Promise<ComponentSettingsIdentifier[]>}} */ getComponentSettingsOverview() { return this.request('GetComponentSettingsOverview') .then(componentSettingsIdentifiers => componentSettingsIdentifiers.map(componentSettingsIdentifier => ComponentSettingsIdentifier.fromJS(componentSettingsIdentifier))) .then(this.success()) .catch(this.error()); } /** * Change componentsettings * @param groupName * @param name * @param returnedSettings * @returns {{Promise<ComponentSettingsBase>}} */ setComponentSettings(groupName, name, returnedSettings) { const setComponentSettingsRequest = { groupName, name, returnedSettings }; return this.request('SetComponentSettings', setComponentSettingsRequest) .then(componentSettingsBase => ComponentSettingsBase.fromJS(componentSettingsBase)) .then(this.success()) .catch(this.error()); } /** * Retrieve componentsettings * @param groupName * @param name * @returns {{Promise<ComponentSettingsBase>}} */ getComponentSettings(groupName, name) { const getComponentSettingsRequest = { groupName, name }; return this.request('GetComponentSettings', getComponentSettingsRequest) .then(componentSettingsBase => ComponentSettingsBase.fromJS(componentSettingsBase)) .then(this.success()) .catch(this.error()); } /** * Retrieve componentsettings list item * @param groupName * @param name * @param index * @returns {{Promise<ComponentSettingsListItem>}} */ getComponentSettingsListItem(groupName, name, index) { const getComponentSettingsListItemRequest = { groupName, name, index }; return this.request('GetComponentSettingsListItem', getComponentSettingsListItemRequest) .then(componentSettingsListItem => ComponentSettingsListItem.fromJS(componentSettingsListItem)) .then(this.success()) .catch(this.error()); } /** * Set componentsettings list item settings * @param {string} groupName * @param {string} name * @param {number} index * @param {ComponentSettingsListItem} item * @returns {Promise<ComponentSettingsListItem>} */ setComponentSettingsListItem(groupName, name, index, item) { const setComponentSettingsListItemRequest = { groupName, name, index, item }; return this.request('SetComponentSettingsListItem', setComponentSettingsListItemRequest) .then(componentSettingsListItem => ComponentSettingsListItem.fromJS(componentSettingsListItem)) .then(this.success()) .catch(this.error()); } /** * Get component setting data grid * @param {string} groupName * @param {string} name * @param {number} index * @param {string} propertyName * @returns {Promise<DataGridControl>} */ getComponentSettingDataGrid(groupName, name, index, propertyName) { const getComponentSettingDataGridRequest = { groupName, name, index, propertyName, }; return this.request('GetComponentSettingDataGrid', getComponentSettingDataGridRequest) .then(dataGridControl => DataGridControl.fromJS(dataGridControl)) .then(this.success()) .catch(this.error()); } /** * Set component setting data grid * @param {string} groupName * @param {string} name * @param {number} index * @param {string} propertyName * @param {DataGridControl} dataGridControl * @returns {{Promise<DataGridControl>}} */ setComponentSettingDataGrid(groupName, name, index, propertyName, dataGridControl) { const setComponentSettingDataGridRequest = { groupName, name, index, propertyName, dataGridControl, }; return this.request('SetComponentSettingDataGrid', setComponentSettingDataGridRequest) .then(dataGridControl => DataGridControl.fromJS(dataGridControl)) .then(this.success()) .catch(this.error()); } /** * Add component setting item type to data grid * @param {string} groupName * @param {string} name * @param {number} index * @param {string} propertyName * @param {string} typeName * @returns {Promise<DataGridControl>} */ addComponentSettingDataGridItemType(groupName, name, index, propertyName, typeName) { const addComponentSettingDataGridItemTypeRequest = { groupName, name, index, propertyName, typeName, }; return this.request('AddComponentSettingDataGridItemType', addComponentSettingDataGridItemTypeRequest) .then(dataGridControl => DataGridControl.fromJS(dataGridControl)) .then(this.success()) .catch(this.error()); } /** * Add component setting item to data grid * @param {string} groupName * @param {string} name * @param {number} index * @param {string} propertyName * @returns {Promise<DataGridControl>} */ addComponentSettingDataGridItem(groupName, name, index, propertyName) { const getComponentSettingDataGridRequest = { groupName, name, index, propertyName, }; return this.request('AddComponentSettingDataGridItem', getComponentSettingDataGridRequest) .then(dataGridControl => DataGridControl.fromJS(dataGridControl)) .then(this.success()) .catch(this.error()); } /** * Get item types available in the component setting data grid * @param groupName * @param name * @param index * @param propertyName * @returns {Promise<ListItemType[]>} */ getComponentSettingDataGridTypes(groupName, name, index, propertyName) { const getComponentSettingDataGridRequest = { groupName, name, index, propertyName, }; return this.request('GetComponentSettingDataGridTypes', getComponentSettingDataGridRequest) .then(listItemTypes => listItemTypes.map(listItemType => ListItemType.fromJS(listItemType))) .then(this.success()) .catch(this.error()); } /** * Change componentsettings profiles * @param {ProfileGroup[]} returnedSettings * @returns {Promise<ProfileGroup[]>} */ setComponentSettingsProfiles(returnedSettings) { return this.request('SetComponentSettingsProfiles', returnedSettings) .then(profileGroups => profileGroups.map(profileGroup => ProfileGroup.fromJS(profileGroup))) .then(this.success()) .catch(this.error()); } /** * Get componentsettings profiles * @returns {Promise<ProfileGroup[]>} */ getComponentSettingsProfiles() { return this.request('GetComponentSettingsProfiles') .then(profileGroups => profileGroups.map(profileGroup => ProfileGroup.fromJS(profileGroup))) .then(this.success()) .catch(this.error()); } /** * Upload exported OpenTAP Settings files * @param {FileParameter} file * @returns {Promise<void>} */ uploadComponentSettings(file) { return file ? this.request('UploadComponentSettings', file).then(this.success()).catch(this.error()) : Promise.reject('file must be defined'); } /** * Downloads a .TapSettings file containing the settings for a given component settings group * @param {DownloadTapSettingsRequest} tapSettingsRequest The download request specifying the component settings group to download * @returns {Promise<Uint8Array>} A byte array containing the downloaded .TapSettings file */ downloadComponentSettings(tapSettingsRequest) { return this.request('DownloadComponentSettings', tapSettingsRequest, { rawResponse: true }) .then(this.success()) .catch(this.error()); } /** * Load a component settings TapPackage by referencing a package in a package repository * @param {RepositoryPackageReference} packageReference * @returns {Promise<ErrorResponse[]>} */ loadComponentSettingsFromRepository(packageReference) { return this.request('LoadComponentSettingsFromRepository', packageReference) .then(errorResponses => errorResponses.map(errorResponse => ErrorResponse.fromJS(errorResponse))) .then(this.success()) .catch(this.error()); } /** * Save a TapPackage containing component settings in a package repository * @param {RepositorySettingsPackageDefinition} repositoryPackageDefinition * @returns {Promise<void>} */ saveComponentSettingsToRepository(repositoryPackageDefinition) { return repositoryPackageDefinition ? this.request('SaveComponentSettingsToRepository', repositoryPackageDefinition).then(this.success()).catch(this.error()) : Promise.reject('repositoryPackageDefinition must be defined'); } /** * Retrieve types available to be added to specified component settings list * @param {string} groupName * @param {string} name * @returns {Promise<ListItemType[]>} */ getComponentSettingsListAvailableTypes(groupName, name) { const getComponentSettingsRequest = { groupName, name }; return this.request('GetComponentSettingsListAvailableTypes', getComponentSettingsRequest) .then(listItemTypes => listItemTypes.map(listItemType => ListItemType.fromJS(listItemType))) .then(this.success()) .catch(this.error()); } /** * Adds a new item to a component settings list * @param {string} groupName * @param {string} name * @param {string} typeName * @returns {Promise<ListItemType[]>} */ addComponentSettingsListItem(groupName, name, typeName) { const addComponentSettingsListItemRequest = { groupName, name, typeName }; return this.request('AddComponentSettingsListItem', addComponentSettingsListItemRequest) .then(componentSettingsBase => ComponentSettingsBase.fromJS(componentSettingsBase)) .then(this.success()) .catch(this.error()); } /** * Get settings package files * @returns {Promise<string[]>} */ getSettingsPackageFiles() { return this.request('GetSettingsPackageFiles').then(this.success()).catch(this.error()); } /** * Get settings package types * @returns {Promise<string[]>} */ settingsPackageTypes() { return this.request('SettingsPackageTypes').then(this.success()).catch(this.error()); } /** * Dispatches a request with the given subject and returns a Promise of type T * @param subject The subject to send the request to * @returns Promise<T> The response from the request */ dispatchRequest(subject, payload, options) { return __awaiter(this, void 0, void 0, function* () { return this.request(subject, payload, options); }); } }