UNPKG

nerdbank-streams

Version:
625 lines 30.4 kB
"use strict"; /* TODO: * Tracing * Auto-terminate channels when both ends have finished writing (AutoCloseOnPipesClosureAsync) */ Object.defineProperty(exports, "__esModule", { value: true }); exports.MultiplexingStreamClass = exports.MultiplexingStream = void 0; const await_semaphore_1 = require("await-semaphore"); const cancellationtoken_1 = require("cancellationtoken"); const events_1 = require("events"); const Channel_1 = require("./Channel"); const ControlCode_1 = require("./ControlCode"); const Deferred_1 = require("./Deferred"); const FrameHeader_1 = require("./FrameHeader"); const MultiplexingStreamFormatters_1 = require("./MultiplexingStreamFormatters"); require("./MultiplexingStreamOptions"); const QualifiedChannelId_1 = require("./QualifiedChannelId"); const Utilities_1 = require("./Utilities"); const caught = require("caught"); class MultiplexingStream { get disposalToken() { return this.disposalTokenSource.token; } /** * Gets a promise that is resolved or rejected based on how this stream is disposed or fails. */ get completion() { return this._completionSource.promise; } /** * Gets a value indicating whether this instance has been disposed. */ get isDisposed() { return this.disposalTokenSource.token.isCancelled; } /** * Initializes a new instance of the `MultiplexingStream` class. * @param stream The duplex stream to read and write to. * Use `FullDuplexStream.Splice` if you have distinct input/output streams. * @param options Options to customize the behavior of the stream. * @param cancellationToken A token whose cancellation aborts the handshake with the remote end. * @returns The multiplexing stream, once the handshake is complete. */ // eslint-disable-next-line @typescript-eslint/naming-convention static async CreateAsync(stream, options, cancellationToken = cancellationtoken_1.default.CONTINUE) { var _a; if (!stream) { throw new Error('stream must be specified.'); } options = options || {}; (_a = options.protocolMajorVersion) !== null && _a !== void 0 ? _a : (options.protocolMajorVersion = 1); options.defaultChannelReceivingWindowSize = options.defaultChannelReceivingWindowSize || MultiplexingStream.recommendedDefaultChannelReceivingWindowSize; if (options.protocolMajorVersion < 3 && options.seededChannels && options.seededChannels.length > 0) { throw new Error('Seeded channels require v3 of the protocol at least.'); } // Send the protocol magic number, and a random 16-byte number to establish even/odd assignments. const formatter = options.protocolMajorVersion === 1 ? new MultiplexingStreamFormatters_1.MultiplexingStreamV1Formatter(stream) : options.protocolMajorVersion === 2 ? new MultiplexingStreamFormatters_1.MultiplexingStreamV2Formatter(stream) : options.protocolMajorVersion === 3 ? new MultiplexingStreamFormatters_1.MultiplexingStreamV3Formatter(stream) : undefined; if (!formatter) { throw new Error(`Protocol major version ${options.protocolMajorVersion} is not supported.`); } const writeHandshakeData = await formatter.writeHandshakeAsync(); const handshakeResult = await formatter.readHandshakeAsync(writeHandshakeData, cancellationToken); formatter.isOdd = handshakeResult.isOdd; return new MultiplexingStreamClass(formatter, handshakeResult.isOdd, options); } /** * Initializes a new instance of the `MultiplexingStream` class * with protocolMajorVersion set to 3. * @param stream The duplex stream to read and write to. * Use `FullDuplexStream.Splice` if you have distinct input/output streams. * @param options Options to customize the behavior of the stream. * @returns The multiplexing stream. */ // eslint-disable-next-line @typescript-eslint/naming-convention static Create(stream, options) { var _a; options !== null && options !== void 0 ? options : (options = { protocolMajorVersion: 3 }); (_a = options.protocolMajorVersion) !== null && _a !== void 0 ? _a : (options.protocolMajorVersion = 3); const formatter = options.protocolMajorVersion === 3 ? new MultiplexingStreamFormatters_1.MultiplexingStreamV3Formatter(stream) : undefined; if (!formatter) { throw new Error(`Protocol major version ${options.protocolMajorVersion} is not supported. Try CreateAsync instead.`); } return new MultiplexingStreamClass(formatter, undefined, options); } constructor(formatter, isOdd, options) { var _a, _b, _c; this.formatter = formatter; this.isOdd = isOdd; this._completionSource = new Deferred_1.Deferred(); /** * A dictionary of channels that were seeded, keyed by their ID. */ this.seededOpenChannels = {}; /** * A dictionary of channels that were offered locally, keyed by their ID. */ this.locallyOfferedOpenChannels = {}; /** * A dictionary of channels that were offered remotely, keyed by their ID. */ this.remotelyOfferedOpenChannels = {}; /** * A map of channel names to queues of channels waiting for local acceptance. */ this.channelsOfferedByThemByName = {}; /** * A map of channel names to queues of Deferred<Channel> from waiting accepters. */ this.acceptingChannels = {}; this.eventEmitter = new events_1.EventEmitter(); this.disposalTokenSource = cancellationtoken_1.default.create(); this.defaultChannelReceivingWindowSize = (_a = options.defaultChannelReceivingWindowSize) !== null && _a !== void 0 ? _a : MultiplexingStream.recommendedDefaultChannelReceivingWindowSize; this.protocolMajorVersion = (_b = options.protocolMajorVersion) !== null && _b !== void 0 ? _b : 1; if (options.seededChannels) { for (let i = 0; i < options.seededChannels.length; i++) { const channelOptions = options.seededChannels[i]; const id = { id: i, source: QualifiedChannelId_1.ChannelSource.Seeded }; this.setOpenChannel(new Channel_1.ChannelClass(this, id, { name: '', remoteWindowSize: (_c = channelOptions.channelReceivingWindowSize) !== null && _c !== void 0 ? _c : this.defaultChannelReceivingWindowSize, })); } } } /** * Creates an anonymous channel that may be accepted by <see cref="AcceptChannel(int, ChannelOptions)"/>. * Its existance must be communicated by other means (typically another, existing channel) to encourage acceptance. * @param options A set of options that describe local treatment of this channel. * @returns The anonymous channel. * @description Note that while the channel is created immediately, any local write to that channel will be * buffered locally until the remote party accepts the channel. */ createChannel(options) { var _a; const offerParameters = { name: '', remoteWindowSize: (_a = options === null || options === void 0 ? void 0 : options.channelReceivingWindowSize) !== null && _a !== void 0 ? _a : this.defaultChannelReceivingWindowSize, }; const payload = this.formatter.serializeOfferParameters(offerParameters); const channel = new Channel_1.ChannelClass(this, { id: this.getUnusedChannelId(), source: QualifiedChannelId_1.ChannelSource.Local }, offerParameters); this.setOpenChannel(channel); this.rejectOnFailure(this.sendFrameAsync(new FrameHeader_1.FrameHeader(ControlCode_1.ControlCode.offer, channel.qualifiedId), payload, this.disposalToken)); return channel; } /** * Accepts a channel with a specific ID. * @param id The id of the channel to accept. * @param options A set of options that describe local treatment of this channel. * @description This method can be used to accept anonymous channels created with <see cref="CreateChannel"/>. * Unlike <see cref="AcceptChannelAsync(string, ChannelOptions, CancellationToken)"/> which will await * for a channel offer if a matching one has not been made yet, this method only accepts an offer * for a channel that has already been made. */ acceptChannel(id, options) { const channel = this.remotelyOfferedOpenChannels[id] || this.seededOpenChannels[id]; if (!channel) { throw new Error('No channel with ID ' + id); } this.removeChannelFromOfferedQueue(channel); this.acceptChannelOrThrow(channel, options); return channel; } /** * Rejects an offer for the channel with a specified ID. * @param id The ID of the channel whose offer should be rejected. */ rejectChannel(id) { const channel = this.remotelyOfferedOpenChannels[id]; if (channel) { (0, Utilities_1.removeFromQueue)(channel, this.channelsOfferedByThemByName[channel.name]); } else { throw new Error('No channel with that ID found.'); } // Rejecting a channel rejects a couple promises that we don't want the caller to have to observe // separately since they are explicitly stating they want to take this action now. caught(channel.acceptance); caught(channel.completion); channel.dispose(); } /** * Offers a new, named channel to the remote party so they may accept it with * {@link acceptChannelAsync}. * @param name A name for the channel, which must be accepted on the remote end to complete creation. * It need not be unique, and may be empty but must not be null. * Any characters are allowed, and max length is determined by the maximum frame payload (based on UTF-8 encoding). * @param options A set of options that describe local treatment of this channel. * @param cancellationToken A cancellation token. Do NOT let this be a long-lived token * or a memory leak will result since we add continuations to its promise. * @returns A task that completes with the `Channel` if the offer is accepted on the remote end * or faults with `MultiplexingProtocolException` if the remote end rejects the channel. */ async offerChannelAsync(name, options, cancellationToken = cancellationtoken_1.default.CONTINUE) { var _a; if (name === null || name === undefined) { throw new Error('Name must be specified (but may be empty).'); } cancellationToken.throwIfCancelled(); (0, Utilities_1.throwIfDisposed)(this); const offerParameters = { name, remoteWindowSize: (_a = options === null || options === void 0 ? void 0 : options.channelReceivingWindowSize) !== null && _a !== void 0 ? _a : this.defaultChannelReceivingWindowSize, }; const payload = this.formatter.serializeOfferParameters(offerParameters); const channel = new Channel_1.ChannelClass(this, { id: this.getUnusedChannelId(), source: QualifiedChannelId_1.ChannelSource.Local }, offerParameters); this.setOpenChannel(channel); const header = new FrameHeader_1.FrameHeader(ControlCode_1.ControlCode.offer, channel.qualifiedId); const unsubscribeFromCT = cancellationToken.onCancelled(reason => this.offerChannelCanceled(channel, reason)); try { // We *will* recognize rejection of this promise. But just in case sendFrameAsync completes synchronously, // we want to signify that we *will* catch it first to avoid node.js emitting warnings or crashing. caught(channel.acceptance); await this.sendFrameAsync(header, payload, cancellationToken); await channel.acceptance; return channel; } finally { unsubscribeFromCT(); } } /** * Accepts a channel that the remote end has attempted or may attempt to create. * @param name The name of the channel to accept. * An empty string will match an offer made via {@link offerChannelAsync} with an empty channel name. * It will also match an anonymous channel offer made with {@link createChannel}. * @param options A set of options that describe local treatment of this channel. * @param cancellationToken A token to indicate lost interest in accepting the channel. * Do NOT let this be a long-lived token * or a memory leak will result since we add continuations to its promise. * @returns The `Channel`, after its offer has been received from the remote party and accepted. * @description If multiple offers exist with the specified `name`, the first one received will be accepted. */ async acceptChannelAsync(name, options, cancellationToken = cancellationtoken_1.default.CONTINUE) { if (name === null || name === undefined) { throw new Error('Name must be specified (but may be empty).'); } cancellationToken.throwIfCancelled(); (0, Utilities_1.throwIfDisposed)(this); let channel; let pendingAcceptChannel; const channelsOfferedByThem = this.channelsOfferedByThemByName[name]; if (channelsOfferedByThem) { while (channel === undefined && channelsOfferedByThem.length > 0) { channel = channelsOfferedByThem.shift(); if (channel.isAccepted || channel.isRejectedOrCanceled) { channel = undefined; continue; } } } if (channel === undefined) { let acceptingChannels = this.acceptingChannels[name]; if (!acceptingChannels) { this.acceptingChannels[name] = acceptingChannels = []; } pendingAcceptChannel = new Deferred_1.Deferred(options); acceptingChannels.push(pendingAcceptChannel); } if (channel !== undefined) { this.acceptChannelOrThrow(channel, options); return channel; } else { const unsubscribeFromCT = cancellationToken.onCancelled(reason => this.acceptChannelCanceled(pendingAcceptChannel, name, reason)); try { return await pendingAcceptChannel.promise; } finally { unsubscribeFromCT(); } } } /** * Disposes the stream. */ dispose() { this.disposeCore(false); } disposeCore(remoteDisconnect) { this.disposalTokenSource.cancel(); this._completionSource.resolve(); this.formatter.end(); [this.locallyOfferedOpenChannels, this.remotelyOfferedOpenChannels].forEach(cb => { for (const channelId in cb) { if (cb.hasOwnProperty(channelId)) { const channel = cb[channelId]; // Acceptance gets rejected when a channel is disposed. // Avoid a node.js crash or test failure for unobserved channels (e.g. offers for channels from the other party that no one cared to receive on this side). caught(channel.acceptance); channel.dispose(); } } }); // Iterate over acceptingChannels to reject each promise const errorMessage = remoteDisconnect ? 'The remote party has disconnected.' : 'The stream is being disposed locally.'; for (const name in this.acceptingChannels) { if (this.acceptingChannels.hasOwnProperty(name)) { const acceptingChannels = this.acceptingChannels[name]; for (const channelDeferral of acceptingChannels) { channelDeferral.reject(new Error(errorMessage)); } delete this.acceptingChannels[name]; } } } on(event, listener) { this.eventEmitter.on(event, listener); } off(event, listener) { this.eventEmitter.off(event, listener); } once(event, listener) { this.eventEmitter.once(event, listener); } raiseChannelOffered(id, name, isAccepted) { const args = { id, isAccepted, name, }; try { this.eventEmitter.emit('channelOffered', args); } catch (err) { this._completionSource.reject(err); } } acceptChannelOrThrow(channel, options) { var _a, _b; if (channel.tryAcceptOffer(options)) { const acceptanceParameters = { remoteWindowSize: (_b = (_a = options === null || options === void 0 ? void 0 : options.channelReceivingWindowSize) !== null && _a !== void 0 ? _a : channel.localWindowSize) !== null && _b !== void 0 ? _b : this.defaultChannelReceivingWindowSize, }; if (acceptanceParameters.remoteWindowSize < this.defaultChannelReceivingWindowSize) { acceptanceParameters.remoteWindowSize = this.defaultChannelReceivingWindowSize; } if (channel.qualifiedId.source !== QualifiedChannelId_1.ChannelSource.Seeded) { const payload = this.formatter.serializeAcceptanceParameters(acceptanceParameters); this.rejectOnFailure(this.sendFrameAsync(new FrameHeader_1.FrameHeader(ControlCode_1.ControlCode.offerAccepted, channel.qualifiedId), payload, this.disposalToken)); } } else if (channel.isAccepted) { throw new Error('Channel is already accepted.'); } else if (channel.isRejectedOrCanceled) { throw new Error('Channel is no longer available for acceptance.'); } else { throw new Error('Channel could not be accepted.'); } } /** * Disposes this instance if the specified promise is rejected. * @param promise The promise to check for failures. */ async rejectOnFailure(promise) { try { await promise; } catch (err) { this._completionSource.reject(err); } } removeChannelFromOfferedQueue(channel) { if (channel.name) { (0, Utilities_1.removeFromQueue)(channel, this.channelsOfferedByThemByName[channel.name]); } } getOpenChannel(qualifiedId) { return this.getChannelCollection(qualifiedId.source)[qualifiedId.id]; } setOpenChannel(channel) { this.getChannelCollection(channel.qualifiedId.source)[channel.qualifiedId.id] = channel; } deleteOpenChannel(qualifiedId) { delete this.getChannelCollection(qualifiedId.source)[qualifiedId.id]; } getChannelCollection(source) { switch (source) { case QualifiedChannelId_1.ChannelSource.Local: return this.locallyOfferedOpenChannels; case QualifiedChannelId_1.ChannelSource.Remote: return this.remotelyOfferedOpenChannels; case QualifiedChannelId_1.ChannelSource.Seeded: return this.seededOpenChannels; } } /** * Cancels a prior call to acceptChannelAsync * @param channel The promise of a channel to be canceled. * @param name The name of the channel the caller was accepting. * @param reason The reason for cancellation. */ acceptChannelCanceled(channel, name, reason) { if (channel.reject(new cancellationtoken_1.default.CancellationError(reason))) { (0, Utilities_1.removeFromQueue)(channel, this.acceptingChannels[name]); } } /** * Responds to cancellation of a prior call to offerChannelAsync. * @param channel The channel previously offered. */ offerChannelCanceled(channel, reason) { channel.tryCancelOffer(reason); } /** * Gets a unique number that can be used to represent a channel. * @description The channel numbers increase by two in order to maintain odd or even numbers, * since each party is allowed to create only one or the other. */ getUnusedChannelId() { return (this.lastOfferedChannelId += this.isOdd !== undefined ? 2 : 1); } } exports.MultiplexingStream = MultiplexingStream; /** * The maximum length of a frame's payload. */ MultiplexingStream.framePayloadMaxLength = 20 * 1024; MultiplexingStream.recommendedDefaultChannelReceivingWindowSize = 5 * MultiplexingStream.framePayloadMaxLength; /** * The options to use for channels we create in response to incoming offers. * @description Whatever these settings are, they can be replaced when the channel is accepted. */ MultiplexingStream.defaultChannelOptions = {}; /** * The encoding used for characters in control frames. */ // eslint-disable-next-line @typescript-eslint/naming-convention MultiplexingStream.ControlFrameEncoding = 'utf-8'; // tslint:disable-next-line:max-classes-per-file class MultiplexingStreamClass extends MultiplexingStream { constructor(formatter, isOdd, options) { var _a, _b; super(formatter, isOdd, options); this.sendingSemaphore = new await_semaphore_1.Semaphore(1); this.lastOfferedChannelId = isOdd ? -1 : 0; // the first channel created should be 1 or 2 this.lastOfferedChannelId += (_b = (_a = options.seededChannels) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0; // Initiate reading from the transport stream. This will not end until the stream does, or we're disposed. // If reading the stream fails, we'll dispose ourselves. this.readFromStream(this.disposalToken).catch(err => this._completionSource.reject(err)); } get backpressureSupportEnabled() { return this.protocolMajorVersion > 1; } async sendFrameAsync(header, payload, cancellationToken = cancellationtoken_1.default.CONTINUE) { if (!header) { throw new Error('Header is required.'); } await this.sendingSemaphore.use(async () => { cancellationToken.throwIfCancelled(); (0, Utilities_1.throwIfDisposed)(this); await this.formatter.writeFrameAsync(header, payload); }); } /** * Transmits a frame over the stream. * @param code The op code for the channel. * @param channelId The ID of the channel to receive the frame. * @description The promise returned from this function is always resolved (not rejected) * since it is anticipated that callers may not be awaiting its result. */ async sendFrame(code, channelId) { try { if (this._completionSource.isCompleted) { // Any frames that come in after we're done are most likely frames just informing that channels are // being terminated, which we do not need to communicate since the connection going down implies that. return; } const header = new FrameHeader_1.FrameHeader(code, channelId); await this.sendFrameAsync(header); } catch (error) { // We mustn't throw back to our caller. So report the failure by disposing with failure. this._completionSource.reject(error); } } onChannelWritingCompleted(channel) { // Only inform the remote side if this channel has not already been terminated. if (!channel.isDisposed && this.getOpenChannel(channel.qualifiedId)) { this.sendFrame(ControlCode_1.ControlCode.contentWritingCompleted, channel.qualifiedId); } } async onChannelDisposed(channel, error) { if (!this._completionSource.isCompleted) { try { const payload = this.protocolMajorVersion > 1 && error ? this.formatter.serializeException(error) : Buffer.alloc(0); const frameHeader = new FrameHeader_1.FrameHeader(ControlCode_1.ControlCode.channelTerminated, channel.qualifiedId); await this.sendFrameAsync(frameHeader, payload); } catch (err) { // Swallow exceptions thrown about channel disposal if the whole stream has been taken down. if (this.isDisposed) { return; } throw err; } } } localContentExamined(channel, bytesConsumed) { const payload = this.formatter.serializeContentProcessed(bytesConsumed); this.rejectOnFailure(this.sendFrameAsync(new FrameHeader_1.FrameHeader(ControlCode_1.ControlCode.contentProcessed, channel.qualifiedId), payload)); } async readFromStream(cancellationToken) { while (!this.isDisposed) { const frame = await this.formatter.readFrameAsync(cancellationToken); if (frame === null) { break; } frame.header.flipChannelPerspective(); switch (frame.header.code) { case ControlCode_1.ControlCode.offer: this.onOffer(frame.header.requiredChannel, frame.payload); break; case ControlCode_1.ControlCode.offerAccepted: this.onOfferAccepted(frame.header.requiredChannel, frame.payload); break; case ControlCode_1.ControlCode.content: this.onContent(frame.header.requiredChannel, frame.payload); break; case ControlCode_1.ControlCode.contentProcessed: this.onContentProcessed(frame.header.requiredChannel, frame.payload); break; case ControlCode_1.ControlCode.contentWritingCompleted: this.onContentWritingCompleted(frame.header.requiredChannel); break; case ControlCode_1.ControlCode.channelTerminated: this.onChannelTerminated(frame.header.requiredChannel, frame.payload); break; default: break; } } this.disposeCore(true); } onOffer(channelId, payload) { const offerParameters = this.formatter.deserializeOfferParameters(payload); const channel = new Channel_1.ChannelClass(this, channelId, offerParameters); let acceptingChannelAlreadyPresent = false; let options; let acceptingChannels; if ((acceptingChannels = this.acceptingChannels[offerParameters.name]) !== undefined) { while (acceptingChannels.length > 0) { const candidate = acceptingChannels.shift(); if (candidate.resolve(channel)) { acceptingChannelAlreadyPresent = true; options = candidate.state; break; } } } if (!acceptingChannelAlreadyPresent) { let offeredChannels; if (!(offeredChannels = this.channelsOfferedByThemByName[offerParameters.name])) { this.channelsOfferedByThemByName[offerParameters.name] = offeredChannels = []; } offeredChannels.push(channel); } this.setOpenChannel(channel); if (acceptingChannelAlreadyPresent) { this.acceptChannelOrThrow(channel, options); } this.raiseChannelOffered(channel.id, channel.name, acceptingChannelAlreadyPresent); } onOfferAccepted(channelId, payload) { if (channelId.source !== QualifiedChannelId_1.ChannelSource.Local) { throw new Error('Remote party tried to accept a channel that they offered.'); } const acceptanceParameter = this.formatter.deserializeAcceptanceParameters(payload); const channel = this.getOpenChannel(channelId); if (!channel) { throw new Error(`Unexpected channel created with ID ${QualifiedChannelId_1.QualifiedChannelId.toString(channelId)}`); } if (!channel.onAccepted(acceptanceParameter)) { // This may be an acceptance of a channel that we canceled an offer for, and a race condition // led to our cancellation notification crossing in transit with their acceptance notification. // In this case, do nothing since we already sent a channel termination message, and the remote side // should notice it soon. } } onContent(channelId, payload) { const channel = this.getOpenChannel(channelId); if (!channel) { throw new Error(`No channel with id ${channelId} found.`); } channel.onContent(payload); } onContentProcessed(channelId, payload) { const channel = this.getOpenChannel(channelId); if (!channel) { throw new Error(`No channel with id ${channelId} found.`); } const bytesProcessed = this.formatter.deserializeContentProcessed(payload); channel.onContentProcessed(bytesProcessed); } onContentWritingCompleted(channelId) { const channel = this.getOpenChannel(channelId); if (!channel) { throw new Error(`No channel with id ${channelId} found.`); } channel.onContent(null); // signify that the remote is done writing. } /** * Occurs when the remote party has terminated a channel (including canceling an offer). * @param channelId The ID of the terminated channel. */ onChannelTerminated(channelId, payload) { const channel = this.getOpenChannel(channelId); if (channel) { this.deleteOpenChannel(channelId); this.removeChannelFromOfferedQueue(channel); // Extract the exception that we received from the remote side. const remoteException = this.protocolMajorVersion > 1 ? this.formatter.deserializeException(payload) : null; channel.dispose(remoteException); } } } exports.MultiplexingStreamClass = MultiplexingStreamClass; //# sourceMappingURL=MultiplexingStream.js.map