UNPKG

event-store-client

Version:

Client library for connecting to Event Store instances over TCP/IP

749 lines (649 loc) 29 kB
var tls = require('tls'); var uuidv4 = require('uuid/v4'); var CatchUpSubscription = require("./catchUpSubscription"); var Messages = require("./messages"); var Commands = require("./commands"); var OperationResult = require("./operationResult"); var ReadAllResult = require("./readAllResult"); var ReadStreamResult = require("./readStreamResult"); /*************************************************************************************************/ // TCP Flags var FLAGS_NONE = 0x00; var FLAGS_AUTH = 0x01; var UINT32_LENGTH = 4; var GUID_LENGTH = 16; var HEADER_LENGTH = 1 + 1 + GUID_LENGTH; // Cmd + Flags + CorrelationId var COMMAND_OFFSET = UINT32_LENGTH; var FLAGS_OFFSET = COMMAND_OFFSET + 1; var CORRELATION_ID_OFFSET = FLAGS_OFFSET + 1; var DATA_OFFSET = CORRELATION_ID_OFFSET + GUID_LENGTH; // Length + Cmd + Flags + CorrelationId /*************************************************************************************************/ /*** * Creates a new native TCP connection to an EventStore instance * @param options The host and port to connect to * @constructor */ function TlsConnection(options) { var instance = this; this.debug = false; this.callbacks = {}; this.currentOffset = 0; this.currentLength = 0; this.currentMessage = null; // Set default option values options.host = options.host || "localhost"; options.port = options.port || 1113; if (options.debug) { instance.debug = true; } instance.connection = tls.connect(options.port, options.host, {}, function () { if (instance.connection.authorized) { if (options.onConnect) { options.onConnect(); } } else { console.log('Connection not authorized: ' + instance.connection.authorizationError); } }); instance.connection.on('error', options.onError || defaultOnError); instance.connection.on('end', options.onEnd || defaultOnClose); instance.connection.on('close', options.onClose || defaultOnClose); instance.connection.on('data', onData); /*** * Called whenever the socket receives data from the EventStore. * Splits the incoming data into messages, and passes each message to receiveMessage * @param data A Buffer containing the data received from Event Store */ function onData(data) { data = new Buffer(data); var packetLength = 0; while (data != null) { if (instance.currentMessage == null) { // This is the start of a new message var commandLength = data.readUInt32LE(0); if (commandLength < HEADER_LENGTH) { console.error('Invalid command length of ' + commandLength + ' bytes. Needs to be at least big enough for the header'); instance.connection.close(); return; } // The entire message will include the command length at the start var messageLength = UINT32_LENGTH + commandLength; if (data.length == messageLength) { // A single packet message, no need to copy into another buffer receiveMessage(data); data = null; } else if (data.length > messageLength) { // Multiple messages in one packet var firstMessage = data.slice(0, messageLength); instance.currentLength = messageLength; receiveMessage(firstMessage); data = data.slice(instance.currentLength); } else { // The first packet of a multi-packet message instance.currentMessage = new Buffer(messageLength); packetLength = data.copy(instance.currentMessage, instance.currentOffset, 0); instance.currentOffset = packetLength; data = null; } } else { // Another packet for a multi-packet message packetLength = data.copy(instance.currentMessage, instance.currentOffset, 0); instance.currentOffset += packetLength; if (instance.currentOffset >= instance.currentMessage.length) { // Finished receiving the current message receiveMessage(instance.currentMessage); instance.currentMessage = null; instance.currentOffset = 0; } data = null; } } } /*** * Called when a socket error occurs * @param err The error that occurred */ function defaultOnError(err) { console.error(err); } /*** * Called when the socket is closed (does nothing) * @param hadError true if the socket had a transmission error */ function defaultOnClose() { } /*** * Called when a complete message has arrived from the EventStore. * @param buf A Buffer containing a complete message */ function receiveMessage(buf) { var command = buf[COMMAND_OFFSET]; if (instance.debug) { console.log('Inbound: ' + buf.toString('hex') + ' (' + buf.length + ' bytes) ' + Commands.getCommandName(command)); } // Read the packet length var commandLength = buf.readUInt32LE(0); if (commandLength < HEADER_LENGTH) { console.error('Invalid command length of ' + commandLength + ' bytes. Needs to be at least big enough for the header'); instance.connection.close(); return; } // Read the header var flags = buf[FLAGS_OFFSET]; var correlationId = buf.toString('hex', CORRELATION_ID_OFFSET, CORRELATION_ID_OFFSET + GUID_LENGTH); // Read the payload data var dataLength = commandLength - HEADER_LENGTH; var data = new Buffer(dataLength); if (dataLength > 0) { buf.copy(data, 0, DATA_OFFSET, DATA_OFFSET + dataLength); } // Handle the message if (command == Commands.HeartbeatRequest) { // Automatically respond to heartbeat requests instance.sendMessage(correlationId, Commands.HeartbeatResponse); } else if (instance.callbacks.hasOwnProperty(correlationId)) { // Handle the callback that was previously registered when the request was sent var callback = instance.callbacks[correlationId]; switch (command) { case Commands.SubscriptionConfirmation: case Commands.StreamEventAppeared: // This is a subscription event, we'll see more in future for the same correlation ID break; default: // This is the last message we expect to see for this correlation ID delete instance.callbacks[correlationId]; break; } var pkg = { command: command, flags: flags, data: data }; try { callback(pkg); } catch (x) { console.error("Callback for " + correlationId + " failed, unhooking.\r\n" + x); delete instance.callbacks[correlationId]; } } else { console.warn('Received ' + Commands.getCommandName(command) + ' message with unknown correlation ID: ' + correlationId); } } } /*** * Closes the connection to the Event Store */ TlsConnection.prototype.close = function() { this.connection.end(); }; /*** * Creates a Buffer containing a new v4 GUID * @returns {Buffer} */ TlsConnection.createGuid = function() { var buffer = new Buffer(GUID_LENGTH); uuidv4({}, buffer); return buffer; }; /*** * Sends a ping to the server and expects a pong response * @param callback Invoked when the corresponding pong is received from the server */ TlsConnection.prototype.sendPing = function(callback) { var correlationId = TlsConnection.createGuid(); this.sendMessage(correlationId, Commands.Ping, null, null, callback); }; /*** * Subscribes to a particular stream * @param streamName The name of the stream to subscribe to * @param resolveLinkTos True, if linked events should be resolved * @param onEventAppeared Invoked whenever an event is received via this subscription * @param onConfirmed Invoked when the subscription is confirmed (can be null) * @param onDropped Invoked when the subscription is dropped (such as when unsubscribing or when permission is denied, can be null) * @param credentials Credentials to use to access a protected stream (can be null for public streams) * @param onNotHandled Invoked when the subscription request is not handled, for reason NotReady - 0, TooBusy - 1, or NotMaster - 2 * @returns {Buffer} The correlation GUID for this subscription */ TlsConnection.prototype.subscribeToStream = function(streamName, resolveLinkTos, onEventAppeared, onConfirmed, onDropped, credentials, onNotHandled) { var subscribeRequest = new Messages.SubscribeToStream(streamName, resolveLinkTos); var data = subscribeRequest.encode().toBuffer(); var correlationId = TlsConnection.createGuid(); this.sendMessage(correlationId, Commands.SubscribeToStream, credentials, data, function(pkg) { switch (pkg.command) { case Commands.SubscriptionConfirmation: if (onConfirmed) { var confirmation = Messages.SubscriptionConfirmation.decode(pkg.data); onConfirmed(confirmation); } break; case Commands.SubscriptionDropped: if (onDropped) { var dropped = Messages.SubscriptionDropped.decode(pkg.data); onDropped(dropped); } break; case Commands.StreamEventAppeared: var eventAppeared = Messages.StreamEventAppeared.decode(pkg.data); var event = unpackResolvedEvent(eventAppeared.event); onEventAppeared(event); break; case Commands.NotAuthenticated: // This occurs when the username or password is incorrect // We'll just treat it the same as the subscription being dropped if (onDropped) { var dropped = new Messages.SubscriptionDropped(); dropped.reason = 1; // Access Denied onDropped(dropped); } break; case Commands.NotHandled: if (onNotHandled) { var notHandled = Messages.NotHandled.decode(pkg.data); onNotHandled(notHandled); } break; default: console.warn('TODO: Add support for parsing ' + Commands.getCommandName(pkg.command) + ' events'); break; } }); return correlationId; }; TlsConnection.prototype.unsubscribeFromStream = function(correlationId, credentials, callback) { var unsubscribeRequest = new Messages.UnsubscribeFromStream(); var data = unsubscribeRequest.encode().toBuffer(); this.sendMessage(correlationId, Commands.UnsubscribeFromStream, credentials, data, callback); }; /** * Initiate catch-up subscription for one stream. * * @param {string} streamId The stream ID (only if subscribing to a single stream) * @param {number} fromEventNumber Which event number to start after (if null, then from the beginning of the stream.) * @param {ICredentials} credentials User credentials for the operations. * @param {function} onEventAppeared Callback for each event received * @param {function} onLiveProcessingStarted Callback when read history phase finishes. * @param {function} onDropped Callback when subscription drops or is dropped. * @param {CatchUpSubscriptionSettings} settings Settings for this subscription. */ TlsConnection.prototype.subscribeToStreamFrom = function (streamId, fromEventNumber, credentials, onEventAppeared, onLiveProcessingStarted, onDropped, settings) { if (!settings) settings = new CatchUpSubscription.Settings(); var subscription = new CatchUpSubscription.Stream(this, streamId, fromEventNumber, credentials, onEventAppeared, onLiveProcessingStarted, onDropped, settings); subscription.start(); return subscription; }; TlsConnection.prototype.deleteStream = function(streamId, expectedVersion, requireMaster, hardDelete, credentials, callback) { var deleteRequest = new Messages.DeleteStream(); deleteRequest.eventStreamId = streamId; deleteRequest.expectedVersion = expectedVersion; deleteRequest.requireMaster = requireMaster; deleteRequest.hardDelete = hardDelete; var data = deleteRequest.encode().toBuffer(); function onFailure(result, errorText) { var message = new Messages.DeleteStreamCompleted(); message.result = result; message.message = errorText; callback(message); } function onError(errorText) { onFailure(OperationResult.Error, errorText); } var correlationId = TlsConnection.createGuid(); this.sendMessage(correlationId, Commands.DeleteStream, credentials, data, function(response) { var message; switch (response.command) { case Commands.DeleteStreamCompleted: message = Messages.DeleteStreamCompleted.decode(response.data); callback(message); break; case Commands.NotAuthenticated: onFailure(OperationResult.AccessDenied, "Not authenticated"); break; case Commands.NotHandled: message = Messages.NotHandled.decode(response.data); onError("Not handled: " + message.reason); break; case Commands.BadRequest: message = response.data; onError("Bad request: " + message); break; default: onError("Unexpected response: " + Commands.getCommandName(response.command)); break; } }); }; TlsConnection.prototype.readAllEventsBackward = function(commitPosition, preparePosition, maxCount, resolveLinkTos, requireMaster, onEventAppeared, credentials, callback) { return readAllEvents(this, Commands.ReadAllEventsBackward, commitPosition, preparePosition, maxCount, resolveLinkTos, requireMaster, onEventAppeared, credentials, callback); }; TlsConnection.prototype.readAllEventsForward = function(commitPosition, preparePosition, maxCount, resolveLinkTos, requireMaster, onEventAppeared, credentials, callback) { return readAllEvents(this, Commands.ReadAllEventsForward, commitPosition, preparePosition, maxCount, resolveLinkTos, requireMaster, onEventAppeared, credentials, callback); }; TlsConnection.prototype.readStreamEventsBackward = function(streamId, fromEventNumber, maxCount, resolveLinkTos, requireMaster, onEventAppeared, credentials, callback) { return readStreamEvents(this, Commands.ReadStreamEventsBackward, streamId, fromEventNumber, maxCount, resolveLinkTos, requireMaster, onEventAppeared, credentials, callback); }; TlsConnection.prototype.readStreamEventsForward = function(streamId, fromEventNumber, maxCount, resolveLinkTos, requireMaster, onEventAppeared, credentials, callback) { return readStreamEvents(this, Commands.ReadStreamEventsForward, streamId, fromEventNumber, maxCount, resolveLinkTos, requireMaster, onEventAppeared, credentials, callback); }; TlsConnection.prototype.writeEvents = function(streamId, expectedVersion, requireMaster, events, credentials, callback) { // Convert the data into the format required for the message for(var i=0; i<events.length; i++) { var event = events[i]; if (!event) { throw new Error("Event " + i + " is undefined or null"); } // Ensure that the event has an eventId GUID in the right format if (event.eventId) { if (typeof event.eventId == "string") { // GUID has been supplied as a hex string, convert it to a buffer var hex = event.eventId.replace(/[^0-9a-fA-F]/g, ""); if (hex.length != 32) { throw new Error("Event " + i + " does not have a valid GUID for eventId: " + hex); } event.eventId = new Buffer(hex, "hex"); } else if (!Buffer.isBuffer(event.eventId)) { // GUID is not a string or a Buffer throw new Error("Event " + i + " does not have a valid eventId. Expected a string or a Buffer."); } } else { throw new Error("Event " + i + " has no eventId specified"); } // Update the metadata for the event based on the supplied data if (event.data) { if (Buffer.isBuffer(event.data)) { // Binary data event.dataContentType = 0; } else { // JSON encode the nested objects event.dataContentType = 1; var json = JSON.stringify(event.data); event.data = new Buffer(json); } } else { event.dataContentType = 0; event.data = []; } if (event.metadata) { if (Buffer.isBuffer(event.metadata)) { // Binary metadata event.metadataContentType = 0; } else { // JSON encode the nested objects event.metadataContentType = 1; var json = JSON.stringify(event.metadata); event.metadata = new Buffer(json); } } else { event.metadataContentType = 0; event.metadata = null; } } var writeEvents = new Messages.WriteEvents(); writeEvents.eventStreamId = streamId; writeEvents.expectedVersion = expectedVersion; writeEvents.events = events; writeEvents.requireMaster = requireMaster; var data = writeEvents.encode().toBuffer(); function onFailure(result, errorText) { var message = new Messages.WriteEventsCompleted(); message.result = result; message.message = errorText; message.firstEventNumber = 0; message.lastEventNumber = 0; message.preparePosition = 0; message.commitPosition = 0; callback(message); } function onError(errorText) { onFailure(OperationResult.Error, errorText); } var correlationId = TlsConnection.createGuid(); this.sendMessage(correlationId, Commands.WriteEvents, credentials, data, function(response) { var message; switch (response.command) { case Commands.WriteEventsCompleted: message = Messages.WriteEventsCompleted.decode(response.data); callback(message); break; case Commands.NotAuthenticated: onFailure(OperationResult.AccessDenied, "Not authenticated"); break; case Commands.NotHandled: message = Messages.NotHandled.decode(response.data); onError("Not handled: " + message.reason); break; case Commands.BadRequest: message = response.data; onError("Bad request: " + message); break; default: onError("Unexpected response: " + Commands.getCommandName(response.command)); break; } }); return correlationId; }; TlsConnection.prototype.sendMessage = function(correlationId, command, credentials, data, callback) { if (typeof correlationId == "string") { correlationId = new Buffer(correlationId); } var key = correlationId.toString('hex'); if (callback) { this.callbacks[key] = callback; } // Handle authentication var authLength = 0; var flags = FLAGS_NONE; if (credentials) { flags = FLAGS_AUTH; // FIXME: Add support for multi-byte characters authLength = 1 + credentials.username.length + 1 + credentials.password.length; } var commandLength = HEADER_LENGTH + authLength; if (data) { commandLength += data.length; } var packetLength = 4 + commandLength; var buf = new Buffer(packetLength); // Command length (4 bytes) buf.writeUInt32LE(commandLength, 0); // TCP Command (1 byte) + TCP Flags (1 byte) buf[COMMAND_OFFSET] = command; buf[FLAGS_OFFSET] = flags; // Correlation ID (16 byte GUID) correlationId.copy(buf, CORRELATION_ID_OFFSET, 0, GUID_LENGTH); // User's credentials if (credentials) { buf.writeUInt8(credentials.username.length, DATA_OFFSET); buf.write(credentials.username, DATA_OFFSET + 1); buf.writeUInt8(credentials.password.length, DATA_OFFSET + 1 + credentials.username.length); buf.write(credentials.password, DATA_OFFSET + 1 + credentials.username.length + 1); } if (data) { data.copy(buf, DATA_OFFSET + authLength, 0, data.length); } if (this.debug) { console.log('Outbound: ' + buf.toString('hex') + ' (' + buf.length + ' bytes) ' + Commands.getCommandName(command)); } this.connection.write(buf); return key; }; function unpackResolvedEvent(resolvedEvent) { if (!resolvedEvent) { return null; } var unpackedEvent = {}; if (resolvedEvent.event) { unpackedEvent = unpackEventRecord(resolvedEvent.event); } if (resolvedEvent.link) unpackedEvent.link = unpackEventRecord(resolvedEvent.link); return unpackedEvent; } function uuidStringFromBuffer(buffer) { var hex = buffer.toString('hex'); var guid = hex.substring(6, 8) + hex.substring(4, 6) + hex.substring(2, 4) + hex.substring(0, 2) + "-" + hex.substring(10, 12) + hex.substring(8, 10) + "-" + hex.substring(14, 16) + hex.substring(12, 14) + "-" + hex.substring(16, 20) + "-" + hex.substring(20); return guid; } function unpackEventRecord(eventRecord) { if (!eventRecord) { return null; } if (!eventRecord.eventId) { throw new Error("Not an EventRecord: " + eventRecord); } var eventId = uuidStringFromBuffer(eventRecord.eventId); var event = { streamId: eventRecord.eventStreamId, eventNumber: eventRecord.eventNumber, eventId: eventId, eventType: eventRecord.eventType, isJson: false, created: new Date(parseInt(eventRecord.createdEpoch)) }; var data = eventRecord.data.toBuffer(); if (!data) { event.data = null; } else if (eventRecord.dataContentType === 1) { // alternatively check for open curly brace (data[0] == 0x7B) // JSON event.data = JSON.parse(data.toString()); event.isJson = true; } else { // Binary event.data = data; event.dataHex = data.toString('hex'); } event.metadata = parseMetadata(eventRecord); return event; } function parseMetadata(eventRecord) { var metadata = eventRecord.metadata.toBuffer(); if (metadata.length === 0) { return null; } // Metadata may be JSON or binary - EventStore does not honour the metadata_content_type field, // and will set it to the same value as the data_content_type field. try { var json = JSON.parse(metadata.toString()); return json } catch (err) { return metadata } } /*** * Loops through the ResolvedEvents in the provided array, unpacks each one from JSON data into a JS object, * then invokes the onEventAppeared callback for each event * @param events An array of ResolvedEvent messages * @param onEventAppeared A callback that takes an Event object */ function unpackAndNotifyResolvedEvents(events, onEventAppeared) { if (!events) { return []; } var unpackedEvents = []; for(var i=0; i<events.length; i++) { var event = unpackResolvedEvent(events[i]); unpackedEvents.push(event); if (onEventAppeared) { onEventAppeared(event); } } return unpackedEvents; } function readAllEvents(connection, command, commitPosition, preparePosition, maxCount, resolveLinkTos, requireMaster, onEventAppeared, credentials, callback) { var readAllEvents = new Messages.ReadAllEvents(); readAllEvents.commitPosition = commitPosition; readAllEvents.preparePosition = preparePosition; readAllEvents.maxCount = maxCount; readAllEvents.resolveLinkTos = resolveLinkTos; readAllEvents.requireMaster = requireMaster; var data = readAllEvents.encode().toBuffer(); function onFailure(result, errorText) { var readAllCompleted = new Messages.ReadAllEventsCompleted(); readAllCompleted.commitPosition = 0; readAllCompleted.preparePosition = 0; readAllCompleted.events = []; readAllCompleted.nextCommitPosition = 0; readAllCompleted.nextPreparePosition = 0; readAllCompleted.result = result; readAllCompleted.error = errorText; callback(readAllCompleted); } function onError(errorText) { onFailure(ReadAllResult.Error, errorText); } function onNotAuthenticated() { onFailure(ReadAllResult.AccessDenied, "Not authenticated"); } var correlationId = TlsConnection.createGuid(); connection.sendMessage(correlationId, command, credentials, data, function(response) { handleReadResponse(response, callback, onEventAppeared, onNotAuthenticated, onError); }); return correlationId; } function handleReadResponse(response, onSuccess, onEventAppeared, onNotAuthenticated, onError) { var message; switch (response.command) { case Commands.ReadAllEventsBackwardCompleted: case Commands.ReadAllEventsForwardCompleted: message = Messages.ReadAllEventsCompleted.decode(response.data); message.events = unpackAndNotifyResolvedEvents(message.events, onEventAppeared); onSuccess(message); break; case Commands.ReadStreamEventsBackwardCompleted: case Commands.ReadStreamEventsForwardCompleted: message = Messages.ReadStreamEventsCompleted.decode(response.data); message.events = unpackAndNotifyResolvedEvents(message.events, onEventAppeared); onSuccess(message); break; case Commands.NotAuthenticated: onNotAuthenticated(); break; case Commands.NotHandled: message = Messages.NotHandled.decode(response.data); onError("Not handled: " + message.reason); break; case Commands.BadRequest: message = response.data; onError("Bad request: " + message); break; default: onError("Unexpected response: " + Commands.getCommandName(response.command)); break; } } function readStreamEvents(connection, command, streamId, fromEventNumber, maxCount, resolveLinkTos, requireMaster, onEventAppeared, credentials, callback) { var readStreamEvents = new Messages.ReadStreamEvents(); readStreamEvents.eventStreamId = streamId; readStreamEvents.fromEventNumber = fromEventNumber; readStreamEvents.maxCount = maxCount; readStreamEvents.resolveLinkTos = resolveLinkTos; readStreamEvents.requireMaster = requireMaster; var data = readStreamEvents.encode().toBuffer(); function onFailure(result, errorText) { var readStreamCompleted = new Messages.ReadStreamEventsCompleted(); readStreamCompleted.events = []; readStreamCompleted.result = result; readStreamCompleted.nextEventNumber = 0; readStreamCompleted.lastEventNumber = 0; readStreamCompleted.isEndOfStream = true; readStreamCompleted.lastCommitPosition = 0; readStreamCompleted.error = errorText; callback(readStreamCompleted); } function onError(errorText) { onFailure(ReadStreamResult.Error, errorText); } function onNotAuthenticated() { onFailure(ReadStreamResult.AccessDenied, "Not authenticated"); } var correlationId = TlsConnection.createGuid(); connection.sendMessage(correlationId, command, credentials, data, function(response) { handleReadResponse(response, callback, onEventAppeared, onNotAuthenticated, onError); }); return correlationId; } module.exports = TlsConnection;